f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
12278 lines
407 KiB
Go
12278 lines
407 KiB
Go
package sync_test
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
gosync "sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.kenn.io/agentsview/internal/db"
|
|
"go.kenn.io/agentsview/internal/dbtest"
|
|
"go.kenn.io/agentsview/internal/parser"
|
|
"go.kenn.io/agentsview/internal/sync"
|
|
"go.kenn.io/agentsview/internal/testjsonl"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
type testEnv struct {
|
|
claudeDir string
|
|
codexDir string
|
|
cursorDir string
|
|
geminiDir string
|
|
opencodeDir string
|
|
kiloDir string
|
|
mimocodeDir string
|
|
forgeDir string
|
|
piebaldDir string
|
|
warpDir string
|
|
iflowDir string
|
|
ampDir string
|
|
piDir string
|
|
ompDir string
|
|
kiroDir string
|
|
shelleyDir string
|
|
windsurfDir string
|
|
antigravityCLIDir string
|
|
db *db.DB
|
|
engine *sync.Engine
|
|
}
|
|
|
|
type testEnvOpts struct {
|
|
claudeDirs []string
|
|
codexDirs []string
|
|
cursorDirs []string
|
|
opencodeDirs []string
|
|
kiloDirs []string
|
|
kiroDirs []string
|
|
emitter sync.Emitter
|
|
}
|
|
|
|
type TestEnvOption func(*testEnvOpts)
|
|
|
|
func WithClaudeDirs(dirs []string) TestEnvOption {
|
|
return func(o *testEnvOpts) {
|
|
o.claudeDirs = dirs
|
|
}
|
|
}
|
|
|
|
func WithCodexDirs(dirs []string) TestEnvOption {
|
|
return func(o *testEnvOpts) {
|
|
o.codexDirs = dirs
|
|
}
|
|
}
|
|
|
|
func WithCursorDirs(dirs []string) TestEnvOption {
|
|
return func(o *testEnvOpts) {
|
|
o.cursorDirs = dirs
|
|
}
|
|
}
|
|
|
|
func WithOpenCodeDirs(dirs []string) TestEnvOption {
|
|
return func(o *testEnvOpts) {
|
|
o.opencodeDirs = dirs
|
|
}
|
|
}
|
|
|
|
func WithKiloDirs(dirs []string) TestEnvOption {
|
|
return func(o *testEnvOpts) {
|
|
o.kiloDirs = dirs
|
|
}
|
|
}
|
|
|
|
func WithKiroDirs(dirs []string) TestEnvOption {
|
|
return func(o *testEnvOpts) {
|
|
o.kiroDirs = dirs
|
|
}
|
|
}
|
|
|
|
func WithEmitter(em sync.Emitter) TestEnvOption {
|
|
return func(o *testEnvOpts) {
|
|
o.emitter = em
|
|
}
|
|
}
|
|
|
|
func setupTestEnv(t *testing.T, opts ...TestEnvOption) *testEnv {
|
|
t.Helper()
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test")
|
|
}
|
|
|
|
options := testEnvOpts{}
|
|
for _, opt := range opts {
|
|
opt(&options)
|
|
}
|
|
|
|
env := &testEnv{
|
|
geminiDir: t.TempDir(),
|
|
mimocodeDir: t.TempDir(),
|
|
forgeDir: t.TempDir(),
|
|
piebaldDir: t.TempDir(),
|
|
warpDir: t.TempDir(),
|
|
iflowDir: t.TempDir(),
|
|
ampDir: t.TempDir(),
|
|
piDir: t.TempDir(),
|
|
ompDir: t.TempDir(),
|
|
shelleyDir: t.TempDir(),
|
|
antigravityCLIDir: t.TempDir(),
|
|
db: dbtest.OpenTestDB(t),
|
|
}
|
|
|
|
claudeDirs := options.claudeDirs
|
|
if len(claudeDirs) == 0 {
|
|
env.claudeDir = t.TempDir()
|
|
claudeDirs = []string{env.claudeDir}
|
|
} else {
|
|
env.claudeDir = claudeDirs[0]
|
|
}
|
|
|
|
codexDirs := options.codexDirs
|
|
if len(codexDirs) == 0 {
|
|
env.codexDir = t.TempDir()
|
|
codexDirs = []string{env.codexDir}
|
|
} else {
|
|
env.codexDir = codexDirs[0]
|
|
}
|
|
|
|
cursorDirs := options.cursorDirs
|
|
if len(cursorDirs) == 0 {
|
|
env.cursorDir = t.TempDir()
|
|
cursorDirs = []string{env.cursorDir}
|
|
} else {
|
|
env.cursorDir = cursorDirs[0]
|
|
}
|
|
|
|
opencodeDirs := options.opencodeDirs
|
|
if len(opencodeDirs) == 0 {
|
|
env.opencodeDir = t.TempDir()
|
|
opencodeDirs = []string{env.opencodeDir}
|
|
} else {
|
|
env.opencodeDir = opencodeDirs[0]
|
|
}
|
|
|
|
kiloDirs := options.kiloDirs
|
|
if len(kiloDirs) == 0 {
|
|
env.kiloDir = t.TempDir()
|
|
kiloDirs = []string{env.kiloDir}
|
|
} else {
|
|
env.kiloDir = kiloDirs[0]
|
|
}
|
|
|
|
kiroDirs := options.kiroDirs
|
|
if len(kiroDirs) == 0 {
|
|
env.kiroDir = t.TempDir()
|
|
kiroDirs = []string{env.kiroDir}
|
|
} else {
|
|
env.kiroDir = kiroDirs[0]
|
|
}
|
|
|
|
env.engine = sync.NewEngine(env.db, sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentClaude: claudeDirs,
|
|
parser.AgentCodex: codexDirs,
|
|
parser.AgentCursor: cursorDirs,
|
|
parser.AgentGemini: {env.geminiDir},
|
|
parser.AgentOpenCode: opencodeDirs,
|
|
parser.AgentKilo: kiloDirs,
|
|
parser.AgentMiMoCode: {env.mimocodeDir},
|
|
parser.AgentForge: {env.forgeDir},
|
|
parser.AgentPiebald: {env.piebaldDir},
|
|
parser.AgentWarp: {env.warpDir},
|
|
parser.AgentIflow: {env.iflowDir},
|
|
parser.AgentAmp: {env.ampDir},
|
|
parser.AgentPi: {env.piDir},
|
|
parser.AgentOMP: {env.ompDir},
|
|
parser.AgentKiro: kiroDirs,
|
|
parser.AgentShelley: {env.shelleyDir},
|
|
parser.AgentAntigravityCLI: {env.antigravityCLIDir},
|
|
},
|
|
Machine: "local",
|
|
Emitter: options.emitter,
|
|
})
|
|
return env
|
|
}
|
|
|
|
func setupFocusedTestEnv(t *testing.T, agents ...parser.AgentType) *testEnv {
|
|
t.Helper()
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test")
|
|
}
|
|
|
|
env := &testEnv{db: dbtest.OpenTestDB(t)}
|
|
agentDirs := make(map[parser.AgentType][]string, len(agents))
|
|
for _, agent := range agents {
|
|
dir := t.TempDir()
|
|
assignFocusedAgentDir(t, env, agent, dir)
|
|
agentDirs[agent] = []string{dir}
|
|
}
|
|
|
|
env.engine = sync.NewEngine(env.db, sync.EngineConfig{
|
|
AgentDirs: agentDirs,
|
|
Machine: "local",
|
|
})
|
|
return env
|
|
}
|
|
|
|
func setupSingleAgentTestEnv(t *testing.T, agent parser.AgentType) *testEnv {
|
|
t.Helper()
|
|
return setupFocusedTestEnv(t, agent)
|
|
}
|
|
|
|
func setupSingleAgentTestEnvWithDirs(
|
|
t *testing.T, agent parser.AgentType, dirs []string,
|
|
) *testEnv {
|
|
t.Helper()
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test")
|
|
}
|
|
require.NotEmpty(t, dirs, "focused fixture dirs")
|
|
|
|
env := &testEnv{db: dbtest.OpenTestDB(t)}
|
|
assignFocusedAgentDir(t, env, agent, dirs[0])
|
|
env.engine = sync.NewEngine(env.db, sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
agent: dirs,
|
|
},
|
|
Machine: "local",
|
|
})
|
|
return env
|
|
}
|
|
|
|
func assignFocusedAgentDir(
|
|
t *testing.T, env *testEnv, agent parser.AgentType, dir string,
|
|
) {
|
|
t.Helper()
|
|
switch agent {
|
|
case parser.AgentClaude:
|
|
env.claudeDir = dir
|
|
case parser.AgentCodex:
|
|
env.codexDir = dir
|
|
case parser.AgentOpenCode:
|
|
env.opencodeDir = dir
|
|
case parser.AgentGemini:
|
|
env.geminiDir = dir
|
|
case parser.AgentKilo:
|
|
env.kiloDir = dir
|
|
case parser.AgentMiMoCode:
|
|
env.mimocodeDir = dir
|
|
case parser.AgentPiebald:
|
|
env.piebaldDir = dir
|
|
case parser.AgentForge:
|
|
env.forgeDir = dir
|
|
case parser.AgentWarp:
|
|
env.warpDir = dir
|
|
case parser.AgentPi:
|
|
env.piDir = dir
|
|
case parser.AgentOMP:
|
|
env.ompDir = dir
|
|
case parser.AgentKiro:
|
|
env.kiroDir = dir
|
|
case parser.AgentShelley:
|
|
env.shelleyDir = dir
|
|
case parser.AgentWindsurf:
|
|
env.windsurfDir = dir
|
|
case parser.AgentAntigravityCLI:
|
|
env.antigravityCLIDir = dir
|
|
default:
|
|
t.Fatalf("unsupported focused test fixture for %s", agent)
|
|
}
|
|
}
|
|
|
|
func TestGrokSummaryCountsSurviveSync(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test")
|
|
}
|
|
root := t.TempDir()
|
|
summaryPath := filepath.Join(
|
|
root,
|
|
"cwd-key",
|
|
"sess-1",
|
|
"summary.json",
|
|
)
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(summaryPath), 0o755))
|
|
require.NoError(t, os.WriteFile(summaryPath, []byte(`{
|
|
"summary":"Preserve Grok counts",
|
|
"firstPrompt":"resume the build",
|
|
"modelId":"grok-code-fast",
|
|
"createdAt":"2026-07-08T10:00:00Z",
|
|
"updatedAt":"2026-07-08T10:30:00Z",
|
|
"lastActiveAt":"2026-07-08T10:31:00Z",
|
|
"numMessages":6,
|
|
"worktreeLabel":"agentsview"
|
|
}`), 0o644))
|
|
|
|
database := dbtest.OpenTestDB(t)
|
|
engine := sync.NewEngine(database, sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentGrok: {root},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
stats := engine.SyncAll(context.Background(), nil)
|
|
require.GreaterOrEqual(t, stats.Synced, 1)
|
|
|
|
sess, err := database.GetSession(context.Background(), "grok:sess-1")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
assert.Equal(t, 6, sess.MessageCount)
|
|
assert.Equal(t, 1, sess.UserMessageCount)
|
|
|
|
promptSearch, err := database.Search(context.Background(), db.SearchFilter{
|
|
Query: "resume the build",
|
|
Limit: 5,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, promptSearch.Results, 1)
|
|
assert.Equal(t, "grok:sess-1", promptSearch.Results[0].SessionID)
|
|
|
|
nameSearch, err := database.Search(context.Background(), db.SearchFilter{
|
|
Query: "Preserve Grok counts",
|
|
Limit: 5,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, nameSearch.Results, 1)
|
|
assert.Equal(t, "grok:sess-1", nameSearch.Results[0].SessionID)
|
|
}
|
|
|
|
type openCodeFamilySQLiteCase struct {
|
|
name string
|
|
agent parser.AgentType
|
|
dbName string
|
|
prefix string
|
|
}
|
|
|
|
func openCodeFamilySQLiteCases() []openCodeFamilySQLiteCase {
|
|
return []openCodeFamilySQLiteCase{
|
|
{
|
|
name: "opencode", agent: parser.AgentOpenCode,
|
|
dbName: "opencode.db", prefix: "opencode:",
|
|
},
|
|
{
|
|
name: "kilo", agent: parser.AgentKilo,
|
|
dbName: "kilo.db", prefix: "kilo:",
|
|
},
|
|
{
|
|
name: "mimocode", agent: parser.AgentMiMoCode,
|
|
dbName: "mimocode.db", prefix: "mimocode:",
|
|
},
|
|
{
|
|
name: "icodemate", agent: parser.AgentIcodemate,
|
|
dbName: "icodemate.db", prefix: "icodemate:",
|
|
},
|
|
}
|
|
}
|
|
|
|
func newOpenCodeFamilySQLiteTestEngine(
|
|
t *testing.T,
|
|
agent parser.AgentType,
|
|
root string,
|
|
database *db.DB,
|
|
) *sync.Engine {
|
|
t.Helper()
|
|
return sync.NewEngine(database, sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
agent: {root},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
}
|
|
|
|
func seedOpenCodeSQLiteTextSession(
|
|
t *testing.T,
|
|
oc *openCodeTestDB,
|
|
projectID, sessionID string,
|
|
timeCreated, timeUpdated int64,
|
|
userContent, assistantContent string,
|
|
) {
|
|
t.Helper()
|
|
oc.addSession(t, sessionID, projectID, timeCreated, timeUpdated)
|
|
userMessageID := sessionID + "-msg-user"
|
|
assistantMessageID := sessionID + "-msg-assistant"
|
|
oc.addMessage(t, userMessageID, sessionID, "user", timeCreated)
|
|
oc.addMessage(
|
|
t, assistantMessageID, sessionID, "assistant", timeCreated+1,
|
|
)
|
|
oc.addTextPart(
|
|
t, userMessageID+"-part", sessionID, userMessageID,
|
|
userContent, timeCreated,
|
|
)
|
|
oc.addTextPart(
|
|
t, assistantMessageID+"-part", sessionID,
|
|
assistantMessageID, assistantContent, timeCreated+1,
|
|
)
|
|
}
|
|
|
|
func openCodeLocalModifiedSnapshot(
|
|
t *testing.T,
|
|
database *db.DB,
|
|
sessionIDs ...string,
|
|
) map[string]string {
|
|
t.Helper()
|
|
out := make(map[string]string, len(sessionIDs))
|
|
for _, sessionID := range sessionIDs {
|
|
sess, err := database.GetSessionFull(context.Background(), sessionID)
|
|
require.NoError(t, err, "GetSessionFull(%q)", sessionID)
|
|
require.NotNil(t, sess, "session %q not found", sessionID)
|
|
require.NotNil(t, sess.LocalModifiedAt,
|
|
"local_modified_at for %q", sessionID)
|
|
out[sessionID] = *sess.LocalModifiedAt
|
|
}
|
|
return out
|
|
}
|
|
|
|
func openCodeStoredSession(
|
|
t *testing.T,
|
|
database *db.DB,
|
|
sessionID string,
|
|
) *db.Session {
|
|
t.Helper()
|
|
sess, err := database.GetSessionFull(context.Background(), sessionID)
|
|
require.NoError(t, err, "GetSessionFull(%q)", sessionID)
|
|
require.NotNil(t, sess, "session %q not found", sessionID)
|
|
return sess
|
|
}
|
|
|
|
func setFileMtime(t *testing.T, path string, mtimeNS int64) {
|
|
t.Helper()
|
|
ts := time.Unix(0, mtimeNS)
|
|
require.NoError(t, os.Chtimes(path, ts, ts), "chtimes %s", path)
|
|
}
|
|
|
|
// TestSyncPathsContextCancelledAbortsWithoutWriting pins shutdown
|
|
// responsiveness for watcher-driven syncs: SyncPathsContext must honor its
|
|
// context and abort before writing, instead of running the sync to
|
|
// completion while Watcher.Stop (and therefore SIGTERM shutdown) waits on
|
|
// it. The same paths sync fine once the context is live again.
|
|
func TestSyncPathsContextCancelledAbortsWithoutWriting(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
storage := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
const sessionID = "oc-cancelled-sync"
|
|
sessionPath := storage.addSession(
|
|
t, "global", sessionID,
|
|
"/home/user/code/oc-app", "Cancelled Sync",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
storage.addMessage(
|
|
t, sessionID, "msg-a1", "assistant",
|
|
1704067201000, nil,
|
|
)
|
|
storage.addTextPart(
|
|
t, sessionID, "msg-a1", "part-a1",
|
|
"cancelled sync reply", 1704067201000,
|
|
)
|
|
|
|
cancelled, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
env.engine.SyncPathsContext(cancelled, []string{sessionPath})
|
|
|
|
sess, err := env.db.GetSessionFull(
|
|
context.Background(), "opencode:"+sessionID,
|
|
)
|
|
require.NoError(t, err, "GetSessionFull after cancelled sync")
|
|
assert.Nil(t, sess,
|
|
"a cancelled watcher sync must not write the session")
|
|
|
|
env.engine.SyncPathsContext(
|
|
context.Background(), []string{sessionPath},
|
|
)
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+sessionID, "cancelled sync reply",
|
|
)
|
|
}
|
|
|
|
func TestSyncEngineOpenCodeFamilySQLiteDropsUnchangedContainerSessions(
|
|
t *testing.T,
|
|
) {
|
|
for _, tt := range openCodeFamilySQLiteCases() {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
root := t.TempDir()
|
|
database := dbtest.OpenTestDB(t)
|
|
engine := newOpenCodeFamilySQLiteTestEngine(
|
|
t, tt.agent, root, database,
|
|
)
|
|
oc := createOpenCodeLikeDB(
|
|
t, filepath.Join(root, tt.dbName), tt.name,
|
|
)
|
|
oc.addProject(t, "proj", "/home/user/code/opencode-app")
|
|
seedOpenCodeSQLiteTextSession(
|
|
t, oc, "proj", "stable-one",
|
|
1779012000000, 1779012030000,
|
|
"first prompt", "first answer",
|
|
)
|
|
seedOpenCodeSQLiteTextSession(
|
|
t, oc, "proj", "stable-two",
|
|
1779012100000, 1779012130000,
|
|
"second prompt", "second answer",
|
|
)
|
|
|
|
stats := engine.SyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "first sync aborted: %+v", stats)
|
|
assert.Equal(t, 2, stats.Synced, "first sync writes both sessions")
|
|
|
|
sessionIDs := []string{
|
|
tt.prefix + "stable-one",
|
|
tt.prefix + "stable-two",
|
|
}
|
|
before := openCodeLocalModifiedSnapshot(
|
|
t, database, sessionIDs...,
|
|
)
|
|
|
|
time.Sleep(20 * time.Millisecond)
|
|
stats = engine.SyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "second sync aborted: %+v", stats)
|
|
assert.Equal(t, 0, stats.Synced,
|
|
"unchanged %s SQLite container sessions must be dropped", tt.name)
|
|
|
|
after := openCodeLocalModifiedSnapshot(
|
|
t, database, sessionIDs...,
|
|
)
|
|
assert.Equal(t, before, after,
|
|
"unchanged %s rows must not be rewritten", tt.name)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSyncEngineOpenCodeSQLiteDropsOnlyUnchangedContainerRows(
|
|
t *testing.T,
|
|
) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
oc := createOpenCodeDB(t, env.opencodeDir)
|
|
oc.addProject(t, "proj", "/home/user/code/opencode-app")
|
|
seedOpenCodeSQLiteTextSession(
|
|
t, oc, "proj", "changed-session",
|
|
1779012000000, 1779012030000,
|
|
"original prompt", "original answer",
|
|
)
|
|
seedOpenCodeSQLiteTextSession(
|
|
t, oc, "proj", "stable-session",
|
|
1779012100000, 1779012130000,
|
|
"stable prompt", "stable answer",
|
|
)
|
|
|
|
stats := env.engine.SyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "first sync aborted: %+v", stats)
|
|
assert.Equal(t, 2, stats.Synced, "first sync writes both sessions")
|
|
|
|
changedID := "opencode:changed-session"
|
|
stableID := "opencode:stable-session"
|
|
before := openCodeLocalModifiedSnapshot(
|
|
t, env.db, changedID, stableID,
|
|
)
|
|
|
|
time.Sleep(20 * time.Millisecond)
|
|
oc.updateSessionTime(t, "changed-session", 1779015630000)
|
|
oc.replaceTextContent(
|
|
t, "changed-session",
|
|
"changed prompt", "changed answer",
|
|
1779015600000,
|
|
)
|
|
|
|
stats = env.engine.SyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "second sync aborted: %+v", stats)
|
|
assert.Equal(t, 1, stats.Synced,
|
|
"only the changed OpenCode SQLite row should be rewritten")
|
|
assertMessageContent(
|
|
t, env.db, changedID, "changed prompt", "changed answer",
|
|
)
|
|
|
|
after := openCodeLocalModifiedSnapshot(
|
|
t, env.db, changedID, stableID,
|
|
)
|
|
assert.NotEqual(t, before[changedID], after[changedID],
|
|
"changed row must be rewritten")
|
|
assert.Equal(t, before[stableID], after[stableID],
|
|
"unchanged sibling row must not be rewritten")
|
|
assertMessageContent(
|
|
t, env.db, stableID, "stable prompt", "stable answer",
|
|
)
|
|
}
|
|
|
|
func TestSyncEngineOpenCodeSQLiteSameMtimeContentChangeUsesFingerprint(
|
|
t *testing.T,
|
|
) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
oc := createOpenCodeDB(t, env.opencodeDir)
|
|
oc.addProject(t, "proj", "/home/user/code/opencode-app")
|
|
seedOpenCodeSQLiteTextSession(
|
|
t, oc, "proj", "same-mtime-sqlite",
|
|
1779012000000, 1779012030000,
|
|
"original prompt", "original answer",
|
|
)
|
|
|
|
stats := env.engine.SyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "first sync aborted: %+v", stats)
|
|
assert.Equal(t, 1, stats.Synced, "first sync writes the SQLite session")
|
|
sessionID := "opencode:same-mtime-sqlite"
|
|
assertMessageContent(
|
|
t, env.db, sessionID, "original prompt", "original answer",
|
|
)
|
|
before := openCodeStoredSession(t, env.db, sessionID)
|
|
require.NotNil(t, before.FileMtime, "file_mtime before rewrite")
|
|
require.NotNil(t, before.FileHash, "file_hash before rewrite")
|
|
require.NotNil(t, before.LocalModifiedAt,
|
|
"local_modified_at before rewrite")
|
|
|
|
time.Sleep(20 * time.Millisecond)
|
|
oc.replaceTextContent(
|
|
t, "same-mtime-sqlite",
|
|
"changed prompt with same session mtime",
|
|
"changed answer with same session mtime",
|
|
1779012000000,
|
|
)
|
|
|
|
stats = env.engine.SyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "second sync aborted: %+v", stats)
|
|
assert.Equal(t, 1, stats.Synced,
|
|
"same-mtime SQLite fingerprint changes must be rewritten")
|
|
assertMessageContent(
|
|
t, env.db, sessionID,
|
|
"changed prompt with same session mtime",
|
|
"changed answer with same session mtime",
|
|
)
|
|
after := openCodeStoredSession(t, env.db, sessionID)
|
|
require.NotNil(t, after.FileMtime, "file_mtime after rewrite")
|
|
require.NotNil(t, after.FileHash, "file_hash after rewrite")
|
|
require.NotNil(t, after.LocalModifiedAt,
|
|
"local_modified_at after rewrite")
|
|
assert.Equal(t, *before.FileMtime, *after.FileMtime,
|
|
"same-mtime rewrite keeps the OpenCode SQLite session mtime")
|
|
assert.NotEqual(t, *before.FileHash, *after.FileHash,
|
|
"changed SQLite child content must change the storage fingerprint")
|
|
assert.Greater(t, *after.LocalModifiedAt, *before.LocalModifiedAt,
|
|
"successful rewrite must bump local_modified_at for push windows")
|
|
}
|
|
|
|
// TestSyncEngineOpenCodeSQLiteUntouchedContainerSkipsReparse pins the
|
|
// container-level freshness gate: when the shared opencode.db file is
|
|
// completely untouched since the last verified sync, its sessions must be
|
|
// counted as skipped (no per-session fingerprint, no parse) instead of
|
|
// being re-parsed and dropped as unchanged after the fact.
|
|
func TestSyncEngineOpenCodeSQLiteUntouchedContainerSkipsReparse(
|
|
t *testing.T,
|
|
) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
oc := createOpenCodeDB(t, env.opencodeDir)
|
|
oc.addProject(t, "proj", "/home/user/code/opencode-app")
|
|
seedOpenCodeSQLiteTextSession(
|
|
t, oc, "proj", "gated-one",
|
|
1779012000000, 1779012030000,
|
|
"first prompt", "first answer",
|
|
)
|
|
seedOpenCodeSQLiteTextSession(
|
|
t, oc, "proj", "gated-two",
|
|
1779012100000, 1779012130000,
|
|
"second prompt", "second answer",
|
|
)
|
|
|
|
stats := env.engine.SyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "first sync aborted: %+v", stats)
|
|
assert.Equal(t, 2, stats.Synced, "first sync writes both sessions")
|
|
|
|
stats = env.engine.SyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "second sync aborted: %+v", stats)
|
|
assert.Equal(t, 0, stats.Synced,
|
|
"untouched container must not re-emit sessions")
|
|
assert.Equal(t, 2, stats.Skipped,
|
|
"untouched container sessions must skip before parse")
|
|
assertMessageContent(
|
|
t, env.db, "opencode:gated-one",
|
|
"first prompt", "first answer",
|
|
)
|
|
assertMessageContent(
|
|
t, env.db, "opencode:gated-two",
|
|
"second prompt", "second answer",
|
|
)
|
|
}
|
|
|
|
// TestSyncEngineOpenCodeSQLiteStatIdenticalContentChangeStillReemits pins
|
|
// the gate's safety boundary: file size and mtime equality alone must never
|
|
// be trusted (mtime granularity differs across filesystems, and a rewrite
|
|
// can land within one granularity unit). A content change that leaves the
|
|
// container stat-identical — same size, mtime restored — still changes
|
|
// SQLite's own write counters, so the sessions must be re-parsed and the
|
|
// changed content re-emitted.
|
|
func TestSyncEngineOpenCodeSQLiteStatIdenticalContentChangeStillReemits(
|
|
t *testing.T,
|
|
) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
oc := createOpenCodeDB(t, env.opencodeDir)
|
|
oc.addProject(t, "proj", "/home/user/code/opencode-app")
|
|
seedOpenCodeSQLiteTextSession(
|
|
t, oc, "proj", "stat-twin",
|
|
1779012000000, 1779012030000,
|
|
"original prompt", "original answer",
|
|
)
|
|
|
|
stats := env.engine.SyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "first sync aborted: %+v", stats)
|
|
assert.Equal(t, 1, stats.Synced, "first sync writes the session")
|
|
|
|
dbPath := filepath.Join(env.opencodeDir, "opencode.db")
|
|
before, err := os.Stat(dbPath)
|
|
require.NoError(t, err, "stat opencode.db")
|
|
|
|
time.Sleep(20 * time.Millisecond)
|
|
// Same-length replacement content keeps the SQLite file size stable, and
|
|
// the mtime is restored below, so only SQLite's internal change counter
|
|
// betrays the rewrite.
|
|
oc.replaceTextContent(
|
|
t, "stat-twin",
|
|
"replaced prompt", "replaced answer",
|
|
1779012000000,
|
|
)
|
|
after, err := os.Stat(dbPath)
|
|
require.NoError(t, err, "stat opencode.db after rewrite")
|
|
require.Equal(t, before.Size(), after.Size(),
|
|
"fixture must keep the container size stable for this test")
|
|
setFileMtime(t, dbPath, before.ModTime().UnixNano())
|
|
|
|
stats = env.engine.SyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "second sync aborted: %+v", stats)
|
|
assert.Equal(t, 1, stats.Synced,
|
|
"stat-identical content change must still be re-emitted")
|
|
assertMessageContent(
|
|
t, env.db, "opencode:stat-twin",
|
|
"replaced prompt", "replaced answer",
|
|
)
|
|
}
|
|
|
|
// TestSyncEngineOpenCodeSQLiteWALOnlyChangeStillReemits pins the gate's WAL
|
|
// awareness: OpenCode runs its database in WAL mode, where a committed write
|
|
// only appends frames to opencode.db-wal and leaves the main file's size,
|
|
// mtime, and header change counter untouched until a checkpoint. A change
|
|
// that lands only in the WAL must still be re-parsed and re-emitted.
|
|
func TestSyncEngineOpenCodeSQLiteWALOnlyChangeStillReemits(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
oc := createOpenCodeDB(t, env.opencodeDir)
|
|
oc.mustExec(t, "enable WAL", "PRAGMA journal_mode=WAL")
|
|
oc.addProject(t, "proj", "/home/user/code/opencode-app")
|
|
seedOpenCodeSQLiteTextSession(
|
|
t, oc, "proj", "wal-session",
|
|
1779012000000, 1779012030000,
|
|
"original prompt", "original answer",
|
|
)
|
|
|
|
stats := env.engine.SyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "first sync aborted: %+v", stats)
|
|
assert.Equal(t, 1, stats.Synced, "first sync writes the session")
|
|
|
|
dbPath := filepath.Join(env.opencodeDir, "opencode.db")
|
|
before, err := os.Stat(dbPath)
|
|
require.NoError(t, err, "stat opencode.db")
|
|
|
|
time.Sleep(20 * time.Millisecond)
|
|
oc.updateSessionTime(t, "wal-session", 1779015630000)
|
|
oc.replaceTextContent(
|
|
t, "wal-session",
|
|
"changed prompt", "changed answer",
|
|
1779015600000,
|
|
)
|
|
// The commit must have landed in the WAL only; a main-file change would
|
|
// mean this test no longer exercises the WAL-awareness of the gate.
|
|
after, err := os.Stat(dbPath)
|
|
require.NoError(t, err, "stat opencode.db after WAL write")
|
|
require.Equal(t, before.Size(), after.Size(),
|
|
"main DB file size must stay untouched by a WAL-mode commit")
|
|
require.Equal(t, before.ModTime(), after.ModTime(),
|
|
"main DB file mtime must stay untouched by a WAL-mode commit")
|
|
|
|
stats = env.engine.SyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "second sync aborted: %+v", stats)
|
|
assert.Equal(t, 1, stats.Synced,
|
|
"a WAL-only container change must still be re-emitted")
|
|
assertMessageContent(
|
|
t, env.db, "opencode:wal-session",
|
|
"changed prompt", "changed answer",
|
|
)
|
|
}
|
|
|
|
// TestSyncEngineOpenCodeSQLiteCwdFilteredContainerStaysUntrusted pins the
|
|
// promotion invariant against the cwd allow-list: a session that parses but
|
|
// is vetoed by the filter was deliberately not persisted, so its container
|
|
// was not fully verified and must never be trusted. A later sync must
|
|
// re-verify the container (no gate skips) instead of hiding the vetoed
|
|
// session behind the trusted state.
|
|
func TestSyncEngineOpenCodeSQLiteCwdFilteredContainerStaysUntrusted(
|
|
t *testing.T,
|
|
) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test")
|
|
}
|
|
root := t.TempDir()
|
|
database := dbtest.OpenTestDB(t)
|
|
engine := sync.NewEngine(database, sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentOpenCode: {root},
|
|
},
|
|
Machine: "local",
|
|
IncludeCwdPrefixes: []string{"/home/user/code/keep-app"},
|
|
})
|
|
oc := createOpenCodeDB(t, root)
|
|
oc.addProject(t, "proj-keep", "/home/user/code/keep-app")
|
|
oc.addProject(t, "proj-drop", "/home/user/code/drop-app")
|
|
seedOpenCodeSQLiteTextSession(
|
|
t, oc, "proj-keep", "keep-session",
|
|
1779012000000, 1779012030000,
|
|
"keep prompt", "keep answer",
|
|
)
|
|
seedOpenCodeSQLiteTextSession(
|
|
t, oc, "proj-drop", "drop-session",
|
|
1779012100000, 1779012130000,
|
|
"drop prompt", "drop answer",
|
|
)
|
|
|
|
stats := engine.SyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "first sync aborted: %+v", stats)
|
|
assert.Equal(t, 1, stats.Synced, "only the allowed session is written")
|
|
|
|
stats = engine.SyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "second sync aborted: %+v", stats)
|
|
assert.Equal(t, 0, stats.Skipped,
|
|
"a container with cwd-vetoed sessions must not be gate-skipped")
|
|
|
|
kept, err := database.GetSessionFull(
|
|
context.Background(), "opencode:keep-session",
|
|
)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, kept, "allowed session must be archived")
|
|
dropped, err := database.GetSessionFull(
|
|
context.Background(), "opencode:drop-session",
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Nil(t, dropped, "vetoed session must stay out of the archive")
|
|
}
|
|
|
|
// TestSyncEngineOpenCodeSQLiteCutoffPassMustNotTrustContainer guards the
|
|
// gate's promotion rule: a cutoff-filtered pass discovers the container but
|
|
// processes none of its sessions, so it has verified nothing and a later
|
|
// full sync must still parse and write them.
|
|
func TestSyncEngineOpenCodeSQLiteCutoffPassMustNotTrustContainer(
|
|
t *testing.T,
|
|
) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
oc := createOpenCodeDB(t, env.opencodeDir)
|
|
oc.addProject(t, "proj", "/home/user/code/opencode-app")
|
|
seedOpenCodeSQLiteTextSession(
|
|
t, oc, "proj", "filtered-session",
|
|
1779012000000, 1779012030000,
|
|
"first prompt", "first answer",
|
|
)
|
|
|
|
future := time.Now().Add(24 * time.Hour)
|
|
stats := env.engine.SyncAllSince(context.Background(), future, nil)
|
|
require.False(t, stats.Aborted, "cutoff sync aborted: %+v", stats)
|
|
assert.Equal(t, 0, stats.Synced, "future cutoff filters every source")
|
|
|
|
stats = env.engine.SyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "full sync aborted: %+v", stats)
|
|
assert.Equal(t, 1, stats.Synced,
|
|
"a cutoff-filtered pass must not mark the container verified")
|
|
assertMessageContent(
|
|
t, env.db, "opencode:filtered-session",
|
|
"first prompt", "first answer",
|
|
)
|
|
}
|
|
|
|
// TestSyncEngineOpenCodeStorageUntouchedSessionSkipsReparse pins the
|
|
// per-session freshness gate for file-backed storage sessions: once a pass
|
|
// has written (or verified) a session, an untouched session must skip
|
|
// before fingerprinting and parsing on every later pass — including the
|
|
// very next one after the write. The part file is made unreadable for the
|
|
// gated pass, so any attempt to re-read the tree would surface as a
|
|
// failure instead of a skip.
|
|
func TestSyncEngineOpenCodeStorageUntouchedSessionSkipsReparse(
|
|
t *testing.T,
|
|
) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
storage := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
const sessionID = "oc-storage-gated"
|
|
storage.addSession(
|
|
t, "global", sessionID,
|
|
"/home/user/code/oc-app", "Storage Gated",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
storage.addMessage(
|
|
t, sessionID, "msg-a1", "assistant",
|
|
1704067201000, nil,
|
|
)
|
|
partPath := storage.addTextPart(
|
|
t, sessionID, "msg-a1", "part-a1",
|
|
"gated storage reply", 1704067201000,
|
|
)
|
|
|
|
stats := env.engine.SyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "first sync aborted: %+v", stats)
|
|
assert.Equal(t, 1, stats.Synced, "first sync writes the session")
|
|
|
|
// The write pass itself promotes the session's stat signature, so
|
|
// the very next pass must already gate-skip it.
|
|
require.NoError(t, os.Chmod(partPath, 0o000), "make part unreadable")
|
|
t.Cleanup(func() {
|
|
_ = os.Chmod(partPath, 0o644)
|
|
})
|
|
|
|
stats = env.engine.SyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "gated sync aborted: %+v", stats)
|
|
assert.Equal(t, 0, stats.Failed,
|
|
"gated pass must not re-read message or part files")
|
|
assert.Equal(t, 1, stats.Skipped,
|
|
"verified-unchanged session must skip before parse")
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+sessionID, "gated storage reply",
|
|
)
|
|
}
|
|
|
|
// TestSyncEngineOpenCodeStorageAppendedMessageReemitsSession covers the
|
|
// streaming case through the gate: a new message/part appended after the
|
|
// session was trusted changes its stat signature, so the session re-parses
|
|
// and re-emits exactly once, then settles back to skipping.
|
|
func TestSyncEngineOpenCodeStorageAppendedMessageReemitsSession(
|
|
t *testing.T,
|
|
) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
storage := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
const sessionID = "oc-storage-append"
|
|
storage.addSession(
|
|
t, "global", sessionID,
|
|
"/home/user/code/oc-app", "Storage Append",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
storage.addMessage(
|
|
t, sessionID, "msg-a1", "assistant",
|
|
1704067201000, nil,
|
|
)
|
|
storage.addTextPart(
|
|
t, sessionID, "msg-a1", "part-a1",
|
|
"first storage reply", 1704067201000,
|
|
)
|
|
|
|
stats := env.engine.SyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "first sync aborted: %+v", stats)
|
|
assert.Equal(t, 1, stats.Synced, "first sync writes the session")
|
|
stats = env.engine.SyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "second sync aborted: %+v", stats)
|
|
assert.Equal(t, 0, stats.Synced, "verification pass drops the unchanged session")
|
|
|
|
storage.addMessage(
|
|
t, sessionID, "msg-b1", "assistant",
|
|
1704067202000, nil,
|
|
)
|
|
storage.addTextPart(
|
|
t, sessionID, "msg-b1", "part-b1",
|
|
"second storage reply", 1704067202000,
|
|
)
|
|
|
|
stats = env.engine.SyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "append sync aborted: %+v", stats)
|
|
assert.Equal(t, 1, stats.Synced,
|
|
"appended message must re-emit the session")
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+sessionID,
|
|
"first storage reply", "second storage reply",
|
|
)
|
|
}
|
|
|
|
// TestSyncEngineOpenCodeStorageWatcherEventDoesNotRewriteUnchanged pins the
|
|
// watcher path for file-backed storage sessions: a changed-path event that
|
|
// resolves to a session whose parse output is already stored must not
|
|
// rewrite it. Message/part events used to force-parse, which bypassed
|
|
// dropUnchangedSharedSQLiteResults and rewrote the whole session (bumping
|
|
// local_modified_at and amplifying remote pushes) on every streamed append
|
|
// or spurious event re-fire.
|
|
func TestSyncEngineOpenCodeStorageWatcherEventDoesNotRewriteUnchanged(
|
|
t *testing.T,
|
|
) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
storage := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
const sessionID = "oc-storage-watch-unchanged"
|
|
storage.addSession(
|
|
t, "global", sessionID,
|
|
"/home/user/code/oc-app", "Storage Watch",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
storage.addMessage(
|
|
t, sessionID, "msg-a1", "assistant",
|
|
1704067201000, nil,
|
|
)
|
|
partPath := storage.addTextPart(
|
|
t, sessionID, "msg-a1", "part-a1",
|
|
"steady storage reply", 1704067201000,
|
|
)
|
|
|
|
stats := env.engine.SyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "first sync aborted: %+v", stats)
|
|
assert.Equal(t, 1, stats.Synced, "first sync writes the session")
|
|
fullID := "opencode:" + sessionID
|
|
before := openCodeLocalModifiedSnapshot(t, env.db, fullID)
|
|
|
|
env.engine.SyncPaths([]string{partPath})
|
|
|
|
after := openCodeLocalModifiedSnapshot(t, env.db, fullID)
|
|
assert.Equal(t, before[fullID], after[fullID],
|
|
"an event on an unchanged session must not rewrite it")
|
|
assertMessageContent(
|
|
t, env.db, fullID, "steady storage reply",
|
|
)
|
|
|
|
storage.addTextPart(
|
|
t, sessionID, "msg-a1", "part-a1",
|
|
"updated storage reply", 1704067203000,
|
|
)
|
|
env.engine.SyncPaths([]string{partPath})
|
|
|
|
assertMessageContent(
|
|
t, env.db, fullID, "updated storage reply",
|
|
)
|
|
final := openCodeLocalModifiedSnapshot(t, env.db, fullID)
|
|
assert.NotEqual(t, after[fullID], final[fullID],
|
|
"a real content change must rewrite the session")
|
|
}
|
|
|
|
// TestSyncEngineOpenCodeStorageStatIdenticalEventStillReemits pins the
|
|
// gate's safety boundary on the watcher path: a child rewritten in place
|
|
// with the same size and a restored mtime is invisible to the stat
|
|
// signature, but the event names the change, so classification must
|
|
// invalidate the session's trust and the next parse must re-emit the new
|
|
// content.
|
|
func TestSyncEngineOpenCodeStorageStatIdenticalEventStillReemits(
|
|
t *testing.T,
|
|
) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
storage := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
const sessionID = "oc-storage-stat-twin"
|
|
storage.addSession(
|
|
t, "global", sessionID,
|
|
"/home/user/code/oc-app", "Storage Stat Twin",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
storage.addMessage(
|
|
t, sessionID, "msg-a1", "assistant",
|
|
1704067201000, nil,
|
|
)
|
|
partPath := storage.addTextPart(
|
|
t, sessionID, "msg-a1", "part-a1",
|
|
"stat twin reply AAAA", 1704067201000,
|
|
)
|
|
|
|
stats := env.engine.SyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "first sync aborted: %+v", stats)
|
|
assert.Equal(t, 1, stats.Synced, "first sync writes the session")
|
|
stats = env.engine.SyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "second sync aborted: %+v", stats)
|
|
assert.Equal(t, 0, stats.Synced, "verification pass drops the unchanged session")
|
|
|
|
beforeInfo, err := os.Stat(partPath)
|
|
require.NoError(t, err, "stat part before rewrite")
|
|
// Same-length replacement text keeps the part file size stable, and the
|
|
// mtime is restored below, so only the event says anything changed.
|
|
storage.addTextPart(
|
|
t, sessionID, "msg-a1", "part-a1",
|
|
"stat twin reply BBBB", 1704067201000,
|
|
)
|
|
afterInfo, err := os.Stat(partPath)
|
|
require.NoError(t, err, "stat part after rewrite")
|
|
require.Equal(t, beforeInfo.Size(), afterInfo.Size(),
|
|
"fixture must keep the part size stable for this test")
|
|
setFileMtime(t, partPath, beforeInfo.ModTime().UnixNano())
|
|
|
|
env.engine.SyncPaths([]string{partPath})
|
|
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+sessionID, "stat twin reply BBBB",
|
|
)
|
|
}
|
|
|
|
func TestSyncEngineOpenCodeSQLiteSameMtimeMetadataChangeUsesFingerprint(
|
|
t *testing.T,
|
|
) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
oc := createOpenCodeDB(t, env.opencodeDir)
|
|
oc.addProject(t, "proj", "/home/user/code/original-app")
|
|
seedOpenCodeSQLiteTextSession(
|
|
t, oc, "proj", "same-mtime-metadata",
|
|
1779012000000, 1779012030000,
|
|
"stable prompt", "stable answer",
|
|
)
|
|
|
|
stats := env.engine.SyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "first sync aborted: %+v", stats)
|
|
assert.Equal(t, 1, stats.Synced, "first sync writes the SQLite session")
|
|
sessionID := "opencode:same-mtime-metadata"
|
|
before := openCodeStoredSession(t, env.db, sessionID)
|
|
require.NotNil(t, before.FileMtime, "file_mtime before rewrite")
|
|
require.NotNil(t, before.FileHash, "file_hash before rewrite")
|
|
require.NotNil(t, before.LocalModifiedAt,
|
|
"local_modified_at before rewrite")
|
|
assert.Equal(t, "/home/user/code/original-app", before.Cwd)
|
|
assert.Equal(t, "original_app", before.Project)
|
|
|
|
time.Sleep(20 * time.Millisecond)
|
|
oc.mustExec(t, "update project worktree",
|
|
"UPDATE project SET worktree = ? WHERE id = ?",
|
|
"/home/user/code/renamed-app", "proj",
|
|
)
|
|
|
|
stats = env.engine.SyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "second sync aborted: %+v", stats)
|
|
assert.Equal(t, 1, stats.Synced,
|
|
"same-mtime SQLite metadata changes must be rewritten")
|
|
assertMessageContent(
|
|
t, env.db, sessionID, "stable prompt", "stable answer",
|
|
)
|
|
after := openCodeStoredSession(t, env.db, sessionID)
|
|
require.NotNil(t, after.FileMtime, "file_mtime after rewrite")
|
|
require.NotNil(t, after.FileHash, "file_hash after rewrite")
|
|
require.NotNil(t, after.LocalModifiedAt,
|
|
"local_modified_at after rewrite")
|
|
assert.Equal(t, *before.FileMtime, *after.FileMtime,
|
|
"metadata-only rewrite keeps the OpenCode SQLite session mtime")
|
|
assert.NotEqual(t, *before.FileHash, *after.FileHash,
|
|
"changed SQLite metadata must change the storage fingerprint")
|
|
assert.Greater(t, *after.LocalModifiedAt, *before.LocalModifiedAt,
|
|
"successful rewrite must bump local_modified_at for push windows")
|
|
assert.Equal(t, "/home/user/code/renamed-app", after.Cwd)
|
|
assert.Equal(t, "renamed_app", after.Project)
|
|
}
|
|
|
|
func TestSyncEngineOpenCodeStorageSameMtimeContentChangeUsesFingerprint(
|
|
t *testing.T,
|
|
) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
storage := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
sessionPath := storage.addSession(
|
|
t, "proj", "same-mtime-hash",
|
|
"/home/user/code/opencode-app", "Hash Guard",
|
|
1779012000000, 1779012030000,
|
|
)
|
|
storage.addMessage(
|
|
t, "same-mtime-hash", "msg-user", "user",
|
|
1779012000000, nil,
|
|
)
|
|
userPartPath := storage.addTextPart(
|
|
t, "same-mtime-hash", "msg-user", "part-user",
|
|
"original prompt", 1779012000000,
|
|
)
|
|
storage.addMessage(
|
|
t, "same-mtime-hash", "msg-assistant", "assistant",
|
|
1779012000001, nil,
|
|
)
|
|
storage.addTextPart(
|
|
t, "same-mtime-hash", "msg-assistant", "part-assistant",
|
|
"original answer", 1779012000001,
|
|
)
|
|
|
|
stats := env.engine.SyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "first sync aborted: %+v", stats)
|
|
assert.Equal(t, 1, stats.Synced, "first sync writes the storage session")
|
|
sessionID := "opencode:same-mtime-hash"
|
|
assertMessageContent(
|
|
t, env.db, sessionID, "original prompt", "original answer",
|
|
)
|
|
before := openCodeStoredSession(t, env.db, sessionID)
|
|
require.NotNil(t, before.FileMtime, "file_mtime before rewrite")
|
|
require.NotNil(t, before.FileHash, "file_hash before rewrite")
|
|
require.NotNil(t, before.LocalModifiedAt,
|
|
"local_modified_at before rewrite")
|
|
|
|
time.Sleep(20 * time.Millisecond)
|
|
storage.addSession(
|
|
t, "proj", "same-mtime-hash",
|
|
"/home/user/code/opencode-app",
|
|
"Hash Guard with same-mtime metadata padding",
|
|
1779012000000, 1779012030000,
|
|
)
|
|
storage.addTextPart(
|
|
t, "same-mtime-hash", "msg-user", "part-user",
|
|
"changed prompt with different fingerprint",
|
|
1779012000000,
|
|
)
|
|
setFileMtime(t, sessionPath, *before.FileMtime)
|
|
setFileMtime(t, userPartPath, *before.FileMtime)
|
|
|
|
stats = env.engine.SyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "second sync aborted: %+v", stats)
|
|
assert.Equal(t, 1, stats.Synced,
|
|
"same-mtime storage fingerprint changes must be rewritten")
|
|
assertMessageContent(
|
|
t, env.db, sessionID,
|
|
"changed prompt with different fingerprint", "original answer",
|
|
)
|
|
after := openCodeStoredSession(t, env.db, sessionID)
|
|
require.NotNil(t, after.FileMtime, "file_mtime after rewrite")
|
|
require.NotNil(t, after.FileHash, "file_hash after rewrite")
|
|
require.NotNil(t, after.LocalModifiedAt,
|
|
"local_modified_at after rewrite")
|
|
assert.Equal(t, *before.FileMtime, *after.FileMtime,
|
|
"same-mtime rewrite keeps the OpenCode storage source mtime")
|
|
assert.NotEqual(t, *before.FileHash, *after.FileHash,
|
|
"changed child content must change the storage fingerprint")
|
|
assert.Greater(t, *after.LocalModifiedAt, *before.LocalModifiedAt,
|
|
"successful rewrite must bump local_modified_at for push windows")
|
|
}
|
|
|
|
func TestWatcherOverflowReverifiesSameStatOpenCodeStorage(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
storage := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
storage.addSession(
|
|
t, "proj", "overflow-same-stat",
|
|
"/home/user/code/opencode-app", "Overflow Guard",
|
|
1779012000000, 1779012030000,
|
|
)
|
|
storage.addMessage(
|
|
t, "overflow-same-stat", "msg-user", "user",
|
|
1779012000000, nil,
|
|
)
|
|
partPath := storage.addTextPart(
|
|
t, "overflow-same-stat", "msg-user", "part-user",
|
|
"original prompt", 1779012000000,
|
|
)
|
|
|
|
stats := env.engine.SyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted)
|
|
require.Equal(t, 1, stats.Synced)
|
|
const sessionID = "opencode:overflow-same-stat"
|
|
assertMessageContent(t, env.db, sessionID, "original prompt")
|
|
partInfo, err := os.Stat(partPath)
|
|
require.NoError(t, err)
|
|
|
|
storage.addTextPart(
|
|
t, "overflow-same-stat", "msg-user", "part-user",
|
|
"modified prompt", 1779012000000,
|
|
)
|
|
require.NoError(t, os.Chtimes(partPath, partInfo.ModTime(), partInfo.ModTime()))
|
|
rewrittenInfo, err := os.Stat(partPath)
|
|
require.NoError(t, err)
|
|
require.Equal(t, partInfo.Size(), rewrittenInfo.Size())
|
|
require.Equal(t, partInfo.ModTime(), rewrittenInfo.ModTime())
|
|
|
|
stats = env.engine.SyncAllAfterWatcherOverflow(context.Background(), nil)
|
|
require.False(t, stats.Aborted)
|
|
assert.Equal(t, 1, stats.Synced,
|
|
"overflow recovery must reparse event-sensitive storage sessions")
|
|
assertMessageContent(t, env.db, sessionID, "modified prompt")
|
|
}
|
|
|
|
func TestSyncEngineKiroSQLiteUpdatePaths(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentKiro)
|
|
ks := createKiroSQLiteDB(t, env.kiroDir)
|
|
|
|
const (
|
|
createdAt = int64(1779012000000)
|
|
updatedAt = int64(1779012030000)
|
|
)
|
|
standardPayload := readKiroSQLiteFixture(t, "standard_payload.json")
|
|
overlapPayload := readKiroSQLiteFixture(t, "overlap_payload.json")
|
|
malformedPayload := readKiroSQLiteFixture(t, "malformed_payload.txt")
|
|
|
|
for _, id := range []string{
|
|
"full-sync-session",
|
|
"physical-path-session",
|
|
"virtual-path-session",
|
|
"malformed-session",
|
|
} {
|
|
ks.addSession(
|
|
t, "/home/user/code/kiro-app", id,
|
|
standardPayload, createdAt, updatedAt,
|
|
)
|
|
}
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 4, Synced: 4, Skipped: 0,
|
|
})
|
|
assertSessionProject(t, env.db, "kiro:full-sync-session", "kiro_app")
|
|
assertSessionMessageCount(t, env.db, "kiro:full-sync-session", 4)
|
|
source := env.engine.FindSourceFile("kiro:full-sync-session")
|
|
want := filepath.Join(env.kiroDir, "data.sqlite3") + "#full-sync-session"
|
|
require.Equal(t, want, source)
|
|
got, wantMtime := env.engine.SourceMtime("kiro:full-sync-session"), updatedAt*1_000_000
|
|
require.Equal(t, wantMtime, got)
|
|
assertMessageContent(t, env.db, "kiro:physical-path-session",
|
|
"Build the Kiro parser",
|
|
"I can do that.",
|
|
"Read the source first",
|
|
"[Other: execute_bash]",
|
|
)
|
|
assertMessageContent(t, env.db, "kiro:virtual-path-session",
|
|
"Build the Kiro parser",
|
|
"I can do that.",
|
|
"Read the source first",
|
|
"[Other: execute_bash]",
|
|
)
|
|
|
|
// A second full sync with the Kiro DB unchanged must not rewrite the row
|
|
// (Synced stays 0). After the initial fan-out, unchanged Kiro SQLite
|
|
// sources are accounted as the single provider DB path.
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1, Synced: 0, Skipped: 0,
|
|
})
|
|
|
|
ks.updateSession(t, "full-sync-session", overlapPayload, 1779015610000)
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1, Synced: 1, Skipped: 0,
|
|
})
|
|
assertSessionMessageCount(t, env.db, "kiro:full-sync-session", 2)
|
|
|
|
ks.updateSession(t, "physical-path-session", overlapPayload, 1779015620000)
|
|
env.engine.SyncPaths([]string{ks.path})
|
|
|
|
assertSessionMessageCount(t, env.db, "kiro:physical-path-session", 2)
|
|
assertMessageContent(t, env.db, "kiro:physical-path-session",
|
|
"Current store should win",
|
|
"Using the SQLite version.",
|
|
)
|
|
|
|
ks.updateSession(t, "virtual-path-session", overlapPayload, 1779015630000)
|
|
env.engine.SyncPaths([]string{
|
|
parser.KiroSQLiteVirtualPath(ks.path, "virtual-path-session"),
|
|
})
|
|
|
|
assertSessionMessageCount(t, env.db, "kiro:virtual-path-session", 2)
|
|
assertMessageContent(t, env.db, "kiro:virtual-path-session",
|
|
"Current store should win",
|
|
"Using the SQLite version.",
|
|
)
|
|
|
|
ks.updateSession(t, "malformed-session", malformedPayload, 1779015640000)
|
|
// Kiro is provider-authoritative: the database is rediscovered and
|
|
// re-parsed (TotalSessions counts the source), but the malformed payload
|
|
// yields no parseable session, so nothing is written and the previously
|
|
// archived session is preserved.
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1, Synced: 0, Skipped: 0,
|
|
})
|
|
assertSessionMessageCount(t, env.db, "kiro:malformed-session", 4)
|
|
}
|
|
|
|
func TestSyncEngineKiroSQLiteCurrentStoreShadowsLegacy(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentKiro)
|
|
ks := createKiroSQLiteDB(t, env.kiroDir)
|
|
ks.addSession(
|
|
t, "/home/user/code/current-kiro", "overlap-session",
|
|
readKiroSQLiteFixture(t, "overlap_payload.json"),
|
|
1779015600000, 1779015610000,
|
|
)
|
|
writeLegacyKiroSession(
|
|
t, env.kiroDir, "overlap-session",
|
|
"legacy should not win",
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1, Synced: 1, Skipped: 0,
|
|
})
|
|
assertSessionProject(t, env.db, "kiro:overlap-session", "current_kiro")
|
|
|
|
// Kiro is provider-authoritative: the current-store database is
|
|
// rediscovered and re-parsed on every full sync, but an unchanged row is
|
|
// dropped from the write batch (Synced stays 0) instead of being rewritten
|
|
// and recounted. The legacy file stays shadowed throughout.
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1, Synced: 0, Skipped: 0,
|
|
})
|
|
sess, err := env.db.GetSessionFull(
|
|
context.Background(), "kiro:overlap-session",
|
|
)
|
|
require.NoError(t, err, "GetSessionFull")
|
|
require.NotNil(t, sess, "expected sqlite-backed session")
|
|
require.NotNil(t, sess.FilePath, "expected sqlite-backed session")
|
|
require.Contains(t, *sess.FilePath, "data.sqlite3#overlap-session", "expected sqlite-backed session, got %+v", sess)
|
|
|
|
legacyPath := filepath.Join(env.kiroDir, "overlap-session.jsonl")
|
|
env.engine.SyncPaths([]string{legacyPath})
|
|
sess, err = env.db.GetSessionFull(
|
|
context.Background(), "kiro:overlap-session",
|
|
)
|
|
require.NoError(t, err, "GetSessionFull after legacy event")
|
|
require.NotNil(t, sess, "legacy event replaced sqlite-backed session")
|
|
require.NotNil(t, sess.FilePath, "legacy event replaced sqlite-backed session")
|
|
require.Contains(t, *sess.FilePath, "data.sqlite3#overlap-session", "legacy event replaced sqlite-backed session: %+v", sess)
|
|
}
|
|
|
|
func TestSyncRootsSinceKiroLegacyShadowedBySQLiteOutsideScope(t *testing.T) {
|
|
legacyRoot := t.TempDir()
|
|
sqliteRoot := t.TempDir()
|
|
env := setupSingleAgentTestEnvWithDirs(
|
|
t, parser.AgentKiro, []string{legacyRoot, sqliteRoot},
|
|
)
|
|
|
|
ks := createKiroSQLiteDB(t, sqliteRoot)
|
|
ks.addSession(
|
|
t, "/home/user/code/current-kiro", "overlap-session",
|
|
readKiroSQLiteFixture(t, "overlap_payload.json"),
|
|
1779015600000, 1779015610000,
|
|
)
|
|
writeLegacyKiroSession(
|
|
t, legacyRoot, "overlap-session",
|
|
"legacy should not be imported",
|
|
)
|
|
|
|
stats := env.engine.SyncRootsSince(
|
|
context.Background(), []string{legacyRoot}, time.Time{}, nil,
|
|
)
|
|
assert.Equal(t, 0, stats.TotalSessions, "total sessions")
|
|
}
|
|
|
|
func TestSyncEngineKiroLegacyOnlySyncPath(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentKiro)
|
|
writeLegacyKiroSession(
|
|
t, env.kiroDir, "legacy-only-session",
|
|
"legacy-only should sync",
|
|
)
|
|
|
|
legacyPath := filepath.Join(env.kiroDir, "legacy-only-session.jsonl")
|
|
env.engine.SyncPaths([]string{legacyPath})
|
|
assertSessionProject(
|
|
t, env.db, "kiro:legacy-only-session", "legacy_kiro",
|
|
)
|
|
assertSessionMessageCount(t, env.db, "kiro:legacy-only-session", 2)
|
|
}
|
|
|
|
type fakeEmitter struct {
|
|
mu gosync.Mutex
|
|
scopes []string
|
|
}
|
|
|
|
func (f *fakeEmitter) Emit(scope string) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.scopes = append(f.scopes, scope)
|
|
}
|
|
|
|
func (f *fakeEmitter) got() []string {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
out := make([]string, len(f.scopes))
|
|
copy(out, f.scopes)
|
|
return out
|
|
}
|
|
|
|
// writeSession creates a JSONL session file under baseDir at
|
|
// the given relative path, creating parent directories as
|
|
// needed. Returns the full file path.
|
|
func (e *testEnv) writeSession(
|
|
t *testing.T, baseDir, relPath, content string,
|
|
) string {
|
|
t.Helper()
|
|
path := filepath.Join(baseDir, relPath)
|
|
dbtest.WriteTestFile(t, path, []byte(content))
|
|
return path
|
|
}
|
|
|
|
// writeClaudeSession creates a JSONL session file under the
|
|
// Claude projects directory.
|
|
func (e *testEnv) writeClaudeSession(
|
|
t *testing.T, projName, filename, content string,
|
|
) string {
|
|
t.Helper()
|
|
return e.writeSession(
|
|
t, e.claudeDir,
|
|
filepath.Join(projName, filename), content,
|
|
)
|
|
}
|
|
|
|
// writeClaudeSessionForProject creates a JSONL session file under the
|
|
// Claude projects directory using a standard un-sanitized directory path.
|
|
func (e *testEnv) writeClaudeSessionForProject(
|
|
t *testing.T, dirPath, filename, content string,
|
|
) string {
|
|
t.Helper()
|
|
projName := strings.NewReplacer("/", "-", "\\", "-", ":", "-").
|
|
Replace(dirPath)
|
|
return e.writeClaudeSession(t, projName, filename, content)
|
|
}
|
|
|
|
// writeCodexSession creates a JSONL session file under the
|
|
// Codex date-based directory.
|
|
func (e *testEnv) writeCodexSession(
|
|
t *testing.T, dayPath, filename, content string,
|
|
) string {
|
|
t.Helper()
|
|
return e.writeSession(
|
|
t, e.codexDir,
|
|
filepath.Join(dayPath, filename), content,
|
|
)
|
|
}
|
|
|
|
// writeGeminiSession creates a JSON session file under the
|
|
// Gemini directory at the given relative path.
|
|
func (e *testEnv) writeGeminiSession(
|
|
t *testing.T, relPath, content string,
|
|
) string {
|
|
t.Helper()
|
|
return e.writeSession(t, e.geminiDir, relPath, content)
|
|
}
|
|
|
|
// writeAmpThread creates an Amp thread JSON file under the
|
|
// configured Amp threads directory.
|
|
func (e *testEnv) writeAmpThread(
|
|
t *testing.T, filename, content string,
|
|
) string {
|
|
t.Helper()
|
|
return e.writeSession(t, e.ampDir, filename, content)
|
|
}
|
|
|
|
// writeCursorSession creates a Cursor transcript file under
|
|
// the given cursorDir at <project>/agent-transcripts/<file>.
|
|
func (e *testEnv) writeCursorSession(
|
|
t *testing.T, cursorDir, project, filename,
|
|
content string,
|
|
) string {
|
|
t.Helper()
|
|
return e.writeSession(
|
|
t, cursorDir,
|
|
filepath.Join(
|
|
project, "agent-transcripts", filename,
|
|
),
|
|
content,
|
|
)
|
|
}
|
|
|
|
// writeNestedCursorSession creates a Cursor transcript file under
|
|
// the nested layout <project>/agent-transcripts/<session>/<session><ext>.
|
|
func (e *testEnv) writeNestedCursorSession(
|
|
t *testing.T, cursorDir, project, sessionID, ext,
|
|
content string,
|
|
) string {
|
|
t.Helper()
|
|
return e.writeSession(
|
|
t, cursorDir,
|
|
filepath.Join(
|
|
project, "agent-transcripts", sessionID,
|
|
sessionID+ext,
|
|
),
|
|
content,
|
|
)
|
|
}
|
|
|
|
func TestSyncEngineIntegration(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "Hello", "/Users/alice/code/my-app").
|
|
AddClaudeAssistant(tsEarlyS5, "Hi there!").
|
|
String()
|
|
|
|
env.writeClaudeSessionForProject(
|
|
t, "/Users/alice/code/my-app",
|
|
"test-session.jsonl", content,
|
|
)
|
|
|
|
// First sync should parse
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 1, Synced: 1, Skipped: 0})
|
|
|
|
// Verify session was stored
|
|
assertSessionProject(t, env.db, "test-session", "my_app")
|
|
assertSessionMessageCount(t, env.db, "test-session", 2)
|
|
|
|
// Verify messages
|
|
assertMessageRoles(
|
|
t, env.db, "test-session", "user", "assistant",
|
|
)
|
|
|
|
// Second sync should skip (unchanged files)
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 0 + 1, Synced: 0, Skipped: 1})
|
|
|
|
// FindSourceFile
|
|
src := env.engine.FindSourceFile("test-session")
|
|
assert.NotEmpty(t, src, "FindSourceFile returned empty")
|
|
}
|
|
|
|
func TestSyncEngineWorktreesShareProject(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
root := t.TempDir()
|
|
mainRepo := filepath.Join(root, "agentsview")
|
|
worktree := filepath.Join(root, "agentsview-worktree-tool-call-arguments")
|
|
worktreeGitDir := filepath.Join(mainRepo, ".git", "worktrees", "feature")
|
|
|
|
dbtest.WriteTestFile(t, filepath.Join(worktree, ".git"),
|
|
[]byte("gitdir: "+worktreeGitDir+"\n"))
|
|
dbtest.WriteTestFile(t, filepath.Join(worktreeGitDir, "commondir"),
|
|
[]byte("../..\n"))
|
|
|
|
// Create a standard main repository marker.
|
|
require.NoError(t, os.MkdirAll(filepath.Join(mainRepo, ".git"), 0o755), "mkdir main .git")
|
|
|
|
mainContent := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "Main repo", mainRepo).
|
|
AddClaudeAssistant(tsEarlyS5, "ok").
|
|
String()
|
|
worktreeContent := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "Worktree", worktree).
|
|
AddClaudeAssistant(tsEarlyS5, "ok").
|
|
String()
|
|
|
|
env.writeClaudeSessionForProject(
|
|
t, "/Users/me/code/agentsview",
|
|
"main-repo.jsonl", mainContent,
|
|
)
|
|
env.writeClaudeSessionForProject(
|
|
t, "/Users/me/code/agentsview-worktree-tool-call-arguments",
|
|
"worktree-repo.jsonl", worktreeContent,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 2 + 0, Synced: 2, Skipped: 0})
|
|
|
|
assertSessionProject(t, env.db, "main-repo", "agentsview")
|
|
assertSessionProject(t, env.db, "worktree-repo", "agentsview")
|
|
|
|
projects, err := env.db.GetProjects(context.Background(), false, false)
|
|
require.NoError(t, err, "GetProjects")
|
|
require.Equal(t, 1, len(projects), "len(projects) = %d, want 1", len(projects))
|
|
require.Equal(t, "agentsview", projects[0].Name, "project name = %q, want %q", projects[0].Name, "agentsview")
|
|
require.Equal(t, 2, projects[0].SessionCount, "session_count = %d, want 2", projects[0].SessionCount)
|
|
}
|
|
|
|
func TestSyncEngineWorktreeProjectWhenPathMissing(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
mainContent := testjsonl.NewSessionBuilder().
|
|
AddRaw(`{"type":"user","timestamp":"2024-01-01T10:00:00Z","cwd":"/Users/wesm/code/agentsview","gitBranch":"main","message":{"content":"hello"}}`).
|
|
AddClaudeAssistant(tsEarlyS5, "ok").
|
|
String()
|
|
|
|
worktreeContent := testjsonl.NewSessionBuilder().
|
|
AddRaw(`{"type":"user","timestamp":"2024-01-01T10:00:00Z","cwd":"/Users/wesm/code/agentsview-worktree-tool-call-arguments","gitBranch":"worktree-tool-call-arguments","message":{"content":"hello"}}`).
|
|
AddClaudeAssistant(tsEarlyS5, "ok").
|
|
String()
|
|
|
|
env.writeClaudeSessionForProject(
|
|
t, "/Users/me/code/agentsview",
|
|
"offline-main.jsonl", mainContent,
|
|
)
|
|
env.writeClaudeSessionForProject(
|
|
t, "/Users/me/code/agentsview-worktree-tool-call-arguments",
|
|
"offline-worktree.jsonl", worktreeContent,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 2 + 0, Synced: 2, Skipped: 0})
|
|
|
|
assertSessionProject(t, env.db, "offline-main", "agentsview")
|
|
assertSessionProject(t, env.db, "offline-worktree", "agentsview")
|
|
}
|
|
|
|
func TestSyncEngineAppliesWorktreeProjectMapping(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
assert.Equal(t, "local", env.engine.Machine())
|
|
|
|
root := t.TempDir()
|
|
worktreePrefix := filepath.Join(root, "my-app.worktrees")
|
|
sessionCwd := filepath.Join(worktreePrefix, "feature-login")
|
|
_, err := env.db.CreateWorktreeProjectMapping(
|
|
context.Background(),
|
|
db.WorktreeProjectMapping{
|
|
Machine: "local",
|
|
PathPrefix: worktreePrefix,
|
|
Project: "canonical-app",
|
|
Enabled: true,
|
|
},
|
|
)
|
|
require.NoError(t, err, "CreateWorktreeProjectMapping")
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "Worktree mapped", sessionCwd).
|
|
AddClaudeAssistant(tsEarlyS5, "ok").
|
|
String()
|
|
|
|
env.writeClaudeSessionForProject(
|
|
t, sessionCwd,
|
|
"mapped-worktree.jsonl", content,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
|
|
assertSessionProject(
|
|
t, env.db, "mapped-worktree", "canonical_app",
|
|
)
|
|
}
|
|
|
|
func TestSyncSingleSessionAppliesWorktreeProjectMapping(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
root := t.TempDir()
|
|
worktreePrefix := filepath.Join(root, "my-app.worktrees")
|
|
sessionCwd := filepath.Join(worktreePrefix, "feature-login")
|
|
_, err := env.db.CreateWorktreeProjectMapping(
|
|
context.Background(),
|
|
db.WorktreeProjectMapping{
|
|
Machine: "local",
|
|
PathPrefix: worktreePrefix,
|
|
Project: "canonical-app",
|
|
Enabled: true,
|
|
},
|
|
)
|
|
require.NoError(t, err, "CreateWorktreeProjectMapping")
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "Worktree mapped single", sessionCwd).
|
|
AddClaudeAssistant(tsEarlyS5, "ok").
|
|
String()
|
|
|
|
env.writeClaudeSessionForProject(
|
|
t, sessionCwd,
|
|
"mapped-worktree-single.jsonl", content,
|
|
)
|
|
|
|
err = env.engine.SyncSingleSession(
|
|
"mapped-worktree-single",
|
|
)
|
|
require.NoError(t, err, "SyncSingleSession")
|
|
|
|
assertSessionProject(
|
|
t, env.db, "mapped-worktree-single", "canonical_app",
|
|
)
|
|
}
|
|
|
|
func TestSyncSingleSessionSkippedClaudeDoesNotApplyWorktreeProjectMapping(
|
|
t *testing.T,
|
|
) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
root := t.TempDir()
|
|
worktreePrefix := filepath.Join(root, "my-app.worktrees")
|
|
sessionCwd := filepath.Join(worktreePrefix, "feature-login")
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "Worktree mapped after initial sync", sessionCwd).
|
|
AddClaudeAssistant(tsEarlyS5, "ok").
|
|
String()
|
|
|
|
env.writeClaudeSessionForProject(
|
|
t, sessionCwd,
|
|
"mapped-worktree-single-skip.jsonl", content,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
before, err := env.db.GetSession(
|
|
context.Background(), "mapped-worktree-single-skip",
|
|
)
|
|
require.NoError(t, err, "GetSession before mapping")
|
|
require.NotNil(t, before, "session missing before mapping")
|
|
require.NotEqual(t, "canonical_app", before.Project, "project before mapping = %q, want stale project", before.Project)
|
|
require.Nil(t, before.LocalModifiedAt, "local_modified_at before mapping = %v, want nil", before.LocalModifiedAt)
|
|
|
|
_, err = env.db.CreateWorktreeProjectMapping(
|
|
context.Background(),
|
|
db.WorktreeProjectMapping{
|
|
Machine: "local",
|
|
PathPrefix: worktreePrefix,
|
|
Project: "canonical-app",
|
|
Enabled: true,
|
|
},
|
|
)
|
|
require.NoError(t, err, "CreateWorktreeProjectMapping")
|
|
|
|
err = env.engine.SyncSingleSession(
|
|
"mapped-worktree-single-skip",
|
|
)
|
|
require.NoError(t, err, "SyncSingleSession")
|
|
|
|
after, err := env.db.GetSession(
|
|
context.Background(), "mapped-worktree-single-skip",
|
|
)
|
|
require.NoError(t, err, "GetSession after skipped sync")
|
|
require.NotNil(t, after, "session missing after skipped sync")
|
|
require.Equal(t, before.Project, after.Project, "project after skipped sync = %q, want %q", after.Project, before.Project)
|
|
}
|
|
|
|
func TestSyncAllSkippedClaudeDoesNotApplyWorktreeProjectMapping(
|
|
t *testing.T,
|
|
) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
root := t.TempDir()
|
|
worktreePrefix := filepath.Join(root, "my-app.worktrees")
|
|
sessionCwd := filepath.Join(worktreePrefix, "feature-login")
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "Worktree mapped during normal skip", sessionCwd).
|
|
AddClaudeAssistant(tsEarlyS5, "ok").
|
|
String()
|
|
|
|
env.writeClaudeSessionForProject(
|
|
t, sessionCwd,
|
|
"mapped-worktree-syncall-skip.jsonl", content,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
before, err := env.db.GetSession(
|
|
context.Background(), "mapped-worktree-syncall-skip",
|
|
)
|
|
require.NoError(t, err, "GetSession before mapping")
|
|
require.NotNil(t, before, "session missing before mapping")
|
|
require.NotEqual(t, "canonical_app", before.Project, "project before mapping = %q, want stale project", before.Project)
|
|
_, err = env.db.CreateWorktreeProjectMapping(
|
|
context.Background(),
|
|
db.WorktreeProjectMapping{
|
|
Machine: "local",
|
|
PathPrefix: worktreePrefix,
|
|
Project: "canonical-app",
|
|
Enabled: true,
|
|
},
|
|
)
|
|
require.NoError(t, err, "CreateWorktreeProjectMapping")
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 0,
|
|
Skipped: 1,
|
|
})
|
|
|
|
after, err := env.db.GetSession(
|
|
context.Background(), "mapped-worktree-syncall-skip",
|
|
)
|
|
require.NoError(t, err, "GetSession after skipped sync")
|
|
require.NotNil(t, after, "session missing after skipped sync")
|
|
require.Equal(t, before.Project, after.Project, "project after skipped sync = %q, want %q", after.Project, before.Project)
|
|
}
|
|
|
|
func TestSyncPathsSkippedClaudeDoesNotApplyWorktreeProjectMapping(
|
|
t *testing.T,
|
|
) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
root := t.TempDir()
|
|
worktreePrefix := filepath.Join(root, "my-app.worktrees")
|
|
sessionCwd := filepath.Join(worktreePrefix, "feature-login")
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "Worktree mapped during path skip", sessionCwd).
|
|
AddClaudeAssistant(tsEarlyS5, "ok").
|
|
String()
|
|
|
|
path := env.writeClaudeSessionForProject(
|
|
t, sessionCwd,
|
|
"mapped-worktree-syncpaths-skip.jsonl", content,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
before, err := env.db.GetSession(
|
|
context.Background(), "mapped-worktree-syncpaths-skip",
|
|
)
|
|
require.NoError(t, err, "GetSession before mapping")
|
|
require.NotNil(t, before, "session missing before mapping")
|
|
require.NotEqual(t, "canonical_app", before.Project, "project before mapping = %q, want stale project", before.Project)
|
|
beforeFull, err := env.db.GetSessionFull(
|
|
context.Background(), "mapped-worktree-syncpaths-skip",
|
|
)
|
|
require.NoError(t, err, "GetSessionFull before mapping")
|
|
|
|
_, err = env.db.CreateWorktreeProjectMapping(
|
|
context.Background(),
|
|
db.WorktreeProjectMapping{
|
|
Machine: "local",
|
|
PathPrefix: worktreePrefix,
|
|
Project: "canonical-app",
|
|
Enabled: true,
|
|
},
|
|
)
|
|
require.NoError(t, err, "CreateWorktreeProjectMapping")
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
after, err := env.db.GetSessionFull(
|
|
context.Background(),
|
|
"mapped-worktree-syncpaths-skip",
|
|
)
|
|
require.NoError(t, err, "GetSessionFull after skipped path sync")
|
|
require.Equal(t, beforeFull.Project, after.Project, "project after skipped path sync = %q, want %q", after.Project, beforeFull.Project)
|
|
require.Equal(t,
|
|
testStringPtrValue(beforeFull.LocalModifiedAt),
|
|
testStringPtrValue(after.LocalModifiedAt),
|
|
"local_modified_at after skipped path sync = %v, want %v",
|
|
after.LocalModifiedAt,
|
|
beforeFull.LocalModifiedAt,
|
|
)
|
|
}
|
|
|
|
func TestSyncSingleSessionIncrementalAppliesWorktreeProjectMapping(
|
|
t *testing.T,
|
|
) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
root := t.TempDir()
|
|
worktreePrefix := filepath.Join(root, "my-app.worktrees")
|
|
sessionCwd := filepath.Join(worktreePrefix, "feature-login")
|
|
initial := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "Worktree mapped after append", sessionCwd).
|
|
String()
|
|
|
|
path := env.writeClaudeSessionForProject(
|
|
t, sessionCwd,
|
|
"mapped-worktree-single-incremental.jsonl", initial,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
before, err := env.db.GetSession(
|
|
context.Background(), "mapped-worktree-single-incremental",
|
|
)
|
|
require.NoError(t, err, "GetSession before mapping")
|
|
require.NotNil(t, before, "session missing before mapping")
|
|
require.NotEqual(t, "canonical_app", before.Project, "project before mapping = %q, want stale project", before.Project)
|
|
_, err = env.db.CreateWorktreeProjectMapping(
|
|
context.Background(),
|
|
db.WorktreeProjectMapping{
|
|
Machine: "local",
|
|
PathPrefix: worktreePrefix,
|
|
Project: "canonical-app",
|
|
Enabled: true,
|
|
},
|
|
)
|
|
require.NoError(t, err, "CreateWorktreeProjectMapping")
|
|
|
|
appended := initial + testjsonl.NewSessionBuilder().
|
|
AddClaudeAssistant(tsEarlyS5, "ok").
|
|
String()
|
|
require.NoError(t, os.WriteFile(path, []byte(appended), 0o644), "WriteFile")
|
|
|
|
err = env.engine.SyncSingleSession(
|
|
"mapped-worktree-single-incremental",
|
|
)
|
|
require.NoError(t, err, "SyncSingleSession")
|
|
|
|
assertSessionMessageCount(
|
|
t, env.db, "mapped-worktree-single-incremental", 2,
|
|
)
|
|
assertSessionProject(
|
|
t, env.db, "mapped-worktree-single-incremental", "canonical_app",
|
|
)
|
|
}
|
|
|
|
func TestSyncAllIncrementalAppliesWorktreeProjectMapping(
|
|
t *testing.T,
|
|
) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
root := t.TempDir()
|
|
worktreePrefix := filepath.Join(root, "my-app.worktrees")
|
|
sessionCwd := filepath.Join(worktreePrefix, "feature-login")
|
|
initial := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "Worktree mapped during normal append", sessionCwd).
|
|
String()
|
|
|
|
path := env.writeClaudeSessionForProject(
|
|
t, sessionCwd,
|
|
"mapped-worktree-syncall-incremental.jsonl", initial,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
before, err := env.db.GetSession(
|
|
context.Background(), "mapped-worktree-syncall-incremental",
|
|
)
|
|
require.NoError(t, err, "GetSession before mapping")
|
|
require.NotNil(t, before, "session missing before mapping")
|
|
require.NotEqual(t, "canonical_app", before.Project, "project before mapping = %q, want stale project", before.Project)
|
|
_, err = env.db.CreateWorktreeProjectMapping(
|
|
context.Background(),
|
|
db.WorktreeProjectMapping{
|
|
Machine: "local",
|
|
PathPrefix: worktreePrefix,
|
|
Project: "canonical-app",
|
|
Enabled: true,
|
|
},
|
|
)
|
|
require.NoError(t, err, "CreateWorktreeProjectMapping")
|
|
|
|
appended := initial + testjsonl.NewSessionBuilder().
|
|
AddClaudeAssistant(tsEarlyS5, "ok").
|
|
String()
|
|
require.NoError(t, os.WriteFile(path, []byte(appended), 0o644), "WriteFile")
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
|
|
assertSessionMessageCount(
|
|
t, env.db, "mapped-worktree-syncall-incremental", 2,
|
|
)
|
|
assertSessionProject(
|
|
t, env.db, "mapped-worktree-syncall-incremental", "canonical_app",
|
|
)
|
|
}
|
|
|
|
func TestResyncAllAppliesWorktreeProjectMappingDuringBulkWrites(
|
|
t *testing.T,
|
|
) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
root := t.TempDir()
|
|
worktreePrefix := filepath.Join(root, "my-app.worktrees")
|
|
sessionCwd := filepath.Join(worktreePrefix, "feature-login")
|
|
_, err := env.db.CreateWorktreeProjectMapping(
|
|
context.Background(),
|
|
db.WorktreeProjectMapping{
|
|
Machine: "local",
|
|
PathPrefix: worktreePrefix,
|
|
Project: "canonical-app",
|
|
Enabled: true,
|
|
},
|
|
)
|
|
require.NoError(t, err, "CreateWorktreeProjectMapping")
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "Worktree mapped by resync", sessionCwd).
|
|
AddClaudeAssistant(tsEarlyS5, "ok").
|
|
String()
|
|
|
|
env.writeClaudeSessionForProject(
|
|
t, sessionCwd,
|
|
"mapped-worktree-resync.jsonl", content,
|
|
)
|
|
|
|
stats := env.engine.ResyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "ResyncAll aborted: %+v", stats)
|
|
require.Equal(t, 1, stats.Synced, "ResyncAll synced = %d, want 1: %+v", stats.Synced, stats)
|
|
|
|
assertSessionProject(
|
|
t, env.db, "mapped-worktree-resync", "canonical_app",
|
|
)
|
|
}
|
|
|
|
func TestResyncAllWithOptionsIngestsContributors(t *testing.T) {
|
|
localRoot := t.TempDir()
|
|
remoteRoot := t.TempDir()
|
|
env := setupSingleAgentTestEnvWithDirs(t, parser.AgentClaude, []string{localRoot})
|
|
|
|
localPath := filepath.Join(localRoot, "project-local", "local-session.jsonl")
|
|
remotePath := filepath.Join(remoteRoot, "project-remote", "remote-session.jsonl")
|
|
dbtest.WriteTestFile(t, localPath, []byte(testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "local rebuild message").String()))
|
|
dbtest.WriteTestFile(t, remotePath, []byte(testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarlyS1, "remote rebuild message").String()))
|
|
olderPath := filepath.Join(localRoot, "archived", "older-active.jsonl")
|
|
dbtest.WriteTestFile(t, olderPath, []byte(testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "older active archive message").String()))
|
|
require.Equal(t, 2, env.engine.SyncAll(context.Background(), nil).Synced)
|
|
require.NoError(t, os.Remove(olderPath))
|
|
observedBeforeSwap := false
|
|
|
|
stats, err := env.engine.ResyncAllWithOptions(
|
|
context.Background(), nil, sync.RebuildOptions{Contributors: []sync.RebuildContributor{{
|
|
Name: "remote-host",
|
|
Config: sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{parser.AgentClaude: {remoteRoot}},
|
|
Machine: "remote-host",
|
|
IDPrefix: "remote~",
|
|
PathRewriter: func(path string) string { return "remote:" + path },
|
|
Ephemeral: true,
|
|
},
|
|
AfterSync: func(_ *sync.Engine, tempDB *db.DB) error {
|
|
observedBeforeSwap = true
|
|
assert.NotEqual(t, env.db.Path(), tempDB.Path())
|
|
assert.Equal(t, env.db.Path()+"-resync", tempDB.Path())
|
|
|
|
older, getErr := env.db.GetSession(context.Background(), "older-active")
|
|
require.NoError(t, getErr)
|
|
require.NotNil(t, older, "active archive lost old row before swap")
|
|
assertMessageContent(t, env.db, "older-active", "older active archive message")
|
|
page, searchErr := env.db.Search(context.Background(), db.SearchFilter{
|
|
Query: "older active archive message", Limit: 5,
|
|
})
|
|
require.NoError(t, searchErr)
|
|
require.Len(t, page.Results, 1, "active search changed before swap")
|
|
remote, getErr := env.db.GetSession(context.Background(), "remote~remote-session")
|
|
require.NoError(t, getErr)
|
|
assert.Nil(t, remote, "contributor row reached active DB before swap")
|
|
|
|
remote, getErr = tempDB.GetSession(context.Background(), "remote~remote-session")
|
|
require.NoError(t, getErr)
|
|
require.NotNil(t, remote, "contributor row missing from temporary DB")
|
|
older, getErr = tempDB.GetSession(context.Background(), "older-active")
|
|
require.NoError(t, getErr)
|
|
assert.Nil(t, older, "orphan copy ran before contributor callback")
|
|
return nil
|
|
},
|
|
}}},
|
|
)
|
|
require.NoError(t, err)
|
|
require.True(t, observedBeforeSwap)
|
|
require.False(t, stats.Aborted, "resync aborted: %+v", stats)
|
|
assert.Equal(t, 2, stats.Synced)
|
|
assert.Equal(t, 1, stats.OrphanedCopied)
|
|
|
|
local, err := env.db.GetSession(context.Background(), "local-session")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, local)
|
|
assert.Equal(t, "local", local.Machine)
|
|
remote, err := env.db.GetSession(context.Background(), "remote~remote-session")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, remote)
|
|
assert.Equal(t, "remote-host", remote.Machine)
|
|
assert.Equal(t, "remote:"+remotePath, env.db.GetSessionFilePath(remote.ID))
|
|
older, err := env.db.GetSession(context.Background(), "older-active")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, older)
|
|
|
|
for _, query := range []string{"local rebuild message", "remote rebuild message"} {
|
|
page, searchErr := env.db.Search(context.Background(), db.SearchFilter{Query: query, Limit: 5})
|
|
require.NoError(t, searchErr)
|
|
assert.Len(t, page.Results, 1, query)
|
|
}
|
|
require.Len(t, stats.RebuildPhases, 2)
|
|
require.Len(t, env.engine.LastSyncStats().RebuildPhases, 2)
|
|
assert.Equal(t, "local", stats.RebuildPhases[0].Contributor)
|
|
assert.EqualValues(t, 1, stats.RebuildPhases[0].BatchedWrites)
|
|
assert.Equal(t, "remote-host", stats.RebuildPhases[1].Contributor)
|
|
assert.EqualValues(t, 1, stats.RebuildPhases[1].BatchedWrites)
|
|
}
|
|
|
|
func TestResyncContributorFailureLeavesArchiveUnchanged(t *testing.T) {
|
|
localRoot := t.TempDir()
|
|
remoteRoot := t.TempDir()
|
|
env := setupSingleAgentTestEnvWithDirs(t, parser.AgentClaude, []string{localRoot})
|
|
oldPath := filepath.Join(localRoot, "old-project", "old-session.jsonl")
|
|
dbtest.WriteTestFile(t, oldPath, []byte(testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "durable old archive message").String()))
|
|
require.Equal(t, 1, env.engine.SyncAll(context.Background(), nil).Synced)
|
|
require.NoError(t, os.Remove(oldPath))
|
|
newPath := filepath.Join(localRoot, "new-project", "new-session.jsonl")
|
|
dbtest.WriteTestFile(t, newPath, []byte(testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarlyS1, "partial replacement message").String()))
|
|
remotePath := filepath.Join(remoteRoot, "remote-project", "remote-session.jsonl")
|
|
dbtest.WriteTestFile(t, remotePath, []byte(testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarlyS5, "partial remote message").String()))
|
|
|
|
sentinel := errors.New("after sync failed")
|
|
stats, err := env.engine.ResyncAllWithOptions(context.Background(), nil,
|
|
sync.RebuildOptions{Contributors: []sync.RebuildContributor{{
|
|
Name: "broken-remote",
|
|
Config: sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{parser.AgentClaude: {remoteRoot}},
|
|
Machine: "broken-remote",
|
|
IDPrefix: "broken~",
|
|
Ephemeral: true,
|
|
},
|
|
AfterSync: func(*sync.Engine, *db.DB) error { return sentinel },
|
|
}}})
|
|
require.Error(t, err)
|
|
assert.True(t, stats.Aborted)
|
|
var contributorErr *sync.RebuildContributorError
|
|
require.True(t, errors.As(err, &contributorErr))
|
|
assert.Equal(t, "broken-remote", contributorErr.Contributor)
|
|
assert.ErrorIs(t, err, sentinel)
|
|
assert.Contains(t, stats.Warnings,
|
|
`resync contributor "broken-remote" failed: after sync failed`)
|
|
|
|
oldSession, getErr := env.db.GetSession(context.Background(), "old-session")
|
|
require.NoError(t, getErr)
|
|
require.NotNil(t, oldSession)
|
|
newSession, getErr := env.db.GetSession(context.Background(), "new-session")
|
|
require.NoError(t, getErr)
|
|
assert.Nil(t, newSession)
|
|
page, searchErr := env.db.Search(context.Background(), db.SearchFilter{
|
|
Query: "durable old archive message", Limit: 5,
|
|
})
|
|
require.NoError(t, searchErr)
|
|
require.Len(t, page.Results, 1)
|
|
assert.NoFileExists(t, env.db.Path()+"-resync")
|
|
assert.NoFileExists(t, env.db.Path()+"-resync-wal")
|
|
assert.NoFileExists(t, env.db.Path()+"-resync-shm")
|
|
}
|
|
|
|
func TestResyncEmptyContributorDoesNotAbortNonEmptyLocalRebuild(t *testing.T) {
|
|
localRoot := t.TempDir()
|
|
emptyRoot := t.TempDir()
|
|
env := setupSingleAgentTestEnvWithDirs(t, parser.AgentClaude, []string{localRoot})
|
|
dbtest.WriteTestFile(t, filepath.Join(localRoot, "project", "local.jsonl"),
|
|
[]byte(testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "nonempty local rebuild").String()))
|
|
|
|
stats, err := env.engine.ResyncAllWithOptions(context.Background(), nil,
|
|
sync.RebuildOptions{Contributors: []sync.RebuildContributor{{
|
|
Name: "empty",
|
|
Config: sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{parser.AgentClaude: {emptyRoot}},
|
|
Machine: "empty",
|
|
IDPrefix: "empty~",
|
|
Ephemeral: true,
|
|
},
|
|
}}})
|
|
require.NoError(t, err)
|
|
assert.False(t, stats.Aborted, "resync aborted: %+v", stats)
|
|
assert.Equal(t, 1, stats.Synced)
|
|
assert.EqualValues(t, 1, stats.RebuildPhases[0].BatchedWrites)
|
|
assert.Zero(t, stats.RebuildPhases[1].BatchedWrites)
|
|
assertSessionMessageCount(t, env.db, "local", 1)
|
|
}
|
|
|
|
func TestResyncAllExcludesExistingClaudeUsageProbe(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
const sessionID = "usage-probe"
|
|
usageCmd := "<command-name>/usage</command-name>\n" +
|
|
" <command-message>usage</command-message>\n" +
|
|
" <command-args></command-args>"
|
|
content := testjsonl.ClaudeUserJSON(usageCmd, tsEarly)
|
|
path := env.writeClaudeSession(
|
|
t, "ClaudeProbe", sessionID+".jsonl", content,
|
|
)
|
|
|
|
size := int64(len(content))
|
|
mtime := time.Now().Add(-time.Hour).UnixNano()
|
|
firstMessage := "/usage"
|
|
startedAt := tsEarly
|
|
require.NoError(t, env.db.UpsertSession(db.Session{
|
|
ID: sessionID,
|
|
Project: "ClaudeProbe",
|
|
Machine: "local",
|
|
Agent: string(parser.AgentClaude),
|
|
FirstMessage: &firstMessage,
|
|
StartedAt: &startedAt,
|
|
EndedAt: &startedAt,
|
|
MessageCount: 1,
|
|
UserMessageCount: 1,
|
|
FilePath: &path,
|
|
FileSize: &size,
|
|
FileMtime: &mtime,
|
|
}), "seed stale /usage probe row")
|
|
require.NoError(t, env.db.SetSessionDataVersion(
|
|
sessionID, db.CurrentDataVersion()-1,
|
|
), "seed stale data_version")
|
|
|
|
stats := env.engine.ResyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "ResyncAll aborted: %+v", stats)
|
|
assert.Equal(t, 0, stats.OrphanedCopied,
|
|
"usage probe must not be copied back as an orphan")
|
|
|
|
got, err := env.db.GetSession(context.Background(), sessionID)
|
|
require.NoError(t, err, "GetSession after ResyncAll")
|
|
assert.Nil(t, got, "stale /usage probe row must be excluded")
|
|
assert.False(t, env.db.IsSessionExcluded(sessionID),
|
|
"parser exclusions must not become permanent user deletions")
|
|
}
|
|
|
|
func TestResyncContributorExclusionIsNotRestoredAsOrphan(t *testing.T) {
|
|
localRoot := t.TempDir()
|
|
remoteRoot := t.TempDir()
|
|
env := setupSingleAgentTestEnvWithDirs(t, parser.AgentClaude, []string{localRoot})
|
|
dbtest.WriteTestFile(t, filepath.Join(localRoot, "local", "local.jsonl"),
|
|
[]byte(testjsonl.NewSessionBuilder().AddClaudeUser(tsZero, "local").String()))
|
|
|
|
const rawID = "remote-usage-probe"
|
|
const storedID = "remote~remote-usage-probe"
|
|
usageCmd := "<command-name>/usage</command-name>\n" +
|
|
"<command-message>usage</command-message>\n<command-args></command-args>"
|
|
content := testjsonl.ClaudeUserJSON(usageCmd, tsEarly)
|
|
path := filepath.Join(remoteRoot, "remote", rawID+".jsonl")
|
|
dbtest.WriteTestFile(t, path, []byte(content))
|
|
storedPath := "remote:" + path
|
|
size := int64(len(content))
|
|
mtime := time.Now().Add(-time.Hour).UnixNano()
|
|
firstMessage := "/usage"
|
|
startedAt := tsEarly
|
|
require.NoError(t, env.db.UpsertSession(db.Session{
|
|
ID: storedID, Project: "remote", Machine: "remote", Agent: string(parser.AgentClaude),
|
|
FirstMessage: &firstMessage, StartedAt: &startedAt, EndedAt: &startedAt,
|
|
MessageCount: 1, UserMessageCount: 1, FilePath: &storedPath,
|
|
FileSize: &size, FileMtime: &mtime,
|
|
}))
|
|
|
|
stats, err := env.engine.ResyncAllWithOptions(context.Background(), nil,
|
|
sync.RebuildOptions{Contributors: []sync.RebuildContributor{{
|
|
Name: "remote",
|
|
Config: sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{parser.AgentClaude: {remoteRoot}},
|
|
Machine: "remote", IDPrefix: "remote~", Ephemeral: true,
|
|
PathRewriter: func(path string) string { return "remote:" + path },
|
|
},
|
|
}}})
|
|
require.NoError(t, err)
|
|
require.False(t, stats.Aborted, "resync aborted: %+v", stats)
|
|
assert.Zero(t, stats.OrphanedCopied)
|
|
got, getErr := env.db.GetSession(context.Background(), storedID)
|
|
require.NoError(t, getErr)
|
|
assert.Nil(t, got)
|
|
}
|
|
|
|
func TestResyncContributorPreservesPinnedAndTrashedRemoteState(t *testing.T) {
|
|
localRoot := t.TempDir()
|
|
remoteRoot := t.TempDir()
|
|
env := setupSingleAgentTestEnvWithDirs(t, parser.AgentClaude, []string{localRoot})
|
|
dbtest.WriteTestFile(t, filepath.Join(localRoot, "local", "local.jsonl"),
|
|
[]byte(testjsonl.NewSessionBuilder().AddClaudeUser(tsZero, "local").String()))
|
|
remotePath := filepath.Join(remoteRoot, "remote", "remote-trash.jsonl")
|
|
dbtest.WriteTestFile(t, remotePath, []byte(testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "preserved remote prompt").
|
|
AddClaudeAssistant(tsEarlyS1, "preserved remote reply").String()))
|
|
remoteConfig := sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{parser.AgentClaude: {remoteRoot}},
|
|
Machine: "remote", IDPrefix: "remote~", Ephemeral: true,
|
|
PathRewriter: func(path string) string { return "remote:" + path },
|
|
}
|
|
remoteEngine := sync.NewEngine(env.db, remoteConfig)
|
|
require.Equal(t, 1, remoteEngine.SyncAll(context.Background(), nil).Synced)
|
|
remoteEngine.Close()
|
|
msgs := fetchMessages(t, env.db, "remote~remote-trash")
|
|
require.Len(t, msgs, 2)
|
|
note := "keep this remote message"
|
|
_, err := env.db.PinMessage("remote~remote-trash", msgs[1].ID, ¬e)
|
|
require.NoError(t, err)
|
|
require.NoError(t, env.db.SoftDeleteSession("remote~remote-trash"))
|
|
|
|
stats, err := env.engine.ResyncAllWithOptions(context.Background(), nil,
|
|
sync.RebuildOptions{Contributors: []sync.RebuildContributor{{
|
|
Name: "remote", Config: remoteConfig,
|
|
}}})
|
|
require.NoError(t, err)
|
|
require.False(t, stats.Aborted, "resync aborted: %+v", stats)
|
|
full, err := env.db.GetSessionFull(context.Background(), "remote~remote-trash")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, full)
|
|
assert.NotNil(t, full.DeletedAt)
|
|
preservedMessages := fetchMessages(t, env.db, "remote~remote-trash")
|
|
require.Len(t, preservedMessages, 2)
|
|
assert.Equal(t, "preserved remote prompt", preservedMessages[0].Content)
|
|
assert.Equal(t, "preserved remote reply", preservedMessages[1].Content)
|
|
pins, err := env.db.ListPinnedMessages(context.Background(), "remote~remote-trash", "")
|
|
require.NoError(t, err)
|
|
require.Len(t, pins, 1)
|
|
require.NotNil(t, pins[0].Note)
|
|
assert.Equal(t, note, *pins[0].Note)
|
|
}
|
|
|
|
type usageParityProvider struct {
|
|
parser.ProviderBase
|
|
source parser.SourceRef
|
|
result parser.ParseResult
|
|
}
|
|
|
|
func (p *usageParityProvider) Discover(context.Context) ([]parser.SourceRef, error) {
|
|
return []parser.SourceRef{p.source}, nil
|
|
}
|
|
|
|
func (p *usageParityProvider) Fingerprint(
|
|
context.Context, parser.SourceRef,
|
|
) (parser.SourceFingerprint, error) {
|
|
return parser.SourceFingerprint{
|
|
Key: p.source.FingerprintKey, Size: 128, MTimeNS: 1_700_000_000_000_000_000,
|
|
}, nil
|
|
}
|
|
|
|
func (p *usageParityProvider) Parse(
|
|
context.Context, parser.ParseRequest,
|
|
) (parser.ParseOutcome, error) {
|
|
return parser.ParseOutcome{
|
|
Results: []parser.ParseResultOutcome{{Result: p.result}},
|
|
ResultSetComplete: true,
|
|
}, nil
|
|
}
|
|
|
|
type usageParityFactory struct{ provider *usageParityProvider }
|
|
|
|
func (f usageParityFactory) Definition() parser.AgentDef {
|
|
return f.provider.Definition()
|
|
}
|
|
|
|
func (f usageParityFactory) Capabilities() parser.Capabilities {
|
|
return f.provider.Capabilities()
|
|
}
|
|
|
|
func (f usageParityFactory) NewProvider(parser.ProviderConfig) parser.Provider {
|
|
return f.provider
|
|
}
|
|
|
|
func newUsageParityProvider(sourcePath, machine string) *usageParityProvider {
|
|
const rawID = "usage-equivalent"
|
|
messageOrdinal := 0
|
|
costUSD := 0.0125
|
|
started := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
return &usageParityProvider{
|
|
ProviderBase: parser.ProviderBase{
|
|
Def: parser.AgentDef{Type: parser.AgentCowork, FileBased: true},
|
|
Caps: parser.Capabilities{Source: parser.SourceCapabilities{
|
|
DiscoverSources: parser.CapabilitySupported,
|
|
CompositeFingerprint: parser.CapabilitySupported,
|
|
}},
|
|
},
|
|
source: parser.SourceRef{
|
|
Provider: parser.AgentCowork, Key: sourcePath,
|
|
DisplayPath: sourcePath, FingerprintKey: sourcePath,
|
|
},
|
|
result: parser.ParseResult{
|
|
Session: parser.ParsedSession{
|
|
ID: rawID, Project: "usage-parity", Machine: machine,
|
|
Agent: parser.AgentCowork, StartedAt: started, EndedAt: started,
|
|
FirstMessage: "usage parity fixture", MessageCount: 1,
|
|
UserMessageCount: 1,
|
|
File: parser.FileInfo{
|
|
Path: sourcePath, Size: 128, Mtime: 1_700_000_000_000_000_000,
|
|
},
|
|
},
|
|
Messages: []parser.ParsedMessage{{
|
|
Ordinal: 0, Role: parser.RoleUser,
|
|
Content: "usage parity fixture", Timestamp: started,
|
|
}},
|
|
UsageEvents: []parser.ParsedUsageEvent{{
|
|
SessionID: rawID, MessageOrdinal: &messageOrdinal,
|
|
Source: "literal-provider-usage", Model: "literal-model-v1",
|
|
InputTokens: 101, OutputTokens: 37,
|
|
CacheCreationInputTokens: 23, CacheReadInputTokens: 19,
|
|
ReasoningTokens: 11, CostUSD: &costUSD,
|
|
CostStatus: "exact", CostSource: "literal-fixture",
|
|
OccurredAt: "2026-01-02T03:04:05Z",
|
|
DedupKey: "usage-equivalent:event-1",
|
|
}},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestResyncContributorPersistsEquivalentUsageEvents(t *testing.T) {
|
|
localRoot := t.TempDir()
|
|
remoteRoot := t.TempDir()
|
|
localProvider := newUsageParityProvider(
|
|
filepath.Join(localRoot, "usage-equivalent.fixture"), "local",
|
|
)
|
|
remoteProvider := newUsageParityProvider(
|
|
filepath.Join(remoteRoot, "usage-equivalent.fixture"), "remote",
|
|
)
|
|
database := dbtest.OpenTestDB(t)
|
|
engine := sync.NewEngine(database, sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{parser.AgentCowork: {localRoot}},
|
|
Machine: "local",
|
|
ProviderFactories: []parser.ProviderFactory{
|
|
usageParityFactory{provider: localProvider},
|
|
},
|
|
ProviderMigrationModes: map[parser.AgentType]parser.ProviderMigrationMode{
|
|
parser.AgentCowork: parser.ProviderMigrationProviderAuthoritative,
|
|
},
|
|
})
|
|
t.Cleanup(engine.Close)
|
|
|
|
stats, err := engine.ResyncAllWithOptions(context.Background(), nil,
|
|
sync.RebuildOptions{Contributors: []sync.RebuildContributor{{
|
|
Name: "remote",
|
|
Config: sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{parser.AgentCowork: {remoteRoot}},
|
|
Machine: "remote", IDPrefix: "remote~", Ephemeral: true,
|
|
ProviderFactories: []parser.ProviderFactory{
|
|
usageParityFactory{provider: remoteProvider},
|
|
},
|
|
ProviderMigrationModes: map[parser.AgentType]parser.ProviderMigrationMode{
|
|
parser.AgentCowork: parser.ProviderMigrationProviderAuthoritative,
|
|
},
|
|
},
|
|
}}})
|
|
require.NoError(t, err)
|
|
require.False(t, stats.Aborted, "resync aborted: %+v", stats)
|
|
|
|
localEvents, err := database.GetUsageEvents(context.Background(), "usage-equivalent")
|
|
require.NoError(t, err)
|
|
require.Len(t, localEvents, 1)
|
|
remoteEvents, err := database.GetUsageEvents(context.Background(), "remote~usage-equivalent")
|
|
require.NoError(t, err)
|
|
require.Len(t, remoteEvents, 1)
|
|
|
|
localEvent := localEvents[0]
|
|
remoteEvent := remoteEvents[0]
|
|
assert.Equal(t, "usage-equivalent", localEvent.SessionID)
|
|
assert.Equal(t, "remote~usage-equivalent", remoteEvent.SessionID)
|
|
localEvent.ID = 0
|
|
remoteEvent.ID = 0
|
|
remoteEvent.SessionID = localEvent.SessionID
|
|
assert.Equal(t, localEvent, remoteEvent,
|
|
"contributor usage must differ only by its persisted session namespace")
|
|
|
|
messageOrdinal := 0
|
|
costUSD := 0.0125
|
|
assert.Equal(t, db.UsageEvent{
|
|
SessionID: "usage-equivalent", MessageOrdinal: &messageOrdinal,
|
|
Source: "literal-provider-usage", Model: "literal-model-v1",
|
|
InputTokens: 101, OutputTokens: 37,
|
|
CacheCreationInputTokens: 23, CacheReadInputTokens: 19,
|
|
ReasoningTokens: 11, CostUSD: &costUSD,
|
|
CostStatus: "exact", CostSource: "literal-fixture",
|
|
OccurredAt: "2026-01-02T03:04:05Z",
|
|
DedupKey: "usage-equivalent:event-1",
|
|
}, localEvent)
|
|
}
|
|
|
|
func TestResyncContributorPreservesAnnotationsOrphansAndOtherHosts(t *testing.T) {
|
|
localRoot := t.TempDir()
|
|
remoteRoot := t.TempDir()
|
|
otherRoot := t.TempDir()
|
|
env := setupSingleAgentTestEnvWithDirs(t, parser.AgentClaude, []string{localRoot})
|
|
dbtest.WriteTestFile(t, filepath.Join(localRoot, "local", "local.jsonl"),
|
|
[]byte(testjsonl.NewSessionBuilder().AddClaudeUser(tsZero, "local").String()))
|
|
remoteCurrent := filepath.Join(remoteRoot, "remote", "annotated.jsonl")
|
|
remoteOrphan := filepath.Join(remoteRoot, "remote", "remote-orphan.jsonl")
|
|
dbtest.WriteTestFile(t, remoteCurrent, []byte(testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "annotated remote").String()))
|
|
dbtest.WriteTestFile(t, remoteOrphan, []byte(testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarlyS1, "remote orphan").String()))
|
|
remoteConfig := sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{parser.AgentClaude: {remoteRoot}},
|
|
Machine: "remote", IDPrefix: "remote~", Ephemeral: true,
|
|
}
|
|
remoteEngine := sync.NewEngine(env.db, remoteConfig)
|
|
require.Equal(t, 2, remoteEngine.SyncAll(context.Background(), nil).Synced)
|
|
remoteEngine.Close()
|
|
annotation := "user annotation survives"
|
|
require.NoError(t, env.db.RenameSession("remote~annotated", &annotation))
|
|
require.NoError(t, os.Remove(remoteOrphan))
|
|
|
|
otherPath := filepath.Join(otherRoot, "other", "other-host.jsonl")
|
|
dbtest.WriteTestFile(t, otherPath, []byte(testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarlyS5, "other host archive").String()))
|
|
otherEngine := sync.NewEngine(env.db, sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{parser.AgentClaude: {otherRoot}},
|
|
Machine: "other", IDPrefix: "other~", Ephemeral: true,
|
|
})
|
|
require.Equal(t, 1, otherEngine.SyncAll(context.Background(), nil).Synced)
|
|
otherEngine.Close()
|
|
require.NoError(t, os.Remove(otherPath))
|
|
|
|
stats, err := env.engine.ResyncAllWithOptions(context.Background(), nil,
|
|
sync.RebuildOptions{Contributors: []sync.RebuildContributor{{
|
|
Name: "remote", Config: remoteConfig,
|
|
}}})
|
|
require.NoError(t, err)
|
|
require.False(t, stats.Aborted, "resync aborted: %+v", stats)
|
|
assert.Equal(t, 2, stats.OrphanedCopied)
|
|
annotated, err := env.db.GetSession(context.Background(), "remote~annotated")
|
|
require.NoError(t, err)
|
|
require.NotNil(t, annotated)
|
|
require.NotNil(t, annotated.DisplayName)
|
|
assert.Equal(t, annotation, *annotated.DisplayName)
|
|
for _, id := range []string{"remote~remote-orphan", "other~other-host"} {
|
|
session, getErr := env.db.GetSession(context.Background(), id)
|
|
require.NoError(t, getErr)
|
|
assert.NotNil(t, session, id)
|
|
}
|
|
}
|
|
|
|
func TestSyncEngineCodex(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentCodex)
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddCodexMeta(tsEarly, "test-uuid", "/home/user/code/api", "user").
|
|
AddCodexMessage(tsEarlyS1, "user", "Add tests").
|
|
AddCodexMessage(tsEarlyS5, "assistant", "Adding test coverage.").
|
|
String()
|
|
|
|
env.writeCodexSession(
|
|
t, filepath.Join("2024", "01", "15"),
|
|
"rollout-20240115-test-uuid.jsonl", content,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 1, Synced: 1, Skipped: 0})
|
|
|
|
assertSessionProject(t, env.db, "codex:test-uuid", "api")
|
|
assertSessionState(t, env.db, "codex:test-uuid", func(sess *db.Session) {
|
|
assert.Equal(t, "codex", sess.Agent, "agent = %q", sess.Agent)
|
|
})
|
|
}
|
|
|
|
func TestSyncEngineProgress(t *testing.T) {
|
|
env := setupFocusedTestEnv(t, parser.AgentClaude, parser.AgentPiebald)
|
|
|
|
msg := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "msg").
|
|
String()
|
|
|
|
var firstClaudePath string
|
|
for _, name := range []string{"a", "b", "c"} {
|
|
path := env.writeClaudeSession(
|
|
t, "test-proj", name+".jsonl", msg,
|
|
)
|
|
if firstClaudePath == "" {
|
|
firstClaudePath = path
|
|
}
|
|
}
|
|
piebald := createPiebaldDB(t, env.piebaldDir)
|
|
piebald.addChat(t, 42, "Piebald", "Prompt.", "Answer.", "2026-05-01T10:05:00Z")
|
|
|
|
var progressCalls int
|
|
var firstTotal int
|
|
var last sync.Progress
|
|
var events []sync.Progress
|
|
var seenCurrent sync.Progress
|
|
env.engine.SyncAll(context.Background(), func(p sync.Progress) {
|
|
progressCalls++
|
|
if firstTotal == 0 {
|
|
firstTotal = p.SessionsTotal
|
|
}
|
|
last = p
|
|
events = append(events, p)
|
|
if seenCurrent.Phase == "" {
|
|
current, ok := env.engine.CurrentProgress()
|
|
require.True(t, ok, "CurrentProgress should be available during sync")
|
|
assert.Equal(t, p.Phase, current.Phase, "current progress phase")
|
|
assert.Equal(t, p.SessionsTotal, current.SessionsTotal, "current progress total")
|
|
seenCurrent = current
|
|
}
|
|
})
|
|
|
|
assert.NotZero(t, progressCalls, "expected progress callbacks")
|
|
assert.Equal(t, 4, firstTotal, "first progress total = %d, want 4", firstTotal)
|
|
assert.Equal(t, 4, last.SessionsDone, "last progress = %d/%d, want 4/4", last.SessionsDone, last.SessionsTotal)
|
|
assert.Equal(t, 4, last.SessionsTotal, "last progress = %d/%d, want 4/4", last.SessionsDone, last.SessionsTotal)
|
|
requireProgressDoneOnce(t, events, 4)
|
|
require.NotEmpty(t, seenCurrent.Phase, "expected progress to be observed")
|
|
_, ok := env.engine.CurrentProgress()
|
|
assert.False(t, ok, "CurrentProgress should be cleared after sync")
|
|
requireDiscoveryBeforeSyncing(t, events, false)
|
|
|
|
progressCalls = 0
|
|
firstTotal = 0
|
|
last = sync.Progress{}
|
|
events = nil
|
|
env.engine.SyncAll(context.Background(), func(p sync.Progress) {
|
|
progressCalls++
|
|
if firstTotal == 0 {
|
|
firstTotal = p.SessionsTotal
|
|
}
|
|
last = p
|
|
events = append(events, p)
|
|
})
|
|
assert.NotZero(t, progressCalls, "expected progress callbacks on second sync")
|
|
assert.Equal(t, 4, firstTotal, "second first progress total = %d, want 4", firstTotal)
|
|
assert.Equal(t, 4, last.SessionsDone, "second last progress = %d/%d, want 4/4", last.SessionsDone, last.SessionsTotal)
|
|
assert.Equal(t, 4, last.SessionsTotal, "second last progress = %d/%d, want 4/4", last.SessionsDone, last.SessionsTotal)
|
|
requireProgressDoneOnce(t, events, 4)
|
|
|
|
env.engine.SyncPaths([]string{firstClaudePath})
|
|
_, ok = env.engine.CurrentProgress()
|
|
assert.False(t, ok, "CurrentProgress should be cleared after SyncPaths")
|
|
|
|
var resyncEvents []sync.Progress
|
|
stats := env.engine.ResyncAll(context.Background(), func(p sync.Progress) {
|
|
resyncEvents = append(resyncEvents, p)
|
|
})
|
|
require.False(t, stats.Aborted, "resync aborted: %+v", stats.Warnings)
|
|
|
|
if env.db.HasFTS() {
|
|
var fts sync.Progress
|
|
for _, event := range resyncEvents {
|
|
if event.Phase == sync.PhaseRebuildingSearch {
|
|
fts = event
|
|
break
|
|
}
|
|
}
|
|
require.Equal(t, sync.PhaseRebuildingSearch, fts.Phase, "missing FTS rebuild progress event; events=%+v", resyncEvents)
|
|
assert.True(t, fts.Resync, "FTS progress should identify full resync")
|
|
assert.Contains(t, fts.Detail, "Rebuilding search index")
|
|
assert.Contains(t, fts.Hint, "may take a while")
|
|
}
|
|
|
|
requireDiscoveryBeforeSyncing(t, resyncEvents, true)
|
|
}
|
|
|
|
func requireProgressDoneOnce(t *testing.T, events []sync.Progress, wantTotal int) {
|
|
t.Helper()
|
|
require.NotEmpty(t, events, "expected progress callbacks")
|
|
var doneCount int
|
|
var firstDoneIdx = -1
|
|
for i, e := range events {
|
|
if e.Phase == sync.PhaseDone {
|
|
doneCount++
|
|
if firstDoneIdx == -1 {
|
|
firstDoneIdx = i
|
|
}
|
|
}
|
|
}
|
|
require.Equal(t, 1, doneCount, "PhaseDone emitted %d times, want exactly 1; events=%+v", doneCount, events)
|
|
require.Equal(t, len(events)-1, firstDoneIdx, "PhaseDone at index %d, want last event (index %d)", firstDoneIdx, len(events)-1)
|
|
last := events[len(events)-1]
|
|
require.Equal(t, last.SessionsTotal, last.SessionsDone, "final progress = %d/%d, want done", last.SessionsDone, last.SessionsTotal)
|
|
require.Equal(t, wantTotal, last.SessionsTotal, "final progress = %d/%d, want %d/%d", last.SessionsDone, last.SessionsTotal, wantTotal, wantTotal)
|
|
var peakMessages int
|
|
for _, e := range events {
|
|
if e.MessagesIndexed > peakMessages {
|
|
peakMessages = e.MessagesIndexed
|
|
}
|
|
}
|
|
require.GreaterOrEqual(t, last.MessagesIndexed, peakMessages,
|
|
"final MessagesIndexed = %d regressed from peak %d", last.MessagesIndexed, peakMessages)
|
|
}
|
|
|
|
func requireDiscoveryBeforeSyncing(t *testing.T, events []sync.Progress, wantResync bool) {
|
|
t.Helper()
|
|
discoveryIdx, syncingIdx := -1, -1
|
|
for i, event := range events {
|
|
if event.Phase == sync.PhaseDiscovering && discoveryIdx == -1 {
|
|
discoveryIdx = i
|
|
}
|
|
if event.Phase == sync.PhaseSyncing && syncingIdx == -1 {
|
|
syncingIdx = i
|
|
}
|
|
}
|
|
require.NotEqual(t, -1, discoveryIdx,
|
|
"missing discovery progress event; events=%+v", events)
|
|
require.NotEqual(t, -1, syncingIdx,
|
|
"missing syncing progress event; events=%+v", events)
|
|
assert.Less(t, discoveryIdx, syncingIdx,
|
|
"discovery must be reported before syncing")
|
|
assert.Equal(t, wantResync, events[discoveryIdx].Resync,
|
|
"discovery progress resync flag")
|
|
assert.Contains(t, events[discoveryIdx].Detail, "Discovering sessions")
|
|
}
|
|
|
|
func TestSyncEngineHashSkip(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "msg1").
|
|
String()
|
|
|
|
path := env.writeClaudeSession(
|
|
t, "test-proj", "hash-test.jsonl", content,
|
|
)
|
|
|
|
// First sync
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 1 + 0, Synced: 1, Skipped: 0})
|
|
|
|
// Verify file metadata was stored
|
|
size, mtime, ok := env.db.GetSessionFileInfo("hash-test")
|
|
require.True(t, ok, "file info not stored")
|
|
require.NotZero(t, mtime, "mtime not stored")
|
|
require.NotZero(t, size, "size not stored")
|
|
|
|
// Second sync — unchanged content → skipped
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 0 + 1, Synced: 0, Skipped: 1})
|
|
|
|
// Overwrite with different content (changes mtime).
|
|
different := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "msg2").
|
|
String()
|
|
os.WriteFile(path, []byte(different), 0o644)
|
|
|
|
// Third sync — mtime changed → re-synced
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 1 + 0, Synced: 1, Skipped: 0})
|
|
}
|
|
|
|
func TestSyncAllDedupesClaudeSourcesBySessionID(t *testing.T) {
|
|
liveDir := t.TempDir()
|
|
archiveDir := t.TempDir()
|
|
env := setupTestEnv(t, WithClaudeDirs([]string{liveDir, archiveDir}))
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "same logical session").
|
|
String()
|
|
livePath := env.writeSession(
|
|
t, liveDir, filepath.Join("proj-live", "duplicate.jsonl"), content,
|
|
)
|
|
archivePath := env.writeSession(
|
|
t, archiveDir, filepath.Join("proj-archive", "duplicate.jsonl"), content,
|
|
)
|
|
older := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
newer := older.Add(time.Second)
|
|
require.NoError(t, os.Chtimes(archivePath, older, older))
|
|
require.NoError(t, os.Chtimes(livePath, newer, newer))
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
liveInfo, err := os.Stat(livePath)
|
|
require.NoError(t, err)
|
|
storedSize, storedMtime, ok := env.db.GetSessionFileInfo("duplicate")
|
|
require.True(t, ok, "file info not stored")
|
|
assert.Equal(t, liveInfo.Size(), storedSize)
|
|
assert.Equal(t, liveInfo.ModTime().UnixNano(), storedMtime)
|
|
|
|
touchedArchive := newer.Add(time.Second)
|
|
require.NoError(t, os.Chtimes(archivePath, touchedArchive, touchedArchive))
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 0,
|
|
Skipped: 1,
|
|
})
|
|
}
|
|
|
|
func TestSyncAllClaudeDuplicateLiveGrowthBeatsUnchangedStoredArchive(t *testing.T) {
|
|
liveDir := t.TempDir()
|
|
archiveDir := t.TempDir()
|
|
env := setupTestEnv(t, WithClaudeDirs([]string{liveDir, archiveDir}))
|
|
|
|
initial := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "initial message").
|
|
String()
|
|
livePath := env.writeSession(
|
|
t, liveDir, filepath.Join("proj-live", "duplicate-grow.jsonl"), initial,
|
|
)
|
|
archivePath := env.writeSession(
|
|
t, archiveDir, filepath.Join("proj-archive", "duplicate-grow.jsonl"), initial,
|
|
)
|
|
older := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
newer := older.Add(time.Second)
|
|
require.NoError(t, os.Chtimes(livePath, older, older))
|
|
require.NoError(t, os.Chtimes(archivePath, newer, newer))
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
assert.Equal(t, archivePath, env.db.GetSessionFilePath("duplicate-grow"))
|
|
|
|
f, err := os.OpenFile(livePath, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err)
|
|
_, err = f.WriteString(testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZeroS5, "live follow-up").
|
|
String())
|
|
require.NoError(t, err)
|
|
require.NoError(t, f.Close())
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
assert.Equal(t, livePath, env.db.GetSessionFilePath("duplicate-grow"))
|
|
assertMessageContent(t, env.db, "duplicate-grow", "initial message", "live follow-up")
|
|
}
|
|
|
|
func TestSyncAllSinceClaudeDuplicateTouchedStaleDoesNotBeatPreferred(t *testing.T) {
|
|
liveDir := t.TempDir()
|
|
archiveDir := t.TempDir()
|
|
env := setupTestEnv(t, WithClaudeDirs([]string{liveDir, archiveDir}))
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "same logical session").
|
|
String()
|
|
livePath := env.writeSession(
|
|
t, liveDir, filepath.Join("proj-live", "duplicate-since.jsonl"), content,
|
|
)
|
|
archivePath := env.writeSession(
|
|
t, archiveDir, filepath.Join("proj-archive", "duplicate-since.jsonl"), content,
|
|
)
|
|
baseTime := time.Now().Add(-3 * time.Hour)
|
|
preferredTime := baseTime.Add(time.Hour)
|
|
require.NoError(t, os.Chtimes(archivePath, baseTime, baseTime))
|
|
require.NoError(t, os.Chtimes(livePath, preferredTime, preferredTime))
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
assert.Equal(t, livePath, env.db.GetSessionFilePath("duplicate-since"))
|
|
|
|
cutoff := preferredTime.Add(time.Hour)
|
|
touchedArchive := cutoff.Add(time.Minute)
|
|
require.NoError(t, os.Chtimes(archivePath, touchedArchive, touchedArchive))
|
|
|
|
stats := env.engine.SyncAllSince(context.Background(), cutoff, nil)
|
|
assert.Zero(t, stats.Synced, "stale duplicate must not be re-synced")
|
|
assert.Equal(t, livePath, env.db.GetSessionFilePath("duplicate-since"))
|
|
assertMessageContent(t, env.db, "duplicate-since", "same logical session")
|
|
}
|
|
|
|
func TestSyncPathsClaudeDuplicateTouchedStaleDoesNotBeatPreferred(t *testing.T) {
|
|
liveDir := t.TempDir()
|
|
archiveDir := t.TempDir()
|
|
env := setupTestEnv(t, WithClaudeDirs([]string{liveDir, archiveDir}))
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "same logical session").
|
|
String()
|
|
livePath := env.writeSession(
|
|
t, liveDir, filepath.Join("proj-live", "duplicate-watch.jsonl"), content,
|
|
)
|
|
archivePath := env.writeSession(
|
|
t, archiveDir, filepath.Join("proj-archive", "duplicate-watch.jsonl"), content,
|
|
)
|
|
older := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
newer := older.Add(time.Second)
|
|
require.NoError(t, os.Chtimes(archivePath, older, older))
|
|
require.NoError(t, os.Chtimes(livePath, newer, newer))
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
assert.Equal(t, livePath, env.db.GetSessionFilePath("duplicate-watch"))
|
|
|
|
touchedArchive := newer.Add(time.Second)
|
|
require.NoError(t, os.Chtimes(archivePath, touchedArchive, touchedArchive))
|
|
|
|
env.engine.SyncPaths([]string{archivePath})
|
|
assert.Zero(t, env.engine.LastSyncStats().Synced,
|
|
"stale watcher duplicate must not be re-synced")
|
|
assert.Equal(t, livePath, env.db.GetSessionFilePath("duplicate-watch"))
|
|
assertMessageContent(t, env.db, "duplicate-watch", "same logical session")
|
|
}
|
|
|
|
func TestSyncAllClaudeDuplicatePathRewriterKeepsStoredPreferred(t *testing.T) {
|
|
liveDir := t.TempDir()
|
|
archiveDir := t.TempDir()
|
|
database := dbtest.OpenTestDB(t)
|
|
rewriter := func(path string) string {
|
|
return "host:" + path
|
|
}
|
|
engine := sync.NewEngine(database, sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentClaude: {liveDir, archiveDir},
|
|
},
|
|
Machine: "remote-host",
|
|
IDPrefix: "host~",
|
|
PathRewriter: rewriter,
|
|
})
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "same logical session").
|
|
String()
|
|
livePath := filepath.Join(liveDir, "proj-live", "duplicate-remote.jsonl")
|
|
archivePath := filepath.Join(archiveDir, "proj-archive", "duplicate-remote.jsonl")
|
|
dbtest.WriteTestFile(t, livePath, []byte(content))
|
|
dbtest.WriteTestFile(t, archivePath, []byte(content))
|
|
older := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
newer := older.Add(time.Second)
|
|
require.NoError(t, os.Chtimes(archivePath, older, older))
|
|
require.NoError(t, os.Chtimes(livePath, newer, newer))
|
|
|
|
runSyncAndAssert(t, engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
assert.Equal(t, rewriter(livePath), database.GetSessionFilePath("host~duplicate-remote"))
|
|
|
|
touchedArchive := newer.Add(time.Second)
|
|
require.NoError(t, os.Chtimes(archivePath, touchedArchive, touchedArchive))
|
|
|
|
runSyncAndAssert(t, engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 0,
|
|
Skipped: 1,
|
|
})
|
|
assert.Equal(t, rewriter(livePath), database.GetSessionFilePath("host~duplicate-remote"))
|
|
assertMessageContent(t, database, "host~duplicate-remote", "same logical session")
|
|
}
|
|
|
|
func TestSyncEngineSkipCache(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
// Write malformed content that produces 0 valid messages
|
|
path := env.writeClaudeSession(
|
|
t, "test-proj", "skip-test.jsonl",
|
|
"not json at all\x00\x01",
|
|
)
|
|
|
|
// The deliberately malformed content yields one parser malformed
|
|
// line, which the sync summary now surfaces per agent.
|
|
malformed := sync.AnomalyStats{
|
|
MalformedLinesByAgent: map[string]int{"claude": 1},
|
|
MalformedLinesTotal: 1,
|
|
}
|
|
|
|
// First sync — file parsed (empty session stored)
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1, Synced: 1, Skipped: 0, Anomalies: malformed,
|
|
})
|
|
|
|
// Second sync — unchanged mtime, should be skipped
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 0 + 1, Synced: 0, Skipped: 1})
|
|
|
|
// Touch file (change mtime) but keep same content
|
|
time.Sleep(10 * time.Millisecond)
|
|
os.Chtimes(path, time.Now(), time.Now())
|
|
|
|
// Third sync — mtime changed → re-synced (harmless)
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1 + 0, Synced: 1, Skipped: 0, Anomalies: malformed,
|
|
})
|
|
}
|
|
|
|
func TestSyncEngineFileAppend(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
initial := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "first").
|
|
String()
|
|
|
|
path := env.writeClaudeSession(
|
|
t, "test-proj", "append-test.jsonl", initial,
|
|
)
|
|
|
|
// First sync
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 1 + 0, Synced: 1, Skipped: 0})
|
|
|
|
assertSessionMessageCount(t, env.db, "append-test", 1)
|
|
|
|
// Append a new message (changes size and hash)
|
|
appended := initial + testjsonl.NewSessionBuilder().
|
|
AddClaudeAssistant(tsZeroS5, "reply").
|
|
String()
|
|
|
|
os.WriteFile(path, []byte(appended), 0o644)
|
|
|
|
// Re-sync — different size → re-synced
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 1 + 0, Synced: 1, Skipped: 0})
|
|
|
|
assertSessionMessageCount(t, env.db, "append-test", 2)
|
|
}
|
|
|
|
// TestSyncSingleSessionReplacesContent verifies that an
|
|
// explicit SyncSingleSession replaces existing message
|
|
// content (same ordinals, different text).
|
|
func TestSyncSingleSessionReplacesContent(
|
|
t *testing.T,
|
|
) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
original := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "original question").
|
|
AddClaudeAssistant(tsZeroS5, "original answer").
|
|
String()
|
|
|
|
path := env.writeClaudeSession(
|
|
t, "test-proj", "replace-test.jsonl", original,
|
|
)
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
assertMessageContent(
|
|
t, env.db, "replace-test",
|
|
"original question", "original answer",
|
|
)
|
|
|
|
// Rewrite the file with different content but same
|
|
// number of messages (same ordinals 0 and 1).
|
|
updated := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "updated question").
|
|
AddClaudeAssistant(tsZeroS5, "updated answer").
|
|
String()
|
|
os.WriteFile(path, []byte(updated), 0o644)
|
|
|
|
// SyncSingleSession should fully replace messages.
|
|
err := env.engine.SyncSingleSession(
|
|
"replace-test",
|
|
)
|
|
require.NoError(t, err, "SyncSingleSession")
|
|
|
|
assertMessageContent(
|
|
t, env.db, "replace-test",
|
|
"updated question", "updated answer",
|
|
)
|
|
}
|
|
|
|
func TestSyncSingleSessionHash(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "hello").
|
|
AddClaudeAssistant(tsZeroS5, "hi").
|
|
String()
|
|
|
|
env.writeClaudeSession(
|
|
t, "test-proj", "single-hash.jsonl", content,
|
|
)
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
env.assertResyncRoundTrip(t, "single-hash")
|
|
}
|
|
|
|
func TestSyncSingleSessionHashCodex(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentCodex)
|
|
|
|
uuid := "a1b2c3d4-1234-5678-9abc-def012345678"
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddCodexMeta(tsEarly, uuid, "/home/user/code/api", "user").
|
|
AddCodexMessage(tsEarlyS1, "user", "Add tests").
|
|
AddCodexMessage(tsEarlyS5, "assistant", "Adding test coverage.").
|
|
String()
|
|
|
|
env.writeCodexSession(
|
|
t, filepath.Join("2024", "01", "15"),
|
|
"rollout-20240115-"+uuid+".jsonl", content,
|
|
)
|
|
|
|
sessionID := "codex:" + uuid
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
env.assertResyncRoundTrip(t, sessionID)
|
|
}
|
|
|
|
func TestSyncAllImportsCodexExec(
|
|
t *testing.T,
|
|
) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentCodex)
|
|
|
|
uuid := "e5f6a7b8-5678-9012-cdef-123456789012"
|
|
// Exec-originated sessions should be imported during the
|
|
// normal bulk sync path.
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddCodexMeta(
|
|
tsEarly, uuid,
|
|
"/home/user/code/api", "codex_exec",
|
|
).
|
|
AddCodexMessage(tsEarlyS1, "user", "run ls").
|
|
AddCodexMessage(tsEarlyS5, "assistant", "done").
|
|
String()
|
|
|
|
env.writeCodexSession(
|
|
t, filepath.Join("2024", "01", "15"),
|
|
"rollout-20240115-"+uuid+".jsonl", content,
|
|
)
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
assertSessionState(
|
|
t, env.db, "codex:"+uuid,
|
|
func(sess *db.Session) {
|
|
assert.Equal(t, "codex", sess.Agent, "agent = %q, want codex", sess.Agent)
|
|
},
|
|
)
|
|
}
|
|
|
|
func TestSyncAllImportsCodexExecFromLegacySkipCache(
|
|
t *testing.T,
|
|
) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentCodex)
|
|
|
|
uuid := "f6a7b8c9-6789-0123-def0-234567890123"
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddCodexMeta(
|
|
tsEarly, uuid,
|
|
"/home/user/code/api", "codex_exec",
|
|
).
|
|
AddCodexMessage(tsEarlyS1, "user", "run ls").
|
|
AddCodexMessage(tsEarlyS5, "assistant", "done").
|
|
String()
|
|
|
|
path := env.writeCodexSession(
|
|
t, filepath.Join("2024", "01", "15"),
|
|
"rollout-20240115-"+uuid+".jsonl", content,
|
|
)
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err, "stat codex session")
|
|
|
|
require.NoError(t, env.db.ReplaceSkippedFiles(map[string]int64{
|
|
path: info.ModTime().UnixNano(),
|
|
}), "seed skipped files")
|
|
|
|
// setupTestEnv already built an engine, which ran the
|
|
// codex exec migration against an empty skip cache and
|
|
// flipped the flag to "done". Reset the flag so the new
|
|
// engine below observes a legacy skip entry and scrubs
|
|
// it, matching the production upgrade path.
|
|
err = env.db.SetSyncState(
|
|
sync.CodexExecMigrationKey, "",
|
|
)
|
|
require.NoError(t, err, "reset migration flag")
|
|
|
|
env.engine = sync.NewEngine(env.db, sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentClaude: {env.claudeDir},
|
|
parser.AgentCodex: {env.codexDir},
|
|
parser.AgentCursor: {env.cursorDir},
|
|
parser.AgentGemini: {env.geminiDir},
|
|
parser.AgentOpenCode: {env.opencodeDir},
|
|
parser.AgentIflow: {env.iflowDir},
|
|
parser.AgentAmp: {env.ampDir},
|
|
parser.AgentPi: {env.piDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
assertSessionState(
|
|
t, env.db, "codex:"+uuid,
|
|
func(sess *db.Session) {
|
|
assert.Equal(t, "codex", sess.Agent, "agent = %q, want codex", sess.Agent)
|
|
},
|
|
)
|
|
}
|
|
|
|
// TestCodexExecMigrationIdempotent verifies that once the
|
|
// codex exec skip cache migration has run, subsequent engine
|
|
// starts do not re-scan or remove entries — even those that
|
|
// point at codex_exec files, which legitimately get cached
|
|
// post-migration when the parser fails on them. The flag in
|
|
// pg_sync_state is the gate; without it a broken exec file
|
|
// would be reopened on every startup.
|
|
func TestCodexExecMigrationIdempotent(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
// setupTestEnv already built an engine that set the
|
|
// migration flag against an empty skip cache. Write a
|
|
// codex exec file and seed it into the skip cache to
|
|
// mimic a fresh parse-error cache entry made by a
|
|
// post-migration sync.
|
|
uuid := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddCodexMeta(
|
|
tsEarly, uuid,
|
|
"/home/user/code/api", "codex_exec",
|
|
).
|
|
AddCodexMessage(tsEarlyS1, "user", "run ls").
|
|
String()
|
|
|
|
path := env.writeCodexSession(
|
|
t, filepath.Join("2024", "01", "15"),
|
|
"rollout-20240115-"+uuid+".jsonl", content,
|
|
)
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err, "stat codex session")
|
|
cacheKey := fmt.Sprintf("%s?source_hash=%x", path, sha256.Sum256([]byte(content)))
|
|
|
|
require.NoError(t, env.db.ReplaceSkippedFiles(map[string]int64{
|
|
cacheKey: info.ModTime().UnixNano(),
|
|
}), "seed skipped files")
|
|
|
|
// Rebuild the engine without resetting the migration
|
|
// flag. The migration must be a no-op: the seeded entry
|
|
// stays in the DB and the engine respects it on sync.
|
|
env.engine = sync.NewEngine(env.db, sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentClaude: {env.claudeDir},
|
|
parser.AgentCodex: {env.codexDir},
|
|
parser.AgentCursor: {env.cursorDir},
|
|
parser.AgentGemini: {env.geminiDir},
|
|
parser.AgentOpenCode: {env.opencodeDir},
|
|
parser.AgentIflow: {env.iflowDir},
|
|
parser.AgentAmp: {env.ampDir},
|
|
parser.AgentPi: {env.piDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
loaded, err := env.db.LoadSkippedFiles()
|
|
require.NoError(t, err, "load skipped files")
|
|
_, ok := loaded[cacheKey]
|
|
require.True(t, ok,
|
|
"post-migration skip entry for %s was cleared; migration must be idempotent",
|
|
path,
|
|
)
|
|
}
|
|
|
|
func TestSyncEngineTombstoneClearOnMtimeChange(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
// Write something that produces 0 messages but parses OK
|
|
path := env.writeClaudeSession(
|
|
t, "test-proj", "tombstone-clear.jsonl", "garbage\n",
|
|
)
|
|
|
|
// First sync
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
// Replace with valid content
|
|
valid := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "hello").
|
|
AddClaudeAssistant(tsZeroS5, "hi").
|
|
String()
|
|
|
|
os.WriteFile(path, []byte(valid), 0o644)
|
|
|
|
// Re-sync — content changed (different size) → re-synced
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 1 + 0, Synced: 1, Skipped: 0})
|
|
|
|
assertSessionMessageCount(t, env.db, "tombstone-clear", 2)
|
|
}
|
|
|
|
func TestSyncSingleSessionProjectFallback(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
// 1. Create a session in a directory "default-proj"
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "hello").
|
|
String()
|
|
|
|
env.writeClaudeSession(
|
|
t, "default-proj", "fallback-test.jsonl", content,
|
|
)
|
|
|
|
// 2. Initial sync - should get "default-proj"
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
assertSessionProject(t, env.db, "fallback-test", "default_proj")
|
|
|
|
// 3. Manually update project to "custom_proj"
|
|
// This simulates a user override we want to preserve
|
|
env.updateSessionProject(t, "fallback-test", "custom_proj")
|
|
|
|
assertSessionProject(t, env.db, "fallback-test", "custom_proj")
|
|
|
|
// 4. SyncSingleSession should NOT revert to "default_proj"
|
|
err := env.engine.SyncSingleSession("fallback-test")
|
|
require.NoError(t, err, "SyncSingleSession")
|
|
|
|
assertSessionProject(t, env.db, "fallback-test", "custom_proj")
|
|
|
|
// Case A: Empty project -> should fall back to directory
|
|
env.updateSessionProject(t, "fallback-test", "")
|
|
|
|
err = env.engine.SyncSingleSession("fallback-test")
|
|
require.NoError(t, err, "SyncSingleSession (empty)")
|
|
|
|
assertSessionProject(t, env.db, "fallback-test", "default_proj")
|
|
|
|
// Case B: Bad project -> should fall back to directory
|
|
env.updateSessionProject(t, "fallback-test", "_Users_alice_bad")
|
|
|
|
err = env.engine.SyncSingleSession("fallback-test")
|
|
require.NoError(t, err, "SyncSingleSession (bad)")
|
|
|
|
assertSessionProject(t, env.db, "fallback-test", "default_proj")
|
|
}
|
|
|
|
func TestSyncEngineNoTrailingNewline(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "Hello").
|
|
StringNoTrailingNewline()
|
|
|
|
env.writeClaudeSession(
|
|
t, "test-proj", "no-newline.jsonl", content,
|
|
)
|
|
|
|
// Sync should succeed
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 1 + 0, Synced: 1, Skipped: 0})
|
|
|
|
assertSessionMessageCount(t, env.db, "no-newline", 1)
|
|
}
|
|
|
|
func TestSyncPathsClaude(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "Hello").
|
|
String()
|
|
untouchedContent := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "Untouched").
|
|
String()
|
|
|
|
path := env.writeClaudeSession(
|
|
t, "test-proj", "paths-test.jsonl", content,
|
|
)
|
|
env.writeClaudeSession(
|
|
t, "test-proj", "paths-untouched.jsonl", untouchedContent,
|
|
)
|
|
|
|
// Initial full sync
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 2 + 0, Synced: 2, Skipped: 0})
|
|
|
|
assertSessionMessageCount(t, env.db, "paths-test", 1)
|
|
assertSessionMessageCount(t, env.db, "paths-untouched", 1)
|
|
|
|
// Append a message (changes size and hash)
|
|
appended := content + testjsonl.NewSessionBuilder().
|
|
AddClaudeAssistant(tsZeroS5, "reply").
|
|
String()
|
|
os.WriteFile(path, []byte(appended), 0o644)
|
|
|
|
// SyncPaths with just the changed file
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
assertSessionMessageCount(t, env.db, "paths-test", 2)
|
|
assertSessionMessageCount(t, env.db, "paths-untouched", 1)
|
|
}
|
|
|
|
func TestSyncPathsIgnoresNonSessionFiles(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
// SyncPaths with non-session paths: no panic, no error
|
|
env.engine.SyncPaths([]string{
|
|
filepath.Join(env.claudeDir, "some-dir"),
|
|
filepath.Join(env.claudeDir, "proj", "README.md"),
|
|
"/tmp/random-file.txt",
|
|
})
|
|
}
|
|
|
|
func TestSyncPathsCodex(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
uuid := "c3d4e5f6-3456-7890-abcd-ef1234567890"
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddCodexMeta(
|
|
tsEarly, uuid,
|
|
"/home/user/code/api", "user",
|
|
).
|
|
AddCodexMessage(tsEarlyS1, "user", "Add tests").
|
|
String()
|
|
|
|
path := env.writeCodexSession(
|
|
t, filepath.Join("2024", "01", "15"),
|
|
"rollout-20240115-"+uuid+".jsonl", content,
|
|
)
|
|
|
|
// SyncPaths should process this Codex file
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
assertSessionState(
|
|
t, env.db, "codex:"+uuid,
|
|
func(sess *db.Session) {
|
|
assert.Equal(t, "codex", sess.Agent, "agent = %q, want codex", sess.Agent)
|
|
},
|
|
)
|
|
}
|
|
|
|
func TestSyncPathsIgnoresAgentFiles(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "Hello").
|
|
String()
|
|
|
|
// Create an agent-* file (should be ignored)
|
|
path := env.writeClaudeSession(
|
|
t, "proj", "agent-abc.jsonl", content,
|
|
)
|
|
|
|
// SyncPaths should ignore agent-* files
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
// No session should exist for agent-abc
|
|
sess, _ := env.db.GetSession(
|
|
context.Background(), "agent-abc",
|
|
)
|
|
assert.Nil(t, sess, "agent-* file should be ignored")
|
|
}
|
|
|
|
func TestSyncEngineCodexNoTrailingNewline(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
uuid := "b2c3d4e5-2345-6789-0abc-def123456789"
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddCodexMeta(tsEarly, uuid, "/home/user/code/api", "user").
|
|
AddCodexMessage(tsEarlyS1, "user", "Hello").
|
|
StringNoTrailingNewline()
|
|
|
|
env.writeCodexSession(
|
|
t, filepath.Join("2024", "01", "15"),
|
|
"rollout-20240115-"+uuid+".jsonl", content,
|
|
)
|
|
|
|
// Sync should succeed
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 1 + 0, Synced: 1, Skipped: 0})
|
|
|
|
assertSessionMessageCount(t, env.db, "codex:"+uuid, 1)
|
|
}
|
|
|
|
func TestSyncPathsTrailingSlashDirs(t *testing.T) {
|
|
// Dirs with trailing slashes should still work after
|
|
// filepath.Clean normalisation in isUnder.
|
|
claudeDir := t.TempDir() + "/"
|
|
codexDir := t.TempDir() + "/"
|
|
env := setupTestEnv(t, WithClaudeDirs([]string{claudeDir}), WithCodexDirs([]string{codexDir}))
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "Hello").
|
|
String()
|
|
|
|
claudePath := filepath.Join(
|
|
claudeDir, "proj", "trailing.jsonl",
|
|
)
|
|
dbtest.WriteTestFile(t, claudePath, []byte(content))
|
|
|
|
env.engine.SyncPaths([]string{claudePath})
|
|
|
|
assertSessionMessageCount(t, env.db, "trailing", 1)
|
|
}
|
|
|
|
func TestSyncPathsGemini(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
sessionID := "gem-test-uuid"
|
|
hash := "abcdef1234567890"
|
|
content := testjsonl.GeminiSessionJSON(
|
|
sessionID, hash, tsEarly, tsEarlyS5,
|
|
[]map[string]any{
|
|
testjsonl.GeminiUserMsg(
|
|
"m1", tsEarly, "Hello Gemini",
|
|
),
|
|
testjsonl.GeminiAssistantMsg(
|
|
"m2", tsEarlyS5, "Hi there!", nil,
|
|
),
|
|
},
|
|
)
|
|
|
|
path := env.writeGeminiSession(
|
|
t,
|
|
filepath.Join(
|
|
"tmp", hash, "chats",
|
|
"session-001.json",
|
|
),
|
|
content,
|
|
)
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
assertSessionState(
|
|
t, env.db, "gemini:"+sessionID,
|
|
func(sess *db.Session) {
|
|
assert.Equal(t, "gemini", sess.Agent, "agent = %q, want gemini", sess.Agent)
|
|
},
|
|
)
|
|
assertSessionMessageCount(t, env.db, "gemini:"+sessionID, 2)
|
|
}
|
|
|
|
func TestSyncPathsGeminiJSONL(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
sessionID := "gem-test-jsonl"
|
|
hash := "abcdef1234567890"
|
|
content := strings.Join([]string{
|
|
`{"sessionId":"gem-test-jsonl","projectHash":"abcdef1234567890","startTime":"` + tsEarly + `","lastUpdated":"` + tsEarly + `","kind":"main"}`,
|
|
`{"id":"m1","timestamp":"` + tsEarly + `","type":"user","content":[{"text":"Hello Gemini"}]}`,
|
|
`{"$set":{"lastUpdated":"` + tsEarlyS5 + `"}}`,
|
|
`{"id":"m2","timestamp":"` + tsEarlyS5 + `","type":"gemini","content":"Hi there!","model":"gemini-3.1-pro-preview","tokens":{"input":10,"output":5,"cached":0}}`,
|
|
}, "\n")
|
|
|
|
path := env.writeGeminiSession(
|
|
t,
|
|
filepath.Join(
|
|
"tmp", hash, "chats",
|
|
"session-001.jsonl",
|
|
),
|
|
content,
|
|
)
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
assertSessionState(
|
|
t, env.db, "gemini:"+sessionID,
|
|
func(sess *db.Session) {
|
|
assert.Equal(t, "gemini", sess.Agent, "agent = %q, want gemini", sess.Agent)
|
|
},
|
|
)
|
|
assertSessionMessageCount(t, env.db, "gemini:"+sessionID, 2)
|
|
}
|
|
|
|
func TestSyncPathsGeminiProjectMetadataEventRefreshesProject(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
sessionID := "gem-project-refresh"
|
|
projectsPath := filepath.Join(env.geminiDir, "projects.json")
|
|
writeProject := func(name string) {
|
|
t.Helper()
|
|
require.NoError(t, os.WriteFile(
|
|
projectsPath,
|
|
fmt.Appendf(nil,
|
|
`{"projects":{"/Users/alice/code/%s":"alias"}}`,
|
|
name,
|
|
),
|
|
0o644,
|
|
), "write projects")
|
|
}
|
|
writeProject("one")
|
|
path := env.writeGeminiSession(
|
|
t,
|
|
filepath.Join(
|
|
"tmp", "alias", "chats",
|
|
"session-001.json",
|
|
),
|
|
testjsonl.GeminiSessionJSON(
|
|
sessionID, "alias", tsEarly, tsEarlyS5,
|
|
[]map[string]any{
|
|
testjsonl.GeminiUserMsg(
|
|
"m1", tsEarly, "Hello Gemini",
|
|
),
|
|
testjsonl.GeminiAssistantMsg(
|
|
"m2", tsEarlyS5, "Hi there!", nil,
|
|
),
|
|
},
|
|
),
|
|
)
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
assertSessionState(
|
|
t, env.db, "gemini:"+sessionID,
|
|
func(sess *db.Session) {
|
|
assert.Equal(t, "one", sess.Project)
|
|
},
|
|
)
|
|
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err, "stat gemini session")
|
|
env.engine.InjectSkipCache(map[string]int64{
|
|
path: info.ModTime().UnixNano(),
|
|
})
|
|
|
|
writeProject("two")
|
|
env.engine.SyncPaths([]string{projectsPath})
|
|
|
|
assertSessionState(
|
|
t, env.db, "gemini:"+sessionID,
|
|
func(sess *db.Session) {
|
|
assert.Equal(t, "two", sess.Project)
|
|
},
|
|
)
|
|
|
|
writeProject("three")
|
|
env.engine.SyncPaths([]string{path, projectsPath})
|
|
|
|
assertSessionState(
|
|
t, env.db, "gemini:"+sessionID,
|
|
func(sess *db.Session) {
|
|
assert.Equal(t, "three", sess.Project)
|
|
},
|
|
)
|
|
|
|
writeProject("four")
|
|
env.engine.SyncPaths([]string{projectsPath, path})
|
|
|
|
assertSessionState(
|
|
t, env.db, "gemini:"+sessionID,
|
|
func(sess *db.Session) {
|
|
assert.Equal(t, "four", sess.Project)
|
|
},
|
|
)
|
|
|
|
require.NoError(t, os.Remove(projectsPath), "remove projects")
|
|
env.engine.SyncPaths([]string{projectsPath})
|
|
|
|
assertSessionState(
|
|
t, env.db, "gemini:"+sessionID,
|
|
func(sess *db.Session) {
|
|
assert.Equal(t, "alias", sess.Project)
|
|
},
|
|
)
|
|
}
|
|
|
|
// TestSyncAllGeminiProjectMetadataChangeReparsesProject guards that a scheduled
|
|
// SyncAll is not fooled by the pre-fingerprint fast skip when only Gemini's
|
|
// projects.json changed. The session transcript's own size and mtime are left
|
|
// untouched, so the removed AgentGemini fast skip (which compared just the
|
|
// session stat) would have kept the stale project; the composite fingerprint,
|
|
// which folds in projects.json, must drive a reparse on the periodic full sync.
|
|
func TestSyncAllGeminiProjectMetadataChangeReparsesProject(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
sessionID := "gem-syncall-refresh"
|
|
projectsPath := filepath.Join(env.geminiDir, "projects.json")
|
|
writeProject := func(name string) {
|
|
t.Helper()
|
|
require.NoError(t, os.WriteFile(
|
|
projectsPath,
|
|
fmt.Appendf(nil,
|
|
`{"projects":{"/Users/alice/code/%s":"alias"}}`,
|
|
name,
|
|
),
|
|
0o644,
|
|
), "write projects")
|
|
}
|
|
writeProject("one")
|
|
env.writeGeminiSession(
|
|
t,
|
|
filepath.Join("tmp", "alias", "chats", "session-001.json"),
|
|
testjsonl.GeminiSessionJSON(
|
|
sessionID, "alias", tsEarly, tsEarlyS5,
|
|
[]map[string]any{
|
|
testjsonl.GeminiUserMsg("m1", tsEarly, "Hello Gemini"),
|
|
testjsonl.GeminiAssistantMsg("m2", tsEarlyS5, "Hi there!", nil),
|
|
},
|
|
),
|
|
)
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
assertSessionState(t, env.db, "gemini:"+sessionID, func(sess *db.Session) {
|
|
assert.Equal(t, "one", sess.Project)
|
|
})
|
|
|
|
// Change only the project metadata, then advance its mtime so the composite
|
|
// fingerprint moves forward. The session transcript is left untouched, so a
|
|
// fast skip keyed on the transcript stat alone would keep the stale "one".
|
|
writeProject("two")
|
|
later := time.Now().Add(48 * time.Hour)
|
|
require.NoError(t, os.Chtimes(projectsPath, later, later), "bump projects mtime")
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
assertSessionState(t, env.db, "gemini:"+sessionID, func(sess *db.Session) {
|
|
assert.Equal(t, "two", sess.Project)
|
|
})
|
|
}
|
|
|
|
func TestSyncPathsCodexAcceptsFlatArchived(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
uuid := "d4e5f6a7-4567-8901-bcde-f12345678901"
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddCodexMeta(
|
|
tsEarly, uuid,
|
|
"/home/user/code/api", "user",
|
|
).
|
|
AddCodexMessage(tsEarlyS1, "user", "Add tests").
|
|
String()
|
|
|
|
path := env.writeSession(
|
|
t, env.codexDir,
|
|
"rollout-flat-"+uuid+".jsonl", content,
|
|
)
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
sess, err := env.db.GetSession(
|
|
context.Background(), "codex:"+uuid,
|
|
)
|
|
require.NoError(t, err, "GetSession")
|
|
require.NotNil(t, sess, "expected flat archived Codex session to sync")
|
|
assert.Equal(t, path, env.db.GetSessionFilePath("codex:"+uuid))
|
|
}
|
|
|
|
func TestSyncPathsCodexPrefersLivePathOverArchived(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
uuid := "e5f6a7b8-5678-9012-cdef-234567890123"
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddCodexMeta(
|
|
tsEarly, uuid,
|
|
"/home/user/code/api", "user",
|
|
).
|
|
AddCodexMessage(tsEarlyS1, "user", "Dedupe me").
|
|
String()
|
|
|
|
livePath := env.writeCodexSession(
|
|
t,
|
|
filepath.Join("2026", "05", "04"),
|
|
"rollout-2026-05-04T02-10-04-"+uuid+".jsonl",
|
|
content,
|
|
)
|
|
archivedPath := env.writeSession(
|
|
t, env.codexDir,
|
|
"rollout-2026-05-04T14-31-58-"+uuid+".jsonl",
|
|
content,
|
|
)
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
sess, err := env.db.GetSession(
|
|
context.Background(), "codex:"+uuid,
|
|
)
|
|
require.NoError(t, err, "GetSession")
|
|
require.NotNil(t, sess, "expected Codex session to sync")
|
|
assert.Equal(t, livePath, env.db.GetSessionFilePath("codex:"+uuid))
|
|
assertSessionMessageCount(t, env.db, "codex:"+uuid, 1)
|
|
_ = archivedPath
|
|
}
|
|
|
|
func TestSyncAllSinceCodexKeepsChangedArchivedDuplicate(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
uuid := "f6a7b8c9-6789-0123-def0-345678901234"
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddCodexMeta(
|
|
tsEarly, uuid,
|
|
"/home/user/code/api", "user",
|
|
).
|
|
AddCodexMessage(tsEarlyS1, "user", "Sync changed archive").
|
|
String()
|
|
|
|
livePath := env.writeCodexSession(
|
|
t,
|
|
filepath.Join("2026", "05", "04"),
|
|
"rollout-2026-05-04T02-10-04-"+uuid+".jsonl",
|
|
content,
|
|
)
|
|
archivedPath := env.writeSession(
|
|
t, env.codexDir,
|
|
"rollout-2026-05-04T14-31-58-"+uuid+".jsonl",
|
|
content,
|
|
)
|
|
|
|
oldTime := time.Now().Add(-2 * time.Hour)
|
|
newTime := time.Now().Add(-30 * time.Minute)
|
|
cutoff := time.Now().Add(-1 * time.Hour)
|
|
require.NoError(t, os.Chtimes(livePath, oldTime, oldTime), "chtimes live")
|
|
require.NoError(t, os.Chtimes(archivedPath, newTime, newTime), "chtimes archived")
|
|
|
|
stats := env.engine.SyncAllSince(context.Background(), cutoff, nil)
|
|
require.Equal(t, 1, stats.Synced, "SyncAllSince synced = %d, want 1", stats.Synced)
|
|
|
|
sess, err := env.db.GetSession(
|
|
context.Background(), "codex:"+uuid,
|
|
)
|
|
require.NoError(t, err, "GetSession")
|
|
require.NotNil(t, sess, "expected archived Codex session to sync")
|
|
assert.Equal(t, archivedPath, env.db.GetSessionFilePath("codex:"+uuid))
|
|
}
|
|
|
|
func TestSyncAllSinceCodexRefreshesSessionNameFromIndex(t *testing.T) {
|
|
root := t.TempDir()
|
|
codexDir := filepath.Join(root, "sessions")
|
|
require.NoError(t, os.MkdirAll(codexDir, 0o755))
|
|
env := setupTestEnv(t, WithCodexDirs([]string{codexDir}))
|
|
|
|
uuid := "019eb791-cf7d-75c1-8439-9ed74c1229e1"
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddCodexMeta(
|
|
tsEarly, uuid,
|
|
"/home/user/code/api", "user",
|
|
).
|
|
AddCodexMessage(tsEarlyS1, "user", "Rename me").
|
|
String()
|
|
path := env.writeCodexSession(
|
|
t,
|
|
filepath.Join("2026", "06", "11"),
|
|
"rollout-2026-06-11T12-44-06-"+uuid+".jsonl",
|
|
content,
|
|
)
|
|
|
|
indexPath := filepath.Join(root, "session_index.jsonl")
|
|
require.NoError(t, os.WriteFile(indexPath, fmt.Appendf(nil,
|
|
`{"id":"%s","thread_name":"Original title","updated_at":"2026-06-11T17:34:20Z"}`+"\n",
|
|
uuid,
|
|
), 0o644))
|
|
initialTime := time.Now().Add(-2 * time.Hour)
|
|
require.NoError(t, os.Chtimes(path, initialTime, initialTime), "chtimes initial session")
|
|
require.NoError(t, os.Chtimes(indexPath, initialTime, initialTime), "chtimes initial index")
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
sess, err := env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err, "GetSessionFull")
|
|
require.NotNil(t, sess, "expected Codex session to sync")
|
|
if assert.NotNil(t, sess.SessionName, "expected session_name to be imported") {
|
|
assert.Equal(t, "Original title", *sess.SessionName)
|
|
}
|
|
|
|
cutoff := time.Now().Add(-1 * time.Hour)
|
|
newIndexTime := time.Now().Add(-30 * time.Minute)
|
|
require.NoError(t, os.WriteFile(indexPath, fmt.Appendf(nil,
|
|
`{"id":"%s","thread_name":"Renamed title","updated_at":"2026-06-11T18:00:00Z"}`+"\n",
|
|
uuid,
|
|
), 0o644))
|
|
require.NoError(t, os.Chtimes(indexPath, newIndexTime, newIndexTime), "chtimes index")
|
|
|
|
stats := env.engine.SyncAllSince(context.Background(), cutoff, nil)
|
|
require.Equal(t, 1, stats.Synced, "SyncAllSince synced = %d, want 1", stats.Synced)
|
|
|
|
sess, err = env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err, "GetSessionFull after rename")
|
|
require.NotNil(t, sess, "expected renamed Codex session to remain")
|
|
if assert.NotNil(t, sess.SessionName, "expected renamed session_name") {
|
|
assert.Equal(t, "Renamed title", *sess.SessionName)
|
|
}
|
|
}
|
|
|
|
// TestSyncAllSinceCodexIndexRenameBelowStoredMtimeRefreshesName covers the
|
|
// quick-sync sibling of the incremental masking race: a title-only index rename
|
|
// whose mtime lands at/after the cutoff but at or below the stored (index-folded)
|
|
// file_mtime must still refresh the session_name. codexIndexNeedsRefreshSince
|
|
// must compare the title directly instead of gating on indexMtime > storedMtime.
|
|
func TestSyncAllSinceCodexIndexRenameBelowStoredMtimeRefreshesName(t *testing.T) {
|
|
root := t.TempDir()
|
|
codexDir := filepath.Join(root, "sessions")
|
|
require.NoError(t, os.MkdirAll(codexDir, 0o755))
|
|
env := setupTestEnv(t, WithCodexDirs([]string{codexDir}))
|
|
|
|
uuid := "019eb791-cf7d-75c1-8439-9ed74c1229e1"
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddCodexMeta(tsEarly, uuid, "/home/user/code/api", "user").
|
|
AddCodexMessage(tsEarlyS1, "user", "Rename me").
|
|
String()
|
|
path := env.writeCodexSession(
|
|
t,
|
|
filepath.Join("2026", "06", "11"),
|
|
"rollout-2026-06-11T12-44-06-"+uuid+".jsonl",
|
|
content,
|
|
)
|
|
|
|
indexPath := filepath.Join(root, "session_index.jsonl")
|
|
require.NoError(t, os.WriteFile(indexPath, fmt.Appendf(nil,
|
|
`{"id":"%s","thread_name":"Original title","updated_at":"2026-06-11T17:34:20Z"}`+"\n",
|
|
uuid,
|
|
), 0o644))
|
|
|
|
// Transcript is well before the cutoff; the index is newer, so the initial
|
|
// parse stores the high index mtime as the effective file_mtime.
|
|
transcriptTime := time.Now().Add(-3 * time.Hour)
|
|
highIndexTime := time.Now().Add(-30 * time.Minute)
|
|
require.NoError(t, os.Chtimes(path, transcriptTime, transcriptTime), "chtimes initial session")
|
|
require.NoError(t, os.Chtimes(indexPath, highIndexTime, highIndexTime), "chtimes initial index")
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
sess, err := env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err, "GetSessionFull")
|
|
require.NotNil(t, sess, "expected Codex session to sync")
|
|
require.NotNil(t, sess.SessionName, "expected session_name to be imported")
|
|
require.Equal(t, "Original title", *sess.SessionName)
|
|
require.NotNil(t, sess.FileMtime, "expected stored file_mtime")
|
|
require.Equal(t, highIndexTime.UnixNano(), *sess.FileMtime,
|
|
"expected stored file_mtime to fold in the higher index mtime")
|
|
|
|
// Rename the index with an mtime that is after the cutoff but BELOW the
|
|
// stored file_mtime. The old indexMtime > storedMtime gate would filter this
|
|
// out of the quick sync, stranding the stale title.
|
|
cutoff := time.Now().Add(-1 * time.Hour)
|
|
lowIndexTime := time.Now().Add(-45 * time.Minute)
|
|
require.NoError(t, os.WriteFile(indexPath, fmt.Appendf(nil,
|
|
`{"id":"%s","thread_name":"Renamed title","updated_at":"2026-06-11T18:00:00Z"}`+"\n",
|
|
uuid,
|
|
), 0o644))
|
|
require.NoError(t, os.Chtimes(indexPath, lowIndexTime, lowIndexTime), "chtimes renamed index")
|
|
|
|
stats := env.engine.SyncAllSince(context.Background(), cutoff, nil)
|
|
require.Equal(t, 1, stats.Synced, "SyncAllSince synced = %d, want 1", stats.Synced)
|
|
|
|
sess, err = env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err, "GetSessionFull after rename")
|
|
require.NotNil(t, sess, "expected renamed Codex session to remain")
|
|
if assert.NotNil(t, sess.SessionName, "expected renamed session_name") {
|
|
assert.Equal(t, "Renamed title", *sess.SessionName)
|
|
}
|
|
}
|
|
|
|
func TestSyncAllWarmGateCodexIndexSameStatRenameRefreshesName(t *testing.T) {
|
|
root := t.TempDir()
|
|
codexDir := filepath.Join(root, "sessions")
|
|
require.NoError(t, os.MkdirAll(codexDir, 0o755))
|
|
env := setupTestEnv(t, WithCodexDirs([]string{codexDir}))
|
|
|
|
uuid := "019eb791-cf7d-75c1-8439-9ed74c1229e1"
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddCodexMeta(tsEarly, uuid, "/tmp/project", "user").
|
|
AddCodexMessage(tsEarlyS1, "user", "Rename me").
|
|
String()
|
|
path := env.writeCodexSession(
|
|
t,
|
|
filepath.Join("2026", "06", "11"),
|
|
"rollout-2026-06-11T12-44-06-"+uuid+".jsonl",
|
|
content,
|
|
)
|
|
|
|
indexPath := filepath.Join(root, parser.CodexSessionIndexFilename)
|
|
indexLine := func(title string) []byte {
|
|
return fmt.Appendf(nil,
|
|
`{"id":"%s","thread_name":"%s","updated_at":"2026-06-11T17:34:20Z"}`+"\n",
|
|
uuid, title,
|
|
)
|
|
}
|
|
originalIndex := indexLine("Title Alpha")
|
|
renamedIndex := indexLine("Title Bravo")
|
|
require.Len(t, renamedIndex, len(originalIndex),
|
|
"fixture must preserve the index size")
|
|
require.NoError(t, os.WriteFile(indexPath, originalIndex, 0o644))
|
|
|
|
transcriptTime := time.Now().Add(-30 * time.Minute)
|
|
indexTime := time.Now().Add(-2 * time.Hour)
|
|
require.NoError(t, os.Chtimes(path, transcriptTime, transcriptTime))
|
|
require.NoError(t, os.Chtimes(indexPath, indexTime, indexTime))
|
|
|
|
first := env.engine.SyncAll(context.Background(), nil)
|
|
require.Equal(t, 1, first.Synced)
|
|
second := env.engine.SyncAll(context.Background(), nil)
|
|
require.Equal(t, 0, second.Synced, "second pass must warm source trust")
|
|
|
|
sess, err := env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
require.NotNil(t, sess.SessionName)
|
|
require.Equal(t, "Title Alpha", *sess.SessionName)
|
|
require.NotNil(t, sess.FileMtime)
|
|
require.Equal(t, transcriptTime.UnixNano(), *sess.FileMtime,
|
|
"transcript mtime must remain the stored watermark")
|
|
|
|
// Simulate a missed watcher event: rewrite only the sidecar while restoring
|
|
// its lower mtime and preserving its size. The full sync must still reload
|
|
// the index and repair the persisted title.
|
|
require.NoError(t, os.WriteFile(indexPath, renamedIndex, 0o644))
|
|
require.NoError(t, os.Chtimes(indexPath, indexTime, indexTime))
|
|
|
|
third := env.engine.SyncAll(context.Background(), nil)
|
|
require.Equal(t, 1, third.Synced,
|
|
"same-stat title rewrite must bypass warm source trust")
|
|
sess, err = env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
require.NotNil(t, sess.SessionName)
|
|
assert.Equal(t, "Title Bravo", *sess.SessionName)
|
|
}
|
|
|
|
func TestSyncAllSinceCodexIndexRefreshOnlySyncsRenamedSession(t *testing.T) {
|
|
root := t.TempDir()
|
|
codexDir := filepath.Join(root, "sessions")
|
|
require.NoError(t, os.MkdirAll(codexDir, 0o755))
|
|
env := setupTestEnv(t, WithCodexDirs([]string{codexDir}))
|
|
|
|
renamedUUID := "019eb791-cf7d-75c1-8439-9ed74c1229e1"
|
|
unchangedUUID := "019eb791-cf7d-75c1-8439-9ed74c1229e2"
|
|
renamedContent := testjsonl.NewSessionBuilder().
|
|
AddCodexMeta(
|
|
tsEarly, renamedUUID,
|
|
"/home/user/code/api", "user",
|
|
).
|
|
AddCodexMessage(tsEarlyS1, "user", "Rename me").
|
|
String()
|
|
unchangedContent := testjsonl.NewSessionBuilder().
|
|
AddCodexMeta(
|
|
tsEarly, unchangedUUID,
|
|
"/home/user/code/api", "user",
|
|
).
|
|
AddCodexMessage(tsEarlyS1, "user", "Leave me").
|
|
String()
|
|
renamedPath := env.writeCodexSession(
|
|
t,
|
|
filepath.Join("2026", "06", "11"),
|
|
"rollout-2026-06-11T12-44-06-"+renamedUUID+".jsonl",
|
|
renamedContent,
|
|
)
|
|
unchangedPath := env.writeCodexSession(
|
|
t,
|
|
filepath.Join("2026", "06", "11"),
|
|
"rollout-2026-06-11T12-45-06-"+unchangedUUID+".jsonl",
|
|
unchangedContent,
|
|
)
|
|
|
|
indexPath := filepath.Join(root, "session_index.jsonl")
|
|
require.NoError(t, os.WriteFile(indexPath, fmt.Appendf(nil,
|
|
`{"id":"%s","thread_name":"Original renamed title","updated_at":"2026-06-11T17:34:20Z"}`+"\n"+
|
|
`{"id":"%s","thread_name":"Unchanged title","updated_at":"2026-06-11T17:35:20Z"}`+"\n",
|
|
renamedUUID, unchangedUUID,
|
|
), 0o644))
|
|
initialTime := time.Now().Add(-2 * time.Hour)
|
|
require.NoError(t, os.Chtimes(renamedPath, initialTime, initialTime), "chtimes initial renamed")
|
|
require.NoError(t, os.Chtimes(unchangedPath, initialTime, initialTime), "chtimes initial unchanged")
|
|
require.NoError(t, os.Chtimes(indexPath, initialTime, initialTime), "chtimes initial index")
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
unchangedBefore, err := env.db.GetSessionFull(context.Background(), "codex:"+unchangedUUID)
|
|
require.NoError(t, err, "GetSessionFull unchanged before rename")
|
|
require.NotNil(t, unchangedBefore, "expected unchanged Codex session to sync")
|
|
require.NotNil(t, unchangedBefore.SessionName, "expected unchanged session_name")
|
|
assert.Equal(t, "Unchanged title", *unchangedBefore.SessionName)
|
|
require.NotNil(t, unchangedBefore.FileMtime, "expected unchanged file_mtime")
|
|
|
|
cutoff := time.Now().Add(-1 * time.Hour)
|
|
newIndexTime := time.Now().Add(-30 * time.Minute)
|
|
require.NoError(t, os.WriteFile(indexPath, fmt.Appendf(nil,
|
|
`{"id":"%s","thread_name":"Renamed title","updated_at":"2026-06-11T18:00:00Z"}`+"\n"+
|
|
`{"id":"%s","thread_name":"Unchanged title","updated_at":"2026-06-11T17:35:20Z"}`+"\n",
|
|
renamedUUID, unchangedUUID,
|
|
), 0o644))
|
|
require.NoError(t, os.Chtimes(indexPath, newIndexTime, newIndexTime), "chtimes index")
|
|
|
|
stats := env.engine.SyncAllSince(context.Background(), cutoff, nil)
|
|
require.Equal(t, 1, stats.Synced, "SyncAllSince synced = %d, want 1", stats.Synced)
|
|
|
|
renamed, err := env.db.GetSessionFull(context.Background(), "codex:"+renamedUUID)
|
|
require.NoError(t, err, "GetSessionFull renamed after index update")
|
|
require.NotNil(t, renamed, "expected renamed Codex session to remain")
|
|
if assert.NotNil(t, renamed.SessionName, "expected renamed session_name") {
|
|
assert.Equal(t, "Renamed title", *renamed.SessionName)
|
|
}
|
|
|
|
unchangedAfter, err := env.db.GetSessionFull(context.Background(), "codex:"+unchangedUUID)
|
|
require.NoError(t, err, "GetSessionFull unchanged after index update")
|
|
require.NotNil(t, unchangedAfter, "expected unchanged Codex session to remain")
|
|
if assert.NotNil(t, unchangedAfter.SessionName, "expected unchanged session_name") {
|
|
assert.Equal(t, "Unchanged title", *unchangedAfter.SessionName)
|
|
}
|
|
assert.Equal(t, *unchangedBefore.FileMtime, *unchangedAfter.FileMtime,
|
|
"unchanged session should not be reparsed just because the global index mtime advanced")
|
|
|
|
unchangedIndexTime := time.Now().Add(-15 * time.Minute)
|
|
require.NoError(t, os.WriteFile(indexPath, fmt.Appendf(nil,
|
|
`{"id":"%s","thread_name":"Renamed title","updated_at":"2026-06-11T18:00:00Z"}`+"\n"+
|
|
`{"id":"%s","thread_name":"Unchanged title","updated_at":"2026-06-11T17:35:20Z"}`+"\n",
|
|
renamedUUID, unchangedUUID,
|
|
), 0o644))
|
|
require.NoError(t, os.Chtimes(indexPath, unchangedIndexTime, unchangedIndexTime), "chtimes unchanged index")
|
|
|
|
stats = env.engine.SyncAll(context.Background(), nil)
|
|
require.Equal(t, 0, stats.Synced,
|
|
"SyncAll synced = %d, want 0 when only the global index mtime changed", stats.Synced)
|
|
}
|
|
|
|
func TestSyncPathsCodexIndexEventRefreshesRenamedSession(t *testing.T) {
|
|
root := t.TempDir()
|
|
codexDir := filepath.Join(root, "sessions")
|
|
require.NoError(t, os.MkdirAll(codexDir, 0o755))
|
|
env := setupTestEnv(t, WithCodexDirs([]string{codexDir}))
|
|
|
|
renamedUUID := "019eb791-cf7d-75c1-8439-9ed74c1229e1"
|
|
unchangedUUID := "019eb791-cf7d-75c1-8439-9ed74c1229e2"
|
|
renamedPath := env.writeCodexSession(
|
|
t,
|
|
filepath.Join("2026", "06", "11"),
|
|
"rollout-2026-06-11T12-44-06-"+renamedUUID+".jsonl",
|
|
testjsonl.NewSessionBuilder().
|
|
AddCodexMeta(tsEarly, renamedUUID, "/home/user/code/api", "user").
|
|
AddCodexMessage(tsEarlyS1, "user", "Rename me").
|
|
String(),
|
|
)
|
|
unchangedPath := env.writeCodexSession(
|
|
t,
|
|
filepath.Join("2026", "06", "11"),
|
|
"rollout-2026-06-11T12-45-06-"+unchangedUUID+".jsonl",
|
|
testjsonl.NewSessionBuilder().
|
|
AddCodexMeta(tsEarly, unchangedUUID, "/home/user/code/api", "user").
|
|
AddCodexMessage(tsEarlyS1, "user", "Leave me").
|
|
String(),
|
|
)
|
|
|
|
indexPath := filepath.Join(root, "session_index.jsonl")
|
|
require.NoError(t, os.WriteFile(indexPath, fmt.Appendf(nil,
|
|
`{"id":"%s","thread_name":"Original title","updated_at":"2026-06-11T17:34:20Z"}`+"\n"+
|
|
`{"id":"%s","thread_name":"Unchanged title","updated_at":"2026-06-11T17:35:20Z"}`+"\n",
|
|
renamedUUID, unchangedUUID,
|
|
), 0o644))
|
|
initialTime := time.Now().Add(-2 * time.Hour)
|
|
require.NoError(t, os.Chtimes(renamedPath, initialTime, initialTime), "chtimes renamed")
|
|
require.NoError(t, os.Chtimes(unchangedPath, initialTime, initialTime), "chtimes unchanged")
|
|
require.NoError(t, os.Chtimes(indexPath, initialTime, initialTime), "chtimes index")
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
unchangedBefore, err := env.db.GetSessionFull(context.Background(), "codex:"+unchangedUUID)
|
|
require.NoError(t, err, "GetSessionFull unchanged before rename")
|
|
require.NotNil(t, unchangedBefore, "expected unchanged Codex session to sync")
|
|
require.NotNil(t, unchangedBefore.FileMtime, "expected unchanged file_mtime")
|
|
|
|
newIndexTime := time.Now().Add(-30 * time.Minute)
|
|
require.NoError(t, os.WriteFile(indexPath, fmt.Appendf(nil,
|
|
`{"id":"%s","thread_name":"Renamed title","updated_at":"2026-06-11T18:00:00Z"}`+"\n"+
|
|
`{"id":"%s","thread_name":"Unchanged title","updated_at":"2026-06-11T17:35:20Z"}`+"\n",
|
|
renamedUUID, unchangedUUID,
|
|
), 0o644))
|
|
require.NoError(t, os.Chtimes(indexPath, newIndexTime, newIndexTime), "chtimes index")
|
|
|
|
// The live watcher only delivers the index path; SyncPaths must translate
|
|
// it into a refresh of the renamed session.
|
|
env.engine.SyncPaths([]string{indexPath})
|
|
|
|
renamed, err := env.db.GetSessionFull(context.Background(), "codex:"+renamedUUID)
|
|
require.NoError(t, err, "GetSessionFull renamed after index event")
|
|
require.NotNil(t, renamed, "expected renamed Codex session to remain")
|
|
if assert.NotNil(t, renamed.SessionName, "expected renamed session_name") {
|
|
assert.Equal(t, "Renamed title", *renamed.SessionName)
|
|
}
|
|
|
|
unchangedAfter, err := env.db.GetSessionFull(context.Background(), "codex:"+unchangedUUID)
|
|
require.NoError(t, err, "GetSessionFull unchanged after index event")
|
|
require.NotNil(t, unchangedAfter, "expected unchanged Codex session to remain")
|
|
require.NotNil(t, unchangedAfter.FileMtime, "expected unchanged file_mtime after event")
|
|
assert.Equal(t, *unchangedBefore.FileMtime, *unchangedAfter.FileMtime,
|
|
"unchanged session must not be reparsed from an index-only event")
|
|
}
|
|
|
|
func TestSyncAllSinceCodexIndexRefreshDoesNotShadowChangedArchivedDuplicate(t *testing.T) {
|
|
root := t.TempDir()
|
|
codexDir := filepath.Join(root, "sessions")
|
|
archivedDir := filepath.Join(root, "archived_sessions")
|
|
require.NoError(t, os.MkdirAll(codexDir, 0o755))
|
|
require.NoError(t, os.MkdirAll(archivedDir, 0o755))
|
|
env := setupTestEnv(t, WithCodexDirs([]string{codexDir, archivedDir}))
|
|
|
|
uuid := "f7a8b9ca-7890-1234-ef01-456789012345"
|
|
liveContent := testjsonl.NewSessionBuilder().
|
|
AddCodexMeta(
|
|
tsEarly, uuid,
|
|
"/home/user/code/api", "user",
|
|
).
|
|
AddCodexMessage(tsEarlyS1, "user", "Live copy").
|
|
String()
|
|
archivedContent := testjsonl.NewSessionBuilder().
|
|
AddCodexMeta(
|
|
tsEarly, uuid,
|
|
"/home/user/code/api", "user",
|
|
).
|
|
AddCodexMessage(tsEarlyS1, "user", "Archived copy").
|
|
AddCodexMessage(tsEarlyS5, "assistant", "Updated archive").
|
|
String()
|
|
|
|
livePath := env.writeCodexSession(
|
|
t,
|
|
filepath.Join("2026", "05", "04"),
|
|
"rollout-2026-05-04T02-10-04-"+uuid+".jsonl",
|
|
liveContent,
|
|
)
|
|
archivedPath := env.writeSession(
|
|
t,
|
|
archivedDir,
|
|
"rollout-2026-05-04T14-31-58-"+uuid+".jsonl",
|
|
liveContent,
|
|
)
|
|
|
|
indexPath := filepath.Join(root, "session_index.jsonl")
|
|
require.NoError(t, os.WriteFile(indexPath, fmt.Appendf(nil,
|
|
`{"id":"%s","thread_name":"Original title","updated_at":"2026-05-04T15:00:00Z"}`+"\n",
|
|
uuid,
|
|
), 0o644))
|
|
initialTime := time.Now().Add(-2 * time.Hour)
|
|
require.NoError(t, os.Chtimes(livePath, initialTime, initialTime), "chtimes initial live")
|
|
require.NoError(t, os.Chtimes(archivedPath, initialTime, initialTime), "chtimes initial archived")
|
|
require.NoError(t, os.Chtimes(indexPath, initialTime, initialTime), "chtimes initial index")
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
assert.Equal(t, livePath, env.db.GetSessionFilePath("codex:"+uuid))
|
|
|
|
newTime := time.Now().Add(-30 * time.Minute)
|
|
cutoff := time.Now().Add(-1 * time.Hour)
|
|
require.NoError(t, os.WriteFile(archivedPath, []byte(archivedContent), 0o644))
|
|
require.NoError(t, os.Chtimes(archivedPath, newTime, newTime), "chtimes archived")
|
|
require.NoError(t, os.WriteFile(indexPath, fmt.Appendf(nil,
|
|
`{"id":"%s","thread_name":"Renamed title","updated_at":"2026-05-04T16:00:00Z"}`+"\n",
|
|
uuid,
|
|
), 0o644))
|
|
require.NoError(t, os.Chtimes(indexPath, newTime, newTime), "chtimes index")
|
|
|
|
stats := env.engine.SyncAllSince(context.Background(), cutoff, nil)
|
|
require.Equal(t, 1, stats.Synced, "SyncAllSince synced = %d, want 1", stats.Synced)
|
|
|
|
sess, err := env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err, "GetSessionFull")
|
|
require.NotNil(t, sess, "expected Codex session to sync")
|
|
assert.Equal(t, archivedPath, env.db.GetSessionFilePath("codex:"+uuid))
|
|
if assert.NotNil(t, sess.SessionName, "expected renamed session_name") {
|
|
assert.Equal(t, "Renamed title", *sess.SessionName)
|
|
}
|
|
assertSessionMessageCount(t, env.db, "codex:"+uuid, 2)
|
|
}
|
|
|
|
func TestSyncPathsCodexIndexEventRefreshesStoredDuplicate(t *testing.T) {
|
|
root := t.TempDir()
|
|
codexDir := filepath.Join(root, "sessions")
|
|
archivedDir := filepath.Join(root, "archived_sessions")
|
|
require.NoError(t, os.MkdirAll(codexDir, 0o755))
|
|
require.NoError(t, os.MkdirAll(archivedDir, 0o755))
|
|
env := setupTestEnv(t, WithCodexDirs([]string{codexDir, archivedDir}))
|
|
|
|
uuid := "f7a8b9ca-7890-1234-ef01-456789012345"
|
|
archivedContent := testjsonl.NewSessionBuilder().
|
|
AddCodexMeta(tsEarly, uuid, "/home/user/code/api", "user").
|
|
AddCodexMessage(tsEarlyS1, "user", "Archived copy").
|
|
AddCodexMessage(tsEarlyS5, "assistant", "Archived reply").
|
|
String()
|
|
staleLiveContent := testjsonl.NewSessionBuilder().
|
|
AddCodexMeta(tsEarly, uuid, "/home/user/code/api", "user").
|
|
AddCodexMessage(tsEarlyS1, "user", "Stale live copy").
|
|
String()
|
|
|
|
// Only the archived copy exists at first sync, so the DB tracks it.
|
|
archivedPath := env.writeSession(
|
|
t, archivedDir,
|
|
"rollout-2026-05-04T14-31-58-"+uuid+".jsonl",
|
|
archivedContent,
|
|
)
|
|
indexPath := filepath.Join(root, "session_index.jsonl")
|
|
require.NoError(t, os.WriteFile(indexPath, fmt.Appendf(nil,
|
|
`{"id":"%s","thread_name":"Original title","updated_at":"2026-05-04T15:00:00Z"}`+"\n",
|
|
uuid,
|
|
), 0o644))
|
|
initialTime := time.Now().Add(-2 * time.Hour)
|
|
require.NoError(t, os.Chtimes(archivedPath, initialTime, initialTime), "chtimes archived")
|
|
require.NoError(t, os.Chtimes(indexPath, initialTime, initialTime), "chtimes index")
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
require.Equal(t, archivedPath, env.db.GetSessionFilePath("codex:"+uuid),
|
|
"DB must track the archived copy before the rename")
|
|
|
|
// A stale live duplicate now exists, and the index records a rename.
|
|
livePath := env.writeCodexSession(
|
|
t,
|
|
filepath.Join("2026", "05", "04"),
|
|
"rollout-2026-05-04T02-10-04-"+uuid+".jsonl",
|
|
staleLiveContent,
|
|
)
|
|
require.NoError(t, os.Chtimes(livePath, initialTime, initialTime), "chtimes live")
|
|
newIndexTime := time.Now().Add(-30 * time.Minute)
|
|
require.NoError(t, os.WriteFile(indexPath, fmt.Appendf(nil,
|
|
`{"id":"%s","thread_name":"Renamed title","updated_at":"2026-05-04T16:00:00Z"}`+"\n",
|
|
uuid,
|
|
), 0o644))
|
|
require.NoError(t, os.Chtimes(indexPath, newIndexTime, newIndexTime), "chtimes index rename")
|
|
|
|
env.engine.SyncPaths([]string{indexPath})
|
|
|
|
sess, err := env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err, "GetSessionFull after index event")
|
|
require.NotNil(t, sess, "expected Codex session to remain")
|
|
assert.Equal(t, archivedPath, env.db.GetSessionFilePath("codex:"+uuid),
|
|
"index rename must refresh the stored archived copy, not the stale live duplicate")
|
|
if assert.NotNil(t, sess.SessionName, "expected renamed session_name") {
|
|
assert.Equal(t, "Renamed title", *sess.SessionName)
|
|
}
|
|
assertSessionMessageCount(t, env.db, "codex:"+uuid, 2)
|
|
}
|
|
|
|
func TestSyncPathsCodexArchivedDuplicateEventPinsChangedFile(t *testing.T) {
|
|
root := t.TempDir()
|
|
codexDir := filepath.Join(root, "sessions")
|
|
archivedDir := filepath.Join(root, "archived_sessions")
|
|
require.NoError(t, os.MkdirAll(codexDir, 0o755))
|
|
require.NoError(t, os.MkdirAll(archivedDir, 0o755))
|
|
env := setupTestEnv(t, WithCodexDirs([]string{codexDir, archivedDir}))
|
|
|
|
uuid := "f7a8b9ca-7890-1234-ef01-456789012346"
|
|
staleLiveContent := testjsonl.NewSessionBuilder().
|
|
AddCodexMeta(tsEarly, uuid, "/home/user/code/api", "user").
|
|
AddCodexMessage(tsEarlyS1, "user", "Stale live copy").
|
|
String()
|
|
archivedContent := testjsonl.NewSessionBuilder().
|
|
AddCodexMeta(tsEarly, uuid, "/home/user/code/api", "user").
|
|
AddCodexMessage(tsEarlyS1, "user", "Archived copy").
|
|
String()
|
|
updatedArchivedContent := testjsonl.NewSessionBuilder().
|
|
AddCodexMeta(tsEarly, uuid, "/home/user/code/api", "user").
|
|
AddCodexMessage(tsEarlyS1, "user", "Archived copy").
|
|
AddCodexMessage(tsEarlyS5, "assistant", "Updated archived reply").
|
|
String()
|
|
|
|
livePath := env.writeCodexSession(
|
|
t,
|
|
filepath.Join("2026", "05", "04"),
|
|
"rollout-2026-05-04T02-10-04-"+uuid+".jsonl",
|
|
staleLiveContent,
|
|
)
|
|
archivedPath := env.writeSession(
|
|
t, archivedDir,
|
|
"rollout-2026-05-04T14-31-58-"+uuid+".jsonl",
|
|
archivedContent,
|
|
)
|
|
initialTime := time.Now().Add(-2 * time.Hour)
|
|
require.NoError(t, os.Chtimes(livePath, initialTime, initialTime), "chtimes live")
|
|
require.NoError(t, os.Chtimes(archivedPath, initialTime, initialTime), "chtimes archived")
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
assert.Equal(t, livePath, env.db.GetSessionFilePath("codex:"+uuid))
|
|
|
|
newTime := time.Now().Add(-30 * time.Minute)
|
|
require.NoError(t, os.WriteFile(archivedPath, []byte(updatedArchivedContent), 0o644))
|
|
require.NoError(t, os.Chtimes(archivedPath, newTime, newTime), "chtimes archived update")
|
|
|
|
env.engine.SyncPaths([]string{archivedPath})
|
|
|
|
assert.Equal(t, archivedPath, env.db.GetSessionFilePath("codex:"+uuid),
|
|
"archived transcript event must parse the changed file, not the stale live duplicate")
|
|
assertSessionMessageCount(t, env.db, "codex:"+uuid, 2)
|
|
}
|
|
|
|
func TestSyncSingleSessionCodexPreservesStoredArchivedDuplicate(t *testing.T) {
|
|
root := t.TempDir()
|
|
codexDir := filepath.Join(root, "sessions")
|
|
archivedDir := filepath.Join(root, "archived_sessions")
|
|
require.NoError(t, os.MkdirAll(codexDir, 0o755))
|
|
require.NoError(t, os.MkdirAll(archivedDir, 0o755))
|
|
env := setupSingleAgentTestEnvWithDirs(
|
|
t, parser.AgentCodex, []string{codexDir, archivedDir},
|
|
)
|
|
|
|
uuid := "f7a8b9ca-7890-1234-ef01-456789012347"
|
|
archivedContent := testjsonl.NewSessionBuilder().
|
|
AddCodexMeta(tsEarly, uuid, "/home/user/code/api", "user").
|
|
AddCodexMessage(tsEarlyS1, "user", "Archived copy").
|
|
AddCodexMessage(tsEarlyS5, "assistant", "Archived reply").
|
|
String()
|
|
staleLiveContent := testjsonl.NewSessionBuilder().
|
|
AddCodexMeta(tsEarly, uuid, "/home/user/code/api", "user").
|
|
AddCodexMessage(tsEarlyS1, "user", "Stale live copy").
|
|
String()
|
|
|
|
archivedPath := env.writeSession(
|
|
t, archivedDir,
|
|
"rollout-2026-05-04T14-31-58-"+uuid+".jsonl",
|
|
archivedContent,
|
|
)
|
|
initialTime := time.Now().Add(-2 * time.Hour)
|
|
require.NoError(t, os.Chtimes(archivedPath, initialTime, initialTime), "chtimes archived")
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
require.Equal(t, archivedPath, env.db.GetSessionFilePath("codex:"+uuid),
|
|
"DB must track the archived copy before a stale live duplicate appears")
|
|
|
|
livePath := env.writeCodexSession(
|
|
t,
|
|
filepath.Join("2026", "05", "04"),
|
|
"rollout-2026-05-04T02-10-04-"+uuid+".jsonl",
|
|
staleLiveContent,
|
|
)
|
|
require.NoError(t, os.Chtimes(livePath, initialTime, initialTime), "chtimes live")
|
|
|
|
require.NoError(t, env.engine.SyncSingleSession("codex:"+uuid))
|
|
|
|
assert.Equal(t, archivedPath, env.db.GetSessionFilePath("codex:"+uuid),
|
|
"single-session resync must preserve the stored archived source")
|
|
assertSessionMessageCount(t, env.db, "codex:"+uuid, 2)
|
|
}
|
|
|
|
func TestSyncPathsGeminiRejectsWrongStructure(t *testing.T) {
|
|
|
|
env := setupTestEnv(t)
|
|
|
|
sessionID := "gem-wrong-struct"
|
|
content := testjsonl.GeminiSessionJSON(
|
|
sessionID, "somehash", tsEarly, tsEarlyS5,
|
|
[]map[string]any{
|
|
testjsonl.GeminiUserMsg(
|
|
"m1", tsEarly, "Hello",
|
|
),
|
|
},
|
|
)
|
|
|
|
// Write session-*.json directly under geminiDir (wrong)
|
|
path1 := env.writeGeminiSession(
|
|
t, "session-wrong.json", content,
|
|
)
|
|
// Write under tmp/<hash> but without /chats/ dir
|
|
path2 := env.writeGeminiSession(
|
|
t,
|
|
filepath.Join("tmp", "abc123", "session-bad.json"),
|
|
content,
|
|
)
|
|
|
|
env.engine.SyncPaths([]string{path1, path2})
|
|
|
|
sess, _ := env.db.GetSession(
|
|
context.Background(), "gemini:"+sessionID,
|
|
)
|
|
assert.Nil(t, sess, "Gemini file outside tmp/<hash>/chats "+"should be ignored")
|
|
}
|
|
|
|
func TestSyncPathsAmp(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
content := `{"id":"T-019ca26f-aaaa-bbbb-cccc-dddddddddddd","created":1704103200000,"title":"Amp session","env":{"initial":{"trees":[{"displayName":"amp_proj"}]}},"messages":[{"role":"user","content":[{"type":"text","text":"hello from amp"}]},{"role":"assistant","content":[{"type":"text","text":"hi"}]}]}`
|
|
|
|
path := env.writeAmpThread(
|
|
t, "T-019ca26f-aaaa-bbbb-cccc-dddddddddddd.json",
|
|
content,
|
|
)
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
assertSessionState(
|
|
t, env.db,
|
|
"amp:T-019ca26f-aaaa-bbbb-cccc-dddddddddddd",
|
|
func(sess *db.Session) {
|
|
assert.Equal(t, "amp", sess.Agent, "agent = %q, want amp", sess.Agent)
|
|
},
|
|
)
|
|
assertSessionMessageCount(
|
|
t, env.db,
|
|
"amp:T-019ca26f-aaaa-bbbb-cccc-dddddddddddd", 2,
|
|
)
|
|
|
|
updated := `{"id":"T-019ca26f-aaaa-bbbb-cccc-dddddddddddd","created":1704103200000,"title":"Amp session","env":{"initial":{"trees":[{"displayName":"amp_proj"}]}},"messages":[{"role":"user","content":[{"type":"text","text":"hello from amp"}]},{"role":"assistant","content":[{"type":"text","text":"hi"}]},{"role":"assistant","content":[{"type":"text","text":"incremental update"}]}]}`
|
|
os.WriteFile(path, []byte(updated), 0o644)
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
assertSessionMessageCount(
|
|
t, env.db,
|
|
"amp:T-019ca26f-aaaa-bbbb-cccc-dddddddddddd", 3,
|
|
)
|
|
}
|
|
|
|
func TestSyncPathsAmpRejectsWrongStructure(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
content := `{"id":"T-019ca26f-aaaa-bbbb-cccc-dddddddddddd","created":1704103200000,"title":"Amp session","messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`
|
|
|
|
// Nested paths under ampDir should be ignored.
|
|
nested := env.writeAmpThread(
|
|
t, filepath.Join("nested", "T-019ca26f-aaaa-bbbb-cccc-dddddddddddd.json"),
|
|
content,
|
|
)
|
|
// Non-thread filename pattern at ampDir root should be ignored.
|
|
wrongName := env.writeAmpThread(
|
|
t, "thread-019ca26f-aaaa-bbbb-cccc-dddddddddddd.json",
|
|
content,
|
|
)
|
|
// Malformed thread ID should be ignored.
|
|
malformed := env.writeAmpThread(
|
|
t, "T-.json",
|
|
content,
|
|
)
|
|
|
|
env.engine.SyncPaths([]string{nested, wrongName, malformed})
|
|
|
|
sess, _ := env.db.GetSession(
|
|
context.Background(), "amp:T-019ca26f-aaaa-bbbb-cccc-dddddddddddd",
|
|
)
|
|
assert.Nil(t, sess, "Amp files outside root-level valid T-<id>.json should be ignored")
|
|
}
|
|
|
|
func TestSyncPathsStatsUpdated(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "Hello").
|
|
String()
|
|
|
|
path := env.writeClaudeSession(
|
|
t, "proj", "stats-test.jsonl", content,
|
|
)
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
stats := env.engine.LastSyncStats()
|
|
assert.Equal(t, 1, stats.Synced, "LastSyncStats.Synced = %d, want 1", stats.Synced)
|
|
lastSync := env.engine.LastSync()
|
|
assert.False(t, lastSync.IsZero(), "LastSync should be set after SyncPaths")
|
|
}
|
|
|
|
func TestSyncPathsClaudeParentSessionID(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUserWithSessionID(
|
|
tsZero, "Hello", "parent-uuid",
|
|
).
|
|
AddClaudeAssistant(tsZeroS5, "Hi there!").
|
|
String()
|
|
|
|
path := env.writeClaudeSession(
|
|
t, "test-proj", "child-test.jsonl", content,
|
|
)
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
assertSessionState(
|
|
t, env.db, "child-test",
|
|
func(sess *db.Session) {
|
|
require.NotNil(t, sess.ParentSessionID,
|
|
"parent_session_id = %v, want %q", sess.ParentSessionID, "parent-uuid")
|
|
assert.Equal(t, "parent-uuid", *sess.ParentSessionID,
|
|
"parent_session_id = %v, want %q", sess.ParentSessionID, "parent-uuid")
|
|
},
|
|
)
|
|
}
|
|
|
|
func TestSyncPathsClaudeNoParentSessionID(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "Hello").
|
|
String()
|
|
|
|
path := env.writeClaudeSession(
|
|
t, "test-proj", "no-parent-test.jsonl", content,
|
|
)
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
assertSessionState(
|
|
t, env.db, "no-parent-test",
|
|
func(sess *db.Session) {
|
|
assert.Nil(t, sess.ParentSessionID, "parent_session_id = %v, want nil", sess.ParentSessionID)
|
|
},
|
|
)
|
|
}
|
|
|
|
func TestSyncSubagentSetsParentSessionID(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
// Create parent session
|
|
parentContent := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "Build the feature").
|
|
AddClaudeAssistant(tsEarlyS5, "On it.").
|
|
String()
|
|
|
|
env.writeClaudeSession(
|
|
t, "test-proj", "parent-uuid.jsonl", parentContent,
|
|
)
|
|
|
|
// Create subagent file with sessionId pointing to parent
|
|
subContent := testjsonl.NewSessionBuilder().
|
|
AddClaudeUserWithSessionID(
|
|
tsEarly, "Do subtask", "parent-uuid",
|
|
).
|
|
AddClaudeAssistant(tsEarlyS5, "Subtask done.").
|
|
String()
|
|
|
|
env.writeSession(
|
|
t, env.claudeDir,
|
|
filepath.Join(
|
|
"test-proj", "parent-uuid",
|
|
"subagents", "agent-worker1.jsonl",
|
|
),
|
|
subContent,
|
|
)
|
|
|
|
// SyncAll should discover both parent and subagent
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 2, Synced: 2, Skipped: 0})
|
|
|
|
// Verify parent has no parent_session_id
|
|
assertSessionState(
|
|
t, env.db, "parent-uuid",
|
|
func(sess *db.Session) {
|
|
assert.Nil(t, sess.ParentSessionID, "parent parent_session_id = %v, want nil", sess.ParentSessionID)
|
|
},
|
|
)
|
|
|
|
// Verify subagent has parent_session_id set
|
|
assertSessionState(
|
|
t, env.db, "agent-worker1",
|
|
func(sess *db.Session) {
|
|
require.NotNil(t, sess.ParentSessionID,
|
|
"subagent parent_session_id = %v, want %q", sess.ParentSessionID, "parent-uuid")
|
|
assert.Equal(t, "parent-uuid", *sess.ParentSessionID,
|
|
"subagent parent_session_id = %v, want %q", sess.ParentSessionID, "parent-uuid")
|
|
assert.Equal(t, "claude", sess.Agent, "agent = %q, want claude", sess.Agent)
|
|
},
|
|
)
|
|
assertSessionMessageCount(t, env.db, "agent-worker1", 2)
|
|
|
|
// Verify FindSourceFile works for subagent
|
|
src := env.engine.FindSourceFile("agent-worker1")
|
|
assert.NotEmpty(t, src, "FindSourceFile returned empty for subagent")
|
|
}
|
|
|
|
func TestSyncClaudeToolResultAgentIDLinksSubagentToolCall(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
parentContent := strings.Join([]string{
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:00Z","uuid":"u1","message":{"content":"Build the feature"},"cwd":"/tmp"}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:01Z","uuid":"u2","parentUuid":"u1","message":{"content":[{"type":"tool_use","id":"toolu_agent_result","name":"Agent","input":{"description":"inspect schema","subagent_type":"Explore","prompt":"inspect the schema"}}]}}`,
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:05Z","uuid":"u3","parentUuid":"u2","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_agent_result","content":"done"}]},"toolUseResult":{"status":"completed","agentId":"abc123def4567890"}}`,
|
|
}, "\n")
|
|
|
|
env.writeClaudeSession(
|
|
t, "test-proj", "parent-agentid.jsonl", parentContent,
|
|
)
|
|
|
|
subContent := testjsonl.NewSessionBuilder().
|
|
AddClaudeUserWithSessionID(
|
|
tsEarly, "Do subtask", "parent-agentid",
|
|
).
|
|
AddClaudeAssistant(tsEarlyS5, "Subtask done.").
|
|
String()
|
|
|
|
env.writeSession(
|
|
t, env.claudeDir,
|
|
filepath.Join(
|
|
"test-proj", "parent-agentid",
|
|
"subagents", "agent-abc123def4567890.jsonl",
|
|
),
|
|
subContent,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 2, Synced: 2, Skipped: 0})
|
|
|
|
var got string
|
|
err := env.db.Reader().QueryRow(`
|
|
SELECT subagent_session_id
|
|
FROM tool_calls
|
|
WHERE session_id = ? AND tool_use_id = ?`,
|
|
"parent-agentid", "toolu_agent_result",
|
|
).Scan(&got)
|
|
require.NoError(t, err, "query linked subagent tool call")
|
|
assert.Equal(t, "agent-abc123def4567890", got, "subagent_session_id = %q, want %q", got, "agent-abc123def4567890")
|
|
}
|
|
|
|
func TestSyncClaudeSameMessageIDAgentChunksLinkAllSubagents(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
parentContent := strings.Join([]string{
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:00Z","uuid":"u1","message":{"content":"summarize with subagents"},"cwd":"/tmp"}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:01Z","uuid":"a1","parentUuid":"u1","message":{"id":"msg_same","content":[{"type":"text","text":"Launching agents."}],"usage":{"input_tokens":1,"output_tokens":1},"stop_reason":"tool_use"}}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:02Z","uuid":"a2","parentUuid":"a1","message":{"id":"msg_same","content":[{"type":"tool_use","id":"toolu_first","name":"Agent","input":{"description":"first","subagent_type":"Explore","prompt":"first"}}],"usage":{"input_tokens":1,"output_tokens":2},"stop_reason":"tool_use"}}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:03Z","uuid":"a3","parentUuid":"a2","message":{"id":"msg_same","content":[{"type":"tool_use","id":"toolu_second","name":"Agent","input":{"description":"second","subagent_type":"Explore","prompt":"second"}}],"usage":{"input_tokens":1,"output_tokens":3},"stop_reason":"tool_use"}}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:04Z","uuid":"a4","parentUuid":"a3","message":{"id":"msg_same","content":[{"type":"tool_use","id":"toolu_third","name":"Agent","input":{"description":"third","subagent_type":"Explore","prompt":"third"}}],"usage":{"input_tokens":1,"output_tokens":4},"stop_reason":"tool_use"}}`,
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:05Z","uuid":"r1","parentUuid":"a2","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_first","content":"done first"}]},"toolUseResult":{"status":"completed","agentId":"childfirst"}}`,
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:06Z","uuid":"r2","parentUuid":"a3","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_second","content":"done second"}]},"toolUseResult":{"status":"completed","agentId":"childsecond"}}`,
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:07Z","uuid":"r3","parentUuid":"a4","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_third","content":"done third"}]},"toolUseResult":{"status":"completed","agentId":"childthird"}}`,
|
|
}, "\n")
|
|
|
|
env.writeClaudeSession(
|
|
t, "test-proj", "parent-same-message.jsonl", parentContent,
|
|
)
|
|
|
|
for _, child := range []string{"childfirst", "childsecond", "childthird"} {
|
|
subContent := testjsonl.NewSessionBuilder().
|
|
AddClaudeUserWithSessionID(
|
|
tsEarly, "Do "+child, "parent-same-message",
|
|
).
|
|
AddClaudeAssistant(tsEarlyS5, child+" done.").
|
|
String()
|
|
env.writeSession(
|
|
t, env.claudeDir,
|
|
filepath.Join(
|
|
"test-proj", "parent-same-message",
|
|
"subagents", "agent-"+child+".jsonl",
|
|
),
|
|
subContent,
|
|
)
|
|
}
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 4, Synced: 4, Skipped: 0})
|
|
|
|
rows, err := env.db.Reader().Query(`
|
|
SELECT tool_use_id, subagent_session_id
|
|
FROM tool_calls
|
|
WHERE session_id = ?
|
|
ORDER BY tool_use_id`,
|
|
"parent-same-message",
|
|
)
|
|
require.NoError(t, err, "query linked subagent tool calls")
|
|
defer rows.Close()
|
|
|
|
got := map[string]string{}
|
|
for rows.Next() {
|
|
var toolUseID, subagentSessionID string
|
|
require.NoError(t, rows.Scan(&toolUseID, &subagentSessionID), "scan linked subagent tool call")
|
|
got[toolUseID] = subagentSessionID
|
|
}
|
|
require.NoError(t, rows.Err(), "iterate linked subagent tool calls")
|
|
|
|
want := map[string]string{
|
|
"toolu_first": "agent-childfirst",
|
|
"toolu_second": "agent-childsecond",
|
|
"toolu_third": "agent-childthird",
|
|
}
|
|
require.Equal(t, len(want), len(got), "linked tool calls = %v, want %v", got, want)
|
|
for toolUseID, wantSessionID := range want {
|
|
assert.Equal(t, wantSessionID, got[toolUseID], "%s subagent_session_id = %q, want %q", toolUseID, got[toolUseID], wantSessionID)
|
|
}
|
|
}
|
|
|
|
func TestSyncPathsClaudeSubagent(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
// Create parent session
|
|
parentContent := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "Hello").
|
|
AddClaudeAssistant(tsZeroS5, "Hi!").
|
|
String()
|
|
|
|
env.writeClaudeSession(
|
|
t, "test-proj", "parent-sess.jsonl", parentContent,
|
|
)
|
|
|
|
// Create subagent file with sessionId pointing to parent
|
|
subagentContent := testjsonl.NewSessionBuilder().
|
|
AddClaudeUserWithSessionID(
|
|
tsZero, "Do subtask", "parent-sess",
|
|
).
|
|
AddClaudeAssistant(tsZeroS5, "Done.").
|
|
String()
|
|
|
|
subPath := env.writeSession(
|
|
t, env.claudeDir,
|
|
filepath.Join(
|
|
"test-proj", "parent-sess",
|
|
"subagents", "agent-sub1.jsonl",
|
|
),
|
|
subagentContent,
|
|
)
|
|
|
|
// SyncPaths should accept the subagent path
|
|
env.engine.SyncPaths([]string{subPath})
|
|
|
|
assertSessionState(
|
|
t, env.db, "agent-sub1",
|
|
func(sess *db.Session) {
|
|
assert.Equal(t, "claude", sess.Agent, "agent = %q, want claude", sess.Agent)
|
|
},
|
|
)
|
|
}
|
|
|
|
func TestSyncPathsClaudeSubagentUsesCompanionDirectoryParent(
|
|
t *testing.T,
|
|
) {
|
|
env := setupTestEnv(t)
|
|
|
|
parentContent := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "Hello").
|
|
AddClaudeAssistant(tsZeroS5, "Hi!").
|
|
String()
|
|
|
|
env.writeClaudeSession(
|
|
t, "test-proj", "parent-sess.jsonl", parentContent,
|
|
)
|
|
|
|
subagentContent := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "Do subtask").
|
|
AddClaudeAssistant(tsZeroS5, "Done.").
|
|
String()
|
|
|
|
subPath := env.writeSession(
|
|
t, env.claudeDir,
|
|
filepath.Join(
|
|
"test-proj", "parent-sess",
|
|
"subagents", "agent-sub1.jsonl",
|
|
),
|
|
subagentContent,
|
|
)
|
|
|
|
env.engine.SyncPaths([]string{subPath})
|
|
|
|
assertSessionState(
|
|
t, env.db, "agent-sub1",
|
|
func(sess *db.Session) {
|
|
require.NotNil(t, sess.ParentSessionID,
|
|
"subagent parent_session_id = %v, want %q", sess.ParentSessionID, "parent-sess")
|
|
assert.Equal(t, "parent-sess", *sess.ParentSessionID,
|
|
"subagent parent_session_id = %v, want %q", sess.ParentSessionID, "parent-sess")
|
|
assert.Equal(t, "subagent", sess.RelationshipType,
|
|
"relationship_type = %q, want subagent", sess.RelationshipType)
|
|
},
|
|
)
|
|
}
|
|
|
|
func TestSyncPathsClaudeNestedWorkflowSubagent(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
subagentContent := testjsonl.NewSessionBuilder().
|
|
AddClaudeUserWithSessionID(
|
|
tsZero, "Deep research", "parent-sess",
|
|
).
|
|
AddClaudeAssistant(tsZeroS5, "Done.").
|
|
String()
|
|
|
|
subPath := env.writeSession(
|
|
t, env.claudeDir,
|
|
filepath.Join(
|
|
"test-proj", "parent-sess",
|
|
"subagents", "workflows", "wf-123",
|
|
"agent-deep.jsonl",
|
|
),
|
|
subagentContent,
|
|
)
|
|
|
|
env.engine.SyncPaths([]string{subPath})
|
|
|
|
assertSessionState(
|
|
t, env.db, "agent-deep",
|
|
func(sess *db.Session) {
|
|
assert.Equal(t, "claude", sess.Agent, "agent = %q, want claude", sess.Agent)
|
|
},
|
|
)
|
|
}
|
|
|
|
func TestSyncPathsClaudeRejectsNonAgentInSubagents(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "Hello").
|
|
String()
|
|
|
|
// Write a non-agent file in subagents dir
|
|
path := env.writeSession(
|
|
t, env.claudeDir,
|
|
filepath.Join(
|
|
"proj", "session",
|
|
"subagents", "not-agent.jsonl",
|
|
),
|
|
content,
|
|
)
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
sess, _ := env.db.GetSession(
|
|
context.Background(), "not-agent",
|
|
)
|
|
assert.Nil(t, sess, "non-agent file in subagents dir "+"should be rejected")
|
|
}
|
|
|
|
func TestSyncPathsClaudeRejectsNested(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "Hello").
|
|
String()
|
|
|
|
// Write at proj/subdir/nested.jsonl — should be rejected
|
|
// since Claude expects exactly <project>/<session>.jsonl.
|
|
path := env.writeClaudeSession(
|
|
t, filepath.Join("proj", "subdir"),
|
|
"nested.jsonl", content,
|
|
)
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
sess, _ := env.db.GetSession(
|
|
context.Background(), "nested",
|
|
)
|
|
assert.Nil(t, sess, "nested Claude path should be rejected "+"(only <project>/<session>.jsonl allowed)")
|
|
}
|
|
|
|
// TestSyncEngineOpenCodeBulkSync verifies that SyncAll
|
|
// discovers OpenCode sessions and fully replaces messages
|
|
// when content changes in place (same ordinals, different
|
|
// text/tool data).
|
|
func TestSyncEngineOpenCodeBulkSync(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
|
|
oc := createOpenCodeDB(t, env.opencodeDir)
|
|
oc.addProject(t, "proj-1", "/home/user/code/myapp")
|
|
|
|
sessionID := "oc-sess-001"
|
|
var timeCreated int64 = 1704067200000 // 2024-01-01T00:00:00Z
|
|
var timeUpdated int64 = 1704067205000 // +5s
|
|
|
|
oc.addSession(
|
|
t, sessionID, "proj-1",
|
|
timeCreated, timeUpdated,
|
|
)
|
|
oc.addMessage(
|
|
t, "msg-u1", sessionID, "user", timeCreated,
|
|
)
|
|
oc.addMessage(
|
|
t, "msg-a1", sessionID, "assistant",
|
|
timeCreated+1,
|
|
)
|
|
oc.addTextPart(
|
|
t, "part-u1", sessionID, "msg-u1",
|
|
"original question", timeCreated,
|
|
)
|
|
oc.addTextPart(
|
|
t, "part-a1", sessionID, "msg-a1",
|
|
"original answer", timeCreated+1,
|
|
)
|
|
|
|
// First SyncAll should discover and store the session.
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
agentviewID := "opencode:" + sessionID
|
|
assertSessionState(t, env.db, agentviewID,
|
|
func(sess *db.Session) {
|
|
assert.Equal(t, "opencode", sess.Agent, "agent = %q, want opencode", sess.Agent)
|
|
},
|
|
)
|
|
assertSessionMessageCount(t, env.db, agentviewID, 2)
|
|
assertMessageContent(
|
|
t, env.db, agentviewID,
|
|
"original question", "original answer",
|
|
)
|
|
|
|
// Mutate the session in place: replace content but
|
|
// keep the same number of messages (same ordinals).
|
|
// Bump time_updated so the sync engine detects it.
|
|
oc.replaceTextContent(
|
|
t, sessionID,
|
|
"updated question", "updated answer",
|
|
timeCreated,
|
|
)
|
|
oc.updateSessionTime(t, sessionID, timeUpdated+1000)
|
|
|
|
// Second SyncAll should fully replace messages.
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
assertMessageContent(
|
|
t, env.db, agentviewID,
|
|
"updated question", "updated answer",
|
|
)
|
|
|
|
// Third SyncAll with no changes should be a no-op
|
|
// (time_updated unchanged, so session is skipped).
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
assertMessageContent(
|
|
t, env.db, agentviewID,
|
|
"updated question", "updated answer",
|
|
)
|
|
}
|
|
|
|
func TestSyncEngineOpenCodeReviewWithGeneratedTitleIsAutomated(
|
|
t *testing.T,
|
|
) {
|
|
env := setupTestEnv(t)
|
|
|
|
oc := createOpenCodeDB(t, env.opencodeDir)
|
|
oc.addProject(t, "proj-1", "/home/user/code/myapp")
|
|
|
|
sessionID := "oc-review-title"
|
|
timeCreated := int64(1704067200000)
|
|
timeUpdated := int64(1704067205000)
|
|
oc.mustExec(t, "insert titled session",
|
|
`INSERT INTO session
|
|
(id, project_id, title, time_created, time_updated)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
sessionID, "proj-1", "Review generated title",
|
|
timeCreated, timeUpdated,
|
|
)
|
|
oc.addMessage(t, "msg-u1", sessionID, "user", timeCreated)
|
|
oc.addMessage(t, "msg-a1", sessionID, "assistant", timeCreated+1)
|
|
oc.addTextPart(
|
|
t, "part-u1", sessionID, "msg-u1",
|
|
"You are a code reviewer. Review the code changes shown below.",
|
|
timeCreated,
|
|
)
|
|
oc.addTextPart(
|
|
t, "part-a1", sessionID, "msg-a1",
|
|
"Review complete.", timeCreated+1,
|
|
)
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
agentviewID := "opencode:" + sessionID
|
|
assertSessionState(t, env.db, agentviewID,
|
|
func(sess *db.Session) {
|
|
assert.True(t, sess.IsAutomated,
|
|
"OpenCode review sessions with generated titles must still be classified as automated")
|
|
},
|
|
)
|
|
|
|
page, err := env.db.ListSessions(
|
|
context.Background(),
|
|
db.SessionFilter{ExcludeAutomated: true, Limit: 10},
|
|
)
|
|
require.NoError(t, err, "ListSessions exclude automated")
|
|
assert.Empty(t, page.Sessions,
|
|
"automated OpenCode review should be excluded")
|
|
}
|
|
|
|
func TestSyncEngineOpenCodeStorageBulkSync(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
oc := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
|
|
sessionPath := oc.addSession(
|
|
t, "global", "oc-storage-1",
|
|
"/home/user/code/myapp", "Storage Sync",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
oc.addMessage(
|
|
t, "oc-storage-1", "msg-u1", "user",
|
|
1704067200000, nil,
|
|
)
|
|
oc.addTextPart(
|
|
t, "oc-storage-1", "msg-u1", "part-u1",
|
|
"hello from storage", 1704067200000,
|
|
)
|
|
oc.addMessage(
|
|
t, "oc-storage-1", "msg-a1", "assistant",
|
|
1704067201000, map[string]any{
|
|
"modelID": "gpt-5.2-codex",
|
|
},
|
|
)
|
|
oc.addTextPart(
|
|
t, "oc-storage-1", "msg-a1", "part-a1",
|
|
"reply from storage", 1704067201000,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
|
|
assertSessionState(t, env.db, "opencode:oc-storage-1",
|
|
func(sess *db.Session) {
|
|
assert.Equal(t, "opencode", sess.Agent, "agent = %q, want opencode", sess.Agent)
|
|
},
|
|
)
|
|
assert.Equal(t, sessionPath, env.engine.FindSourceFile("opencode:oc-storage-1"))
|
|
assertMessageContent(
|
|
t, env.db, "opencode:oc-storage-1",
|
|
"hello from storage", "reply from storage",
|
|
)
|
|
}
|
|
|
|
func TestSyncSingleSessionOpenCodeSQLiteFallback(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
oc := createOpenCodeDB(t, env.opencodeDir)
|
|
oc.addProject(t, "proj-1", "/home/user/code/myapp")
|
|
|
|
sessionID := "oc-sqlite-sync-single"
|
|
timeCreated := int64(1704067200000)
|
|
timeUpdated := int64(1704067205000)
|
|
|
|
oc.addSession(
|
|
t, sessionID, "proj-1",
|
|
timeCreated, timeUpdated,
|
|
)
|
|
oc.addMessage(
|
|
t, "msg-u1", sessionID, "user", timeCreated,
|
|
)
|
|
oc.addMessage(
|
|
t, "msg-a1", sessionID, "assistant", timeCreated+1,
|
|
)
|
|
oc.addTextPart(
|
|
t, "part-u1", sessionID, "msg-u1",
|
|
"original sqlite question", timeCreated,
|
|
)
|
|
oc.addTextPart(
|
|
t, "part-a1", sessionID, "msg-a1",
|
|
"original sqlite answer", timeCreated+1,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
|
|
oc.replaceTextContent(
|
|
t, sessionID,
|
|
"updated sqlite question",
|
|
"updated sqlite answer",
|
|
timeCreated,
|
|
)
|
|
oc.updateSessionTime(t, sessionID, timeUpdated+1000)
|
|
|
|
err := env.engine.SyncSingleSession(
|
|
"opencode:" + sessionID,
|
|
)
|
|
require.NoError(t, err, "SyncSingleSession")
|
|
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+sessionID,
|
|
"updated sqlite question",
|
|
"updated sqlite answer",
|
|
)
|
|
}
|
|
|
|
func TestSyncSingleSessionOpenCodeSQLiteFallbackPreservesStorageArchive(
|
|
t *testing.T,
|
|
) {
|
|
env := setupTestEnv(t)
|
|
storage := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
|
|
sessionID := "oc-sqlite-single-preserve"
|
|
storage.addSession(
|
|
t, "global", sessionID,
|
|
"/home/user/code/myapp", "Storage Archive",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
storage.addMessage(
|
|
t, sessionID, "msg-u1", "user",
|
|
1704067200000, nil,
|
|
)
|
|
storage.addTextPart(
|
|
t, sessionID, "msg-u1", "part-u1",
|
|
"hello storage", 1704067200000,
|
|
)
|
|
storage.addMessage(
|
|
t, sessionID, "msg-a1", "assistant",
|
|
1704067201000, nil,
|
|
)
|
|
storage.addTextPart(
|
|
t, sessionID, "msg-a1", "part-a1",
|
|
"storage archive answer", 1704067201000,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
|
|
err := os.RemoveAll(
|
|
filepath.Join(env.opencodeDir, "storage"),
|
|
)
|
|
require.NoError(t, err, "remove storage tree")
|
|
|
|
sqlite := createOpenCodeDB(t, env.opencodeDir)
|
|
sqlite.addProject(t, "proj-1", "/home/user/code/myapp")
|
|
sqlite.addSession(
|
|
t, sessionID, "proj-1",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
sqlite.addMessage(
|
|
t, "sqlite-msg-u1", sessionID, "user",
|
|
1704067200000,
|
|
)
|
|
sqlite.addTextPart(
|
|
t, "sqlite-part-u1", sessionID, "sqlite-msg-u1",
|
|
"hello sqlite fallback", 1704067200000,
|
|
)
|
|
|
|
err = env.engine.SyncSingleSession(
|
|
"opencode:" + sessionID,
|
|
)
|
|
require.NoError(t, err, "SyncSingleSession")
|
|
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+sessionID,
|
|
"hello storage", "storage archive answer",
|
|
)
|
|
}
|
|
|
|
func TestOpenCodeSQLiteRootSyncPathsAndStaleReparse(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
oc := createOpenCodeDB(t, env.opencodeDir)
|
|
const projectID = "proj-1"
|
|
oc.addProject(t, projectID, "/home/user/code/myapp")
|
|
|
|
const (
|
|
timeCreated = int64(1704067200000)
|
|
timeUpdated = int64(1704067205000)
|
|
)
|
|
seedTextSession := func(
|
|
sessionID, question, answer string, offset int64,
|
|
) string {
|
|
t.Helper()
|
|
created := timeCreated + offset
|
|
updated := timeUpdated + offset
|
|
oc.addSession(t, sessionID, projectID, created, updated)
|
|
userMessageID := sessionID + "-msg-u1"
|
|
oc.addMessage(t, userMessageID, sessionID, "user", created)
|
|
oc.addTextPart(
|
|
t, sessionID+"-part-u1", sessionID, userMessageID,
|
|
question, created,
|
|
)
|
|
if answer == "" {
|
|
return ""
|
|
}
|
|
assistantMessageID := sessionID + "-msg-a1"
|
|
oc.addMessage(
|
|
t, assistantMessageID, sessionID, "assistant", created+1,
|
|
)
|
|
oc.addTextPart(
|
|
t, sessionID+"-part-a1", sessionID, assistantMessageID,
|
|
answer, created+1,
|
|
)
|
|
return assistantMessageID
|
|
}
|
|
|
|
syncPathsID := "oc-sqlite-sync-paths"
|
|
seedTextSession(
|
|
syncPathsID,
|
|
"original sqlite question", "original sqlite answer", 0,
|
|
)
|
|
staleVersionID := "oc-sqlite-stale-version"
|
|
staleAssistantMessageID := seedTextSession(
|
|
staleVersionID,
|
|
"stale version question", "stale version answer", 10,
|
|
)
|
|
sourceMtimeID := "oc-source-sqlite"
|
|
seedTextSession(
|
|
sourceMtimeID,
|
|
"source mtime question", "source mtime answer", 20,
|
|
)
|
|
goodSessionID := "oc-sqlite-watch-good"
|
|
seedTextSession(
|
|
goodSessionID,
|
|
"good original question", "good original answer", 30,
|
|
)
|
|
badSessionID := "oc-sqlite-watch-bad"
|
|
seedTextSession(
|
|
badSessionID,
|
|
"bad original question", "", 40,
|
|
)
|
|
|
|
initialMtime := env.engine.SourceMtime("opencode:" + sourceMtimeID)
|
|
require.Equal(t, int64((timeUpdated+20)*1_000_000), initialMtime, "initial source mtime")
|
|
|
|
sourceUpdated := timeUpdated + 1020
|
|
oc.updateSessionTime(t, sourceMtimeID, sourceUpdated)
|
|
updatedMtime := env.engine.SourceMtime("opencode:" + sourceMtimeID)
|
|
require.Equal(t, sourceUpdated*1_000_000, updatedMtime, "updated source mtime")
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 5,
|
|
Synced: 5,
|
|
Skipped: 0,
|
|
})
|
|
|
|
oc.replaceTextContent(
|
|
t, syncPathsID,
|
|
"updated sqlite question",
|
|
"updated sqlite answer",
|
|
timeCreated,
|
|
)
|
|
oc.updateSessionTime(t, syncPathsID, timeUpdated+1000)
|
|
|
|
env.engine.SyncPaths([]string{oc.path})
|
|
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+syncPathsID,
|
|
"updated sqlite question",
|
|
"updated sqlite answer",
|
|
)
|
|
|
|
oc.updateMessageData(t, staleAssistantMessageID, map[string]any{
|
|
"role": "assistant",
|
|
"modelID": "claude-3-7-sonnet",
|
|
})
|
|
err := env.db.SetSessionDataVersion(
|
|
"opencode:"+staleVersionID, 0,
|
|
)
|
|
require.NoError(t, err, "SetSessionDataVersion")
|
|
|
|
stats := env.engine.SyncAll(context.Background(), nil)
|
|
require.Equal(t, 0, stats.Failed, "SyncAll stats = %+v", stats)
|
|
require.NotZero(t, stats.Synced, "SyncAll stats = %+v", stats)
|
|
|
|
msgs := fetchMessages(t, env.db, "opencode:"+staleVersionID)
|
|
assert.Equal(t, "claude-3-7-sonnet", msgs[1].Model)
|
|
|
|
oc.replaceTextContent(
|
|
t, goodSessionID,
|
|
"good updated question",
|
|
"good updated answer",
|
|
timeCreated+30,
|
|
)
|
|
oc.updateSessionTime(t, goodSessionID, timeUpdated+1030)
|
|
oc.updateSessionTime(t, badSessionID, timeUpdated+2040)
|
|
oc.mustExec(
|
|
t, "corrupt bad session message time",
|
|
"UPDATE message SET time_created = ? WHERE id = ?",
|
|
"broken-time", badSessionID+"-msg-u1",
|
|
)
|
|
|
|
env.engine.SyncPaths([]string{oc.path})
|
|
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+goodSessionID,
|
|
"good updated question",
|
|
"good updated answer",
|
|
)
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+badSessionID,
|
|
"bad original question",
|
|
)
|
|
}
|
|
|
|
func TestSyncAllOpenCodeSQLiteFallbackPreservesStorageArchive(
|
|
t *testing.T,
|
|
) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
storage := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
|
|
sessionID := "oc-sqlite-bulk-preserve"
|
|
storage.addSession(
|
|
t, "global", sessionID,
|
|
"/home/user/code/myapp", "Storage Archive Bulk",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
storage.addMessage(
|
|
t, sessionID, "msg-u1", "user",
|
|
1704067200000, nil,
|
|
)
|
|
storage.addTextPart(
|
|
t, sessionID, "msg-u1", "part-u1",
|
|
"hello storage", 1704067200000,
|
|
)
|
|
storage.addMessage(
|
|
t, sessionID, "msg-a1", "assistant",
|
|
1704067201000, nil,
|
|
)
|
|
storage.addTextPart(
|
|
t, sessionID, "msg-a1", "part-a1",
|
|
"storage archive answer", 1704067201000,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
|
|
err := os.RemoveAll(
|
|
filepath.Join(env.opencodeDir, "storage"),
|
|
)
|
|
require.NoError(t, err, "remove storage tree")
|
|
|
|
sqlite := createOpenCodeDB(t, env.opencodeDir)
|
|
sqlite.addProject(t, "proj-1", "/home/user/code/myapp")
|
|
sqlite.addSession(
|
|
t, sessionID, "proj-1",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
sqlite.addMessage(
|
|
t, "sqlite-msg-u1", sessionID, "user",
|
|
1704067200000,
|
|
)
|
|
sqlite.addTextPart(
|
|
t, "sqlite-part-u1", sessionID, "sqlite-msg-u1",
|
|
"hello sqlite fallback", 1704067200000,
|
|
)
|
|
|
|
stats := env.engine.SyncAll(context.Background(), nil)
|
|
require.Equal(t, 0, stats.Failed, "stats.Failed = %d, want 0", stats.Failed)
|
|
require.Equal(t, 0, stats.Synced, "stats.Synced = %d, want 0", stats.Synced)
|
|
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+sessionID,
|
|
"hello storage", "storage archive answer",
|
|
)
|
|
}
|
|
|
|
func TestSyncPathsOpenCodeSQLiteDBEventIgnoresStaleSkipCache(
|
|
t *testing.T,
|
|
) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
oc := createOpenCodeDB(t, env.opencodeDir)
|
|
oc.addProject(t, "proj-1", "/home/user/code/myapp")
|
|
|
|
sessionID := "oc-sqlite-sync-paths-skip-cache"
|
|
timeCreated := int64(1704067200000)
|
|
timeUpdated := int64(1704067205000)
|
|
|
|
oc.addSession(
|
|
t, sessionID, "proj-1",
|
|
timeCreated, timeUpdated,
|
|
)
|
|
oc.addMessage(
|
|
t, "msg-u1", sessionID, "user", timeCreated,
|
|
)
|
|
oc.addMessage(
|
|
t, "msg-a1", sessionID, "assistant", timeCreated+1,
|
|
)
|
|
oc.addTextPart(
|
|
t, "part-u1", sessionID, "msg-u1",
|
|
"original sqlite question", timeCreated,
|
|
)
|
|
oc.addTextPart(
|
|
t, "part-a1", sessionID, "msg-a1",
|
|
"original sqlite answer", timeCreated+1,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
|
|
info, err := os.Stat(oc.path)
|
|
require.NoError(t, err, "stat opencode db")
|
|
cachedMtime := info.ModTime()
|
|
env.engine.InjectSkipCache(map[string]int64{
|
|
oc.path: cachedMtime.UnixNano(),
|
|
})
|
|
|
|
oc.replaceTextContent(
|
|
t, sessionID,
|
|
"updated sqlite question",
|
|
"updated sqlite answer",
|
|
timeCreated,
|
|
)
|
|
oc.updateSessionTime(t, sessionID, timeUpdated+1000)
|
|
require.NoError(t, os.Chtimes(oc.path, cachedMtime, cachedMtime), "restore db mtime")
|
|
|
|
env.engine.SyncPaths([]string{oc.path})
|
|
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+sessionID,
|
|
"updated sqlite question",
|
|
"updated sqlite answer",
|
|
)
|
|
}
|
|
|
|
func TestSyncPathsOpenCodeStorageChildRetryWithoutSessionMtimeChange(
|
|
t *testing.T,
|
|
) {
|
|
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
oc := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
|
|
sessionPath := oc.addSession(
|
|
t, "global", "oc-storage-retry",
|
|
"/home/user/code/myapp", "Retry Session",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
messagePath := filepath.Join(
|
|
env.opencodeDir, "storage", "message",
|
|
"oc-storage-retry", "msg-u1.json",
|
|
)
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(messagePath), 0o755), "mkdir message dir")
|
|
err := os.WriteFile(
|
|
messagePath, []byte(`{"id":"msg-u1"`), 0o644,
|
|
)
|
|
require.NoError(t, err, "write invalid message")
|
|
|
|
env.engine.SyncPaths([]string{messagePath})
|
|
sess, err := env.db.GetSession(
|
|
context.Background(), "opencode:oc-storage-retry",
|
|
)
|
|
require.NoError(t, err, "GetSession")
|
|
require.Nil(t, sess, "unexpected session after invalid child parse: %+v", sess)
|
|
|
|
info, err := os.Stat(sessionPath)
|
|
require.NoError(t, err, "stat session path")
|
|
sessionMtime := info.ModTime().UnixNano()
|
|
|
|
oc.addMessage(
|
|
t, "oc-storage-retry", "msg-u1", "user",
|
|
1704067200000, nil,
|
|
)
|
|
oc.addTextPart(
|
|
t, "oc-storage-retry", "msg-u1", "part-u1",
|
|
"hello after retry", 1704067200000,
|
|
)
|
|
err = os.Chtimes(
|
|
sessionPath,
|
|
time.Unix(0, sessionMtime),
|
|
time.Unix(0, sessionMtime),
|
|
)
|
|
require.NoError(t, err, "restore session mtime")
|
|
|
|
env.engine.SyncPaths([]string{messagePath})
|
|
|
|
assertMessageContent(
|
|
t, env.db, "opencode:oc-storage-retry",
|
|
"hello after retry",
|
|
)
|
|
}
|
|
|
|
func TestSyncPathsOpenCodeStorageChildUpdateAdvancesSessionMtime(
|
|
t *testing.T,
|
|
) {
|
|
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
oc := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
|
|
sessionPath := oc.addSession(
|
|
t, "global", "oc-storage-mtime",
|
|
"/home/user/code/myapp", "Mtime Session",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
oc.addMessage(
|
|
t, "oc-storage-mtime", "msg-a1", "assistant",
|
|
1704067201000, nil,
|
|
)
|
|
partPath := oc.addTextPart(
|
|
t, "oc-storage-mtime", "msg-a1", "part-a1",
|
|
"initial reply", 1704067201000,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
|
|
_, initialMtime, ok := env.db.GetSessionFileInfo(
|
|
"opencode:oc-storage-mtime",
|
|
)
|
|
require.True(t, ok, "expected initial session file_mtime")
|
|
|
|
info, err := os.Stat(sessionPath)
|
|
require.NoError(t, err, "stat session path")
|
|
sessionMtime := info.ModTime().UnixNano()
|
|
|
|
err = os.WriteFile(partPath, []byte(
|
|
`{"id":"part-a1","sessionID":"oc-storage-mtime","messageID":"msg-a1","type":"text","text":"updated reply","time":{"created":1704067201000}}`,
|
|
), 0o644)
|
|
require.NoError(t, err, "rewrite part")
|
|
err = os.Chtimes(
|
|
sessionPath,
|
|
time.Unix(0, sessionMtime),
|
|
time.Unix(0, sessionMtime),
|
|
)
|
|
require.NoError(t, err, "restore session mtime")
|
|
ocProvider, ok := parser.NewProvider(parser.AgentOpenCode, parser.ProviderConfig{
|
|
Roots: []string{env.opencodeDir},
|
|
Machine: "local",
|
|
})
|
|
require.True(t, ok, "opencode provider available")
|
|
ocSource, found, parseErr := ocProvider.FindSource(
|
|
context.Background(),
|
|
parser.FindSourceRequest{FullSessionID: "opencode:oc-storage-mtime"},
|
|
)
|
|
require.NoError(t, parseErr, "find opencode source after rewrite")
|
|
require.True(t, found, "opencode source found after rewrite")
|
|
ocOutcome, parseErr := ocProvider.Parse(context.Background(), parser.ParseRequest{
|
|
Source: ocSource,
|
|
Machine: "local",
|
|
})
|
|
require.NoError(t, parseErr, "parse opencode source after rewrite")
|
|
require.Len(t, ocOutcome.Results, 1, "parsed results after rewrite")
|
|
parsedMsgs := ocOutcome.Results[0].Result.Messages
|
|
require.Len(t, parsedMsgs, 1, "parsed messages after rewrite = %#v, want updated reply", parsedMsgs)
|
|
require.Equal(t, "updated reply", parsedMsgs[0].Content,
|
|
"parsed messages after rewrite = %#v, want updated reply", parsedMsgs)
|
|
|
|
env.engine.SyncPaths([]string{partPath})
|
|
|
|
_, updatedMtime, ok := env.db.GetSessionFileInfo(
|
|
"opencode:oc-storage-mtime",
|
|
)
|
|
require.True(t, ok, "expected updated session file_mtime")
|
|
require.Greater(t, updatedMtime, initialMtime, "updated file_mtime = %d, want > %d", updatedMtime, initialMtime)
|
|
assertMessageContent(
|
|
t, env.db, "opencode:oc-storage-mtime",
|
|
"updated reply",
|
|
)
|
|
}
|
|
|
|
func TestSourceMtimeOpenCodeStorageIncludesChildFiles(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
oc := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
|
|
sessionPath := oc.addSession(
|
|
t, "global", "oc-source-mtime",
|
|
"/home/user/code/myapp", "Source Mtime",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
oc.addMessage(
|
|
t, "oc-source-mtime", "msg-a1", "assistant",
|
|
1704067201000, nil,
|
|
)
|
|
partPath := oc.addTextPart(
|
|
t, "oc-source-mtime", "msg-a1", "part-a1",
|
|
"initial reply", 1704067201000,
|
|
)
|
|
|
|
initialMtime := env.engine.SourceMtime("opencode:oc-source-mtime")
|
|
require.NotZero(t, initialMtime, "expected initial composite source mtime")
|
|
|
|
info, err := os.Stat(sessionPath)
|
|
require.NoError(t, err, "stat session path")
|
|
sessionMtime := info.ModTime()
|
|
future := time.Now().Add(2 * time.Second)
|
|
|
|
err = os.WriteFile(partPath, []byte(
|
|
`{"id":"part-a1","sessionID":"oc-source-mtime","messageID":"msg-a1","type":"text","text":"updated reply","time":{"created":1704067201000}}`,
|
|
), 0o644)
|
|
require.NoError(t, err, "rewrite part")
|
|
require.NoError(t, os.Chtimes(partPath, future, future), "chtimes part")
|
|
require.NoError(t, os.Chtimes(sessionPath, sessionMtime, sessionMtime), "restore session mtime")
|
|
|
|
updatedMtime := env.engine.SourceMtime("opencode:oc-source-mtime")
|
|
require.Greater(t, updatedMtime, initialMtime, "updated source mtime = %d, want > %d", updatedMtime, initialMtime)
|
|
}
|
|
|
|
func TestSourceMtimeOpenCodeStorageTracksChildRemoval(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
oc := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
|
|
oc.addSession(
|
|
t, "global", "oc-source-remove",
|
|
"/home/user/code/myapp", "Source Remove",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
oc.addMessage(
|
|
t, "oc-source-remove", "msg-a1", "assistant",
|
|
1704067201000, nil,
|
|
)
|
|
partPath := oc.addTextPart(
|
|
t, "oc-source-remove", "msg-a1", "part-a1",
|
|
"initial reply", 1704067201000,
|
|
)
|
|
|
|
initialMtime := env.engine.SourceMtime("opencode:oc-source-remove")
|
|
require.NotZero(t, initialMtime, "expected initial composite source mtime")
|
|
|
|
partDir := filepath.Dir(partPath)
|
|
require.NoError(t, os.Remove(partPath), "remove part")
|
|
future := time.Now().Add(2 * time.Second)
|
|
require.NoError(t, os.Chtimes(partDir, future, future), "chtimes part dir")
|
|
|
|
updatedMtime := env.engine.SourceMtime("opencode:oc-source-remove")
|
|
require.Greater(t, updatedMtime, initialMtime, "updated source mtime = %d, want > %d", updatedMtime, initialMtime)
|
|
}
|
|
|
|
func TestSourceMtimeOpenCodeStorageTracksPartDirRemoval(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
oc := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
|
|
oc.addSession(
|
|
t, "global", "oc-source-remove-dir",
|
|
"/home/user/code/myapp", "Source Remove Dir",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
oc.addMessage(
|
|
t, "oc-source-remove-dir", "msg-a1", "assistant",
|
|
1704067201000, nil,
|
|
)
|
|
partPath := oc.addTextPart(
|
|
t, "oc-source-remove-dir", "msg-a1", "part-a1",
|
|
"initial reply", 1704067201000,
|
|
)
|
|
|
|
initialMtime := env.engine.SourceMtime("opencode:oc-source-remove-dir")
|
|
require.NotZero(t, initialMtime, "expected initial composite source mtime")
|
|
|
|
future := time.Now().Add(2 * time.Second)
|
|
require.NoError(t, os.RemoveAll(filepath.Dir(partPath)), "remove part dir")
|
|
partRoot := filepath.Join(
|
|
env.opencodeDir, "storage", "part",
|
|
)
|
|
require.NoError(t, os.Chtimes(partRoot, future, future), "chtimes part root")
|
|
|
|
updatedMtime := env.engine.SourceMtime("opencode:oc-source-remove-dir")
|
|
require.Greater(t, updatedMtime, initialMtime, "updated source mtime = %d, want > %d", updatedMtime, initialMtime)
|
|
}
|
|
|
|
func TestSourceMtimeOpenCodeStorageTracksMessageDirRemoval(
|
|
t *testing.T,
|
|
) {
|
|
env := setupTestEnv(t)
|
|
oc := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
|
|
oc.addSession(
|
|
t, "global", "oc-source-remove-message-dir",
|
|
"/home/user/code/myapp", "Source Remove Message Dir",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
messagePath := oc.addMessage(
|
|
t, "oc-source-remove-message-dir", "msg-a1", "assistant",
|
|
1704067201000, nil,
|
|
)
|
|
oc.addTextPart(
|
|
t, "oc-source-remove-message-dir", "msg-a1", "part-a1",
|
|
"initial reply", 1704067201000,
|
|
)
|
|
|
|
initialMtime := env.engine.SourceMtime(
|
|
"opencode:oc-source-remove-message-dir",
|
|
)
|
|
require.NotZero(t, initialMtime, "expected initial composite source mtime")
|
|
|
|
future := time.Now().Add(2 * time.Second)
|
|
require.NoError(t, os.RemoveAll(filepath.Dir(messagePath)), "remove message dir")
|
|
messageRoot := filepath.Join(
|
|
env.opencodeDir, "storage", "message",
|
|
)
|
|
require.NoError(t, os.Chtimes(messageRoot, future, future), "chtimes message root")
|
|
|
|
updatedMtime := env.engine.SourceMtime(
|
|
"opencode:oc-source-remove-message-dir",
|
|
)
|
|
require.Greater(t, updatedMtime, initialMtime, "updated source mtime = %d, want > %d", updatedMtime, initialMtime)
|
|
}
|
|
|
|
func TestOpenCodeHybridRootSyncsSQLiteSessions(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
storage := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
storage.addSession(
|
|
t, "global", "oc-hybrid-storage",
|
|
"/home/user/code/storage-app", "Hybrid Storage",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
storage.addMessage(
|
|
t, "oc-hybrid-storage", "msg-a1", "assistant",
|
|
1704067201000, nil,
|
|
)
|
|
storage.addTextPart(
|
|
t, "oc-hybrid-storage", "msg-a1", "part-a1",
|
|
"storage reply", 1704067201000,
|
|
)
|
|
|
|
const duplicateID = "oc-hybrid-dup"
|
|
storage.addSession(
|
|
t, "global", duplicateID,
|
|
"/home/user/code/storage-app", "Hybrid Dup",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
storage.addMessage(
|
|
t, duplicateID, "msg-storage-a1", "assistant",
|
|
1704067201000, nil,
|
|
)
|
|
storage.addTextPart(
|
|
t, duplicateID, "msg-storage-a1", "part-storage-a1",
|
|
"canonical storage reply", 1704067201000,
|
|
)
|
|
|
|
sqlite := createOpenCodeDB(t, env.opencodeDir)
|
|
sqlite.addProject(t, "proj-1", "/home/user/code/sqlite-app")
|
|
sqlite.addProject(t, "proj-2", "/home/user/code/storage-app")
|
|
sessionID := "oc-hybrid-sqlite"
|
|
timeCreated := int64(1704067200000)
|
|
timeUpdated := int64(1704067205000)
|
|
sqlite.addSession(
|
|
t, sessionID, "proj-1",
|
|
timeCreated, timeUpdated,
|
|
)
|
|
sqlite.addMessage(
|
|
t, "sqlite-msg-u1", sessionID, "user", timeCreated,
|
|
)
|
|
sqlite.addMessage(
|
|
t, "sqlite-msg-a1", sessionID, "assistant", timeCreated+1,
|
|
)
|
|
sqlite.addTextPart(
|
|
t, "sqlite-part-u1", sessionID, "sqlite-msg-u1",
|
|
"original sqlite question", timeCreated,
|
|
)
|
|
sqlite.addTextPart(
|
|
t, "sqlite-part-a1", sessionID, "sqlite-msg-a1",
|
|
"original sqlite answer", timeCreated+1,
|
|
)
|
|
// The duplicate SQLite row is much newer so that the storage transcript
|
|
// wins because of the duplicate-ID filter, not because of mtime ordering.
|
|
duplicateUpdated := int64(1804067200000)
|
|
sqlite.addSession(
|
|
t, duplicateID, "proj-2",
|
|
timeCreated, duplicateUpdated,
|
|
)
|
|
sqlite.addMessage(
|
|
t, "dup-sqlite-msg-u1", duplicateID, "user", timeCreated,
|
|
)
|
|
sqlite.addMessage(
|
|
t, "dup-sqlite-msg-a1", duplicateID, "assistant", timeCreated+1,
|
|
)
|
|
sqlite.addTextPart(
|
|
t, "dup-sqlite-part-u1", duplicateID, "dup-sqlite-msg-u1",
|
|
"stale sqlite question", timeCreated,
|
|
)
|
|
sqlite.addTextPart(
|
|
t, "dup-sqlite-part-a1", duplicateID, "dup-sqlite-msg-a1",
|
|
"stale sqlite answer", timeCreated+1,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 3,
|
|
Synced: 3,
|
|
Skipped: 0,
|
|
})
|
|
|
|
assertMessageContent(
|
|
t, env.db, "opencode:oc-hybrid-storage",
|
|
"storage reply",
|
|
)
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+sessionID,
|
|
"original sqlite question",
|
|
"original sqlite answer",
|
|
)
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+duplicateID,
|
|
"canonical storage reply",
|
|
)
|
|
|
|
virtualPath := parser.OpenCodeSQLiteVirtualPath(sqlite.path, sessionID)
|
|
assert.Equal(t, virtualPath, env.engine.FindSourceFile("opencode:"+sessionID))
|
|
assert.Equal(t, timeUpdated*1_000_000, env.engine.SourceMtime("opencode:"+sessionID))
|
|
duplicateStoragePath := filepath.Join(
|
|
env.opencodeDir, "storage", "session", "global",
|
|
duplicateID+".json",
|
|
)
|
|
assert.Equal(t, duplicateStoragePath, env.engine.FindSourceFile("opencode:"+duplicateID))
|
|
|
|
sqlite.replaceTextContent(
|
|
t, sessionID,
|
|
"updated by sync paths",
|
|
"updated sqlite answer",
|
|
timeCreated,
|
|
)
|
|
sqlite.updateSessionTime(t, sessionID, timeUpdated+1000)
|
|
env.engine.SyncPaths([]string{sqlite.path})
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+sessionID,
|
|
"updated by sync paths",
|
|
"updated sqlite answer",
|
|
)
|
|
|
|
sqlite.replaceTextContent(
|
|
t, sessionID,
|
|
"updated by single sync",
|
|
"updated sqlite answer again",
|
|
timeCreated,
|
|
)
|
|
sqlite.updateSessionTime(t, sessionID, timeUpdated+2000)
|
|
require.NoError(t, env.engine.SyncSingleSession("opencode:"+sessionID), "SyncSingleSession")
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+sessionID,
|
|
"updated by single sync",
|
|
"updated sqlite answer again",
|
|
)
|
|
|
|
sqlite.replaceTextContent(
|
|
t, duplicateID,
|
|
"newer stale sqlite question",
|
|
"newer stale sqlite answer",
|
|
timeCreated,
|
|
)
|
|
sqlite.updateSessionTime(t, duplicateID, duplicateUpdated+1000)
|
|
env.engine.SyncPaths([]string{sqlite.path})
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+duplicateID,
|
|
"canonical storage reply",
|
|
)
|
|
}
|
|
|
|
// TestOpenCodeHybridUnshadowedSQLiteSessionParsesAfterStorageRemoval pins
|
|
// the container gate against hybrid shadow removal: a SQLite row hidden by
|
|
// a same-ID storage JSON during the verified pass becomes discoverable when
|
|
// the storage copy is deleted, while the DB — and so its trusted state — is
|
|
// untouched. The newly exposed row must be parsed and take over the
|
|
// session, not gate-skipped as part of an unchanged container.
|
|
func TestOpenCodeHybridUnshadowedSQLiteSessionParsesAfterStorageRemoval(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
storage := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
const sessionID = "oc-unshadow"
|
|
sessionPath := storage.addSession(
|
|
t, "global", sessionID,
|
|
"/home/user/code/app", "Unshadow",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
storage.addMessage(
|
|
t, sessionID, "msg-s1", "assistant", 1704067201000, nil,
|
|
)
|
|
storage.addTextPart(
|
|
t, sessionID, "msg-s1", "part-s1",
|
|
"storage canonical reply", 1704067201000,
|
|
)
|
|
// The archived storage session's mtime is a composite over the storage
|
|
// tree's files and directories; backdate the whole tree so the SQLite
|
|
// copy's updated time is comparably newer.
|
|
storageMtime := time.UnixMilli(1704067205000)
|
|
require.NoError(t, filepath.WalkDir(
|
|
filepath.Join(env.opencodeDir, "storage"),
|
|
func(p string, _ fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.Chtimes(p, storageMtime, storageMtime)
|
|
},
|
|
), "backdate storage tree")
|
|
|
|
// The SQLite copy is newer than the storage copy from the start, so
|
|
// once exposed it must supersede the preserved storage archive; while
|
|
// the storage JSON exists, the same-ID shadow keeps the row out of
|
|
// discovery regardless of recency.
|
|
sqlite := createOpenCodeDB(t, env.opencodeDir)
|
|
sqlite.addProject(t, "proj-1", "/home/user/code/app")
|
|
sqlite.addSession(t, sessionID, "proj-1", 1704067200000, 1704067299000)
|
|
sqlite.addMessage(t, "msg-q1", sessionID, "user", 1704067200000)
|
|
sqlite.addTextPart(
|
|
t, "part-q1", sessionID, "msg-q1",
|
|
"sqlite only question", 1704067200000,
|
|
)
|
|
// A second, unshadowed row makes the verified pass discover the
|
|
// container at all — a fully shadowed container is never promoted, so
|
|
// only this row's presence puts the container's trust on the line.
|
|
const otherID = "oc-unshadow-other"
|
|
sqlite.addSession(t, otherID, "proj-1", 1704067200000, 1704067205000)
|
|
sqlite.addMessage(t, "msg-o1", otherID, "user", 1704067200000)
|
|
sqlite.addTextPart(
|
|
t, "part-o1", otherID, "msg-o1",
|
|
"other sqlite question", 1704067200000,
|
|
)
|
|
|
|
// The verified pass sees the storage copy and the unshadowed SQLite
|
|
// row; the shadowed row stays out of discovery, and the container is
|
|
// promoted to trusted with only the unshadowed row verified.
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 2,
|
|
Synced: 2,
|
|
})
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+sessionID, "storage canonical reply",
|
|
)
|
|
|
|
// Removing the storage copy exposes the SQLite row without touching
|
|
// the DB, so the container still matches its trusted state.
|
|
require.NoError(t, os.Remove(sessionPath), "remove session json")
|
|
require.NoError(t, os.RemoveAll(filepath.Join(
|
|
env.opencodeDir, "storage", "message", sessionID,
|
|
)), "remove message dir")
|
|
require.NoError(t, os.RemoveAll(filepath.Join(
|
|
env.opencodeDir, "storage", "part", "msg-s1",
|
|
)), "remove part dir")
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 2,
|
|
Synced: 1,
|
|
Skipped: 1,
|
|
})
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+sessionID, "sqlite only question",
|
|
)
|
|
virtualPath := parser.OpenCodeSQLiteVirtualPath(sqlite.path, sessionID)
|
|
assert.Equal(t, virtualPath,
|
|
env.engine.FindSourceFile("opencode:"+sessionID),
|
|
"the session must now resolve to the SQLite source")
|
|
}
|
|
|
|
// TestOpenCodeHybridFullyShadowedContainerDropsStaleTrust pins the
|
|
// shadow-then-unshadow cycle with no other rows: a verified SQLite-only
|
|
// session gets shadowed by a later storage JSON, so the next full sync
|
|
// discovers no SQLite sources at all for the container. That pass must
|
|
// drop the container's trusted entry — otherwise the stale session set
|
|
// still matches once the storage JSON is removed again (the DB never
|
|
// changed), and the re-exposed row is gate-skipped instead of parsed.
|
|
func TestOpenCodeHybridFullyShadowedContainerDropsStaleTrust(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
const sessionID = "oc-reshadow"
|
|
sqlite := createOpenCodeDB(t, env.opencodeDir)
|
|
sqlite.addProject(t, "proj-1", "/home/user/code/app")
|
|
sqlite.addSession(t, sessionID, "proj-1", 1704067200000, 1704067299000)
|
|
sqlite.addMessage(t, "msg-q1", sessionID, "user", 1704067200000)
|
|
sqlite.addTextPart(
|
|
t, "part-q1", sessionID, "msg-q1",
|
|
"sqlite canonical question", 1704067200000,
|
|
)
|
|
|
|
// The verified pass sees only the SQLite row and trusts the container.
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
})
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+sessionID, "sqlite canonical question",
|
|
)
|
|
|
|
// A storage JSON appears for the same session and shadows the row;
|
|
// this full sync discovers no SQLite sources for the container.
|
|
storage := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
sessionPath := storage.addSession(
|
|
t, "global", sessionID,
|
|
"/home/user/code/app", "Reshadow",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
storage.addMessage(
|
|
t, sessionID, "msg-s1", "assistant", 1704067201000, nil,
|
|
)
|
|
storage.addTextPart(
|
|
t, sessionID, "msg-s1", "part-s1",
|
|
"storage interim reply", 1704067201000,
|
|
)
|
|
// Backdate the storage tree so the SQLite copy stays comparably newer
|
|
// (the archived session mtime is a composite over files and dirs).
|
|
storageMtime := time.UnixMilli(1704067205000)
|
|
require.NoError(t, filepath.WalkDir(
|
|
filepath.Join(env.opencodeDir, "storage"),
|
|
func(p string, _ fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.Chtimes(p, storageMtime, storageMtime)
|
|
},
|
|
), "backdate storage tree")
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
})
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+sessionID, "storage interim reply",
|
|
)
|
|
|
|
// The storage copy disappears again; the DB never changed, so a stale
|
|
// trusted entry from the first pass would still match and gate-skip
|
|
// the re-exposed, newer row.
|
|
require.NoError(t, os.Remove(sessionPath), "remove session json")
|
|
require.NoError(t, os.RemoveAll(filepath.Join(
|
|
env.opencodeDir, "storage", "message", sessionID,
|
|
)), "remove message dir")
|
|
require.NoError(t, os.RemoveAll(filepath.Join(
|
|
env.opencodeDir, "storage", "part", "msg-s1",
|
|
)), "remove part dir")
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
})
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+sessionID, "sqlite canonical question",
|
|
)
|
|
}
|
|
|
|
// TestOpenCodeHybridWatcherShadowCycleDropsRowTrust pins the watcher-path
|
|
// shadow/unshadow cycle: a storage JSON for a trusted SQLite row arrives
|
|
// via a changed-path sync (which replaces the archived row but never
|
|
// re-promotes containers) and disappears again before any full pass
|
|
// observes the shadowed state. Processing the storage session must drop
|
|
// the row's trusted membership, or the next full pass — container state
|
|
// unchanged, membership still verified — would gate-skip the re-exposed
|
|
// row forever and the archive would keep the interim storage copy.
|
|
func TestOpenCodeHybridWatcherShadowCycleDropsRowTrust(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
const sessionID = "oc-watcher-shadow"
|
|
sqlite := createOpenCodeDB(t, env.opencodeDir)
|
|
sqlite.addProject(t, "proj-1", "/home/user/code/app")
|
|
sqlite.addSession(t, sessionID, "proj-1", 1704067200000, 1704067299000)
|
|
sqlite.addMessage(t, "msg-q1", sessionID, "user", 1704067200000)
|
|
sqlite.addTextPart(
|
|
t, "part-q1", sessionID, "msg-q1",
|
|
"sqlite canonical question", 1704067200000,
|
|
)
|
|
|
|
// The verified pass trusts the container with the row's membership.
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
})
|
|
|
|
// A storage JSON for the same session arrives and is synced through
|
|
// the watcher's changed-path pass, replacing the archived row.
|
|
storage := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
sessionPath := storage.addSession(
|
|
t, "global", sessionID,
|
|
"/home/user/code/app", "Watcher Shadow",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
storage.addMessage(
|
|
t, sessionID, "msg-s1", "assistant", 1704067201000, nil,
|
|
)
|
|
storage.addTextPart(
|
|
t, sessionID, "msg-s1", "part-s1",
|
|
"storage interim reply", 1704067201000,
|
|
)
|
|
storageMtime := time.UnixMilli(1704067205000)
|
|
require.NoError(t, filepath.WalkDir(
|
|
filepath.Join(env.opencodeDir, "storage"),
|
|
func(p string, _ fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.Chtimes(p, storageMtime, storageMtime)
|
|
},
|
|
), "backdate storage tree")
|
|
env.engine.SyncPaths([]string{sessionPath})
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+sessionID, "storage interim reply",
|
|
)
|
|
|
|
// The storage copy disappears again without the watcher observing it
|
|
// and without any full pass having seen the shadowed state; the DB
|
|
// never changed.
|
|
require.NoError(t, os.Remove(sessionPath), "remove session json")
|
|
require.NoError(t, os.RemoveAll(filepath.Join(
|
|
env.opencodeDir, "storage", "message", sessionID,
|
|
)), "remove message dir")
|
|
require.NoError(t, os.RemoveAll(filepath.Join(
|
|
env.opencodeDir, "storage", "part", "msg-s1",
|
|
)), "remove part dir")
|
|
|
|
// The full pass must re-verify the re-exposed row: it is newer than
|
|
// the interim storage archive and takes the session back.
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
})
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+sessionID, "sqlite canonical question",
|
|
)
|
|
}
|
|
|
|
// TestFindSourceFileSkipsHybridRootMissingSession covers the
|
|
// multi-root shadowing case: an early hybrid root with an
|
|
// opencode.db that lacks the requested session must not shadow a
|
|
// later pure-storage root that contains it. Without the
|
|
// session-existence gate in the OpenCode-format source lookup, the
|
|
// engine would return a virtual SQLite path pointing at the wrong DB.
|
|
func TestFindSourceFileSkipsHybridRootMissingSession(t *testing.T) {
|
|
hybridRoot := t.TempDir()
|
|
storageRoot := t.TempDir()
|
|
err := os.MkdirAll(
|
|
filepath.Join(hybridRoot, "storage", "session", "global"),
|
|
0o755,
|
|
)
|
|
require.NoError(t, err, "mkdir hybrid storage")
|
|
err = os.MkdirAll(
|
|
filepath.Join(storageRoot, "storage", "session", "global"),
|
|
0o755,
|
|
)
|
|
require.NoError(t, err, "mkdir storage root")
|
|
|
|
hybridDB := createOpenCodeDB(t, hybridRoot)
|
|
hybridDB.addProject(t, "proj-x", "/tmp/x")
|
|
hybridDB.addSession(
|
|
t, "oc-only-in-hybrid-db", "proj-x",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
|
|
const wantedID = "oc-real-in-storage"
|
|
storage := createOpenCodeStorageFixture(t, storageRoot)
|
|
storage.addSession(
|
|
t, "global", wantedID,
|
|
"/home/user/code/realapp", "Real Storage",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
storage.addMessage(
|
|
t, wantedID, "msg-a1", "assistant",
|
|
1704067201000, nil,
|
|
)
|
|
storage.addTextPart(
|
|
t, wantedID, "msg-a1", "part-a1",
|
|
"real storage reply", 1704067201000,
|
|
)
|
|
|
|
env := setupTestEnv(
|
|
t,
|
|
WithOpenCodeDirs([]string{hybridRoot, storageRoot}),
|
|
)
|
|
wantPath := filepath.Join(
|
|
storageRoot, "storage", "session", "global",
|
|
wantedID+".json",
|
|
)
|
|
require.Equal(t, wantPath, env.engine.FindSourceFile("opencode:"+wantedID), "FindSourceFile() = ..., want %q (hybrid root must not shadow)", wantPath)
|
|
}
|
|
|
|
func TestKiloStorageRewriteReplacesMessages(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
storage := createOpenCodeStorageFixture(t, env.kiloDir)
|
|
const sessionID = "kilo-storage-rewrite"
|
|
storage.addSession(
|
|
t, "global", sessionID,
|
|
"/home/user/code/kilo-app", "Kilo Rewrite",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
storage.addMessage(
|
|
t, sessionID, "msg-a1", "assistant",
|
|
1704067201000, nil,
|
|
)
|
|
partPath := storage.addTextPart(
|
|
t, sessionID, "msg-a1", "part-a1",
|
|
"initial kilo reply", 1704067201000,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
})
|
|
assertMessageContent(
|
|
t, env.db, "kilo:"+sessionID, "initial kilo reply",
|
|
)
|
|
|
|
storage.addTextPart(
|
|
t, sessionID, "msg-a1", "part-a1",
|
|
"rewritten kilo reply", 1704067201000,
|
|
)
|
|
changed := time.Unix(1804067200, 0)
|
|
require.NoError(t, os.Chtimes(partPath, changed, changed))
|
|
env.engine.SyncPaths([]string{partPath})
|
|
|
|
assertMessageContent(
|
|
t, env.db, "kilo:"+sessionID, "rewritten kilo reply",
|
|
)
|
|
}
|
|
|
|
// TestMiMoCodeSessionDiffStorageWatcherEvents drives MiMoCode indexing
|
|
// purely through SyncPaths watcher events so the path classifier is the
|
|
// only route into the DB. The session JSON lives under
|
|
// storage/session_diff, so classification must use the resolved session
|
|
// subdir rather than the default storage/session. The follow-up part
|
|
// rewrite exercises the message/part classification branch, which
|
|
// resolves the session file under session_diff to advance the message.
|
|
func TestMiMoCodeSessionDiffStorageWatcherEvents(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentMiMoCode)
|
|
storage := createMiMoCodeStorageFixture(t, env.mimocodeDir)
|
|
const sessionID = "mimo-storage-watch"
|
|
sessionPath := storage.addSession(
|
|
t, "global", sessionID,
|
|
"/home/user/code/mimo-app", "MiMoCode Watch",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
require.Contains(t, sessionPath,
|
|
filepath.Join("storage", "session_diff"),
|
|
"session JSON must live under storage/session_diff")
|
|
storage.addMessage(
|
|
t, sessionID, "msg-a1", "assistant",
|
|
1704067201000, nil,
|
|
)
|
|
partPath := storage.addTextPart(
|
|
t, sessionID, "msg-a1", "part-a1",
|
|
"initial mimo reply", 1704067201000,
|
|
)
|
|
|
|
// A session-JSON create event under storage/session_diff must
|
|
// classify and index the session without any prior full sync.
|
|
env.engine.SyncPaths([]string{sessionPath})
|
|
assertSessionProject(t, env.db, "mimocode:"+sessionID, "mimo_app")
|
|
assertMessageContent(
|
|
t, env.db, "mimocode:"+sessionID, "initial mimo reply",
|
|
)
|
|
|
|
storage.addTextPart(
|
|
t, sessionID, "msg-a1", "part-a1",
|
|
"rewritten mimo reply", 1704067201000,
|
|
)
|
|
changed := time.Unix(1804067200, 0)
|
|
require.NoError(t, os.Chtimes(partPath, changed, changed))
|
|
env.engine.SyncPaths([]string{partPath})
|
|
|
|
assertMessageContent(
|
|
t, env.db, "mimocode:"+sessionID, "rewritten mimo reply",
|
|
)
|
|
}
|
|
|
|
// TestSyncPathsMiMoCodeStorageIgnoresStaleSessionSkipCache covers the
|
|
// shouldCacheSkip change: a MiMoCode session JSON under
|
|
// storage/session_diff must never be skip-cached by its own mtime,
|
|
// because its content depends on message/part files that change
|
|
// independently. With the session subdir hard-coded to storage/session,
|
|
// shouldCacheSkip would treat the session_diff file as cacheable, and a
|
|
// stale skip-cache entry at an unchanged session mtime would suppress a
|
|
// child-driven update.
|
|
func TestSyncPathsMiMoCodeStorageIgnoresStaleSessionSkipCache(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentMiMoCode)
|
|
storage := createMiMoCodeStorageFixture(t, env.mimocodeDir)
|
|
const sessionID = "mimo-storage-skip-cache"
|
|
sessionPath := storage.addSession(
|
|
t, "global", sessionID,
|
|
"/home/user/code/mimo-app", "MiMoCode Skip Cache",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
storage.addMessage(
|
|
t, sessionID, "msg-a1", "assistant",
|
|
1704067201000, nil,
|
|
)
|
|
partPath := storage.addTextPart(
|
|
t, sessionID, "msg-a1", "part-a1",
|
|
"initial mimo reply", 1704067201000,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
})
|
|
assertMessageContent(
|
|
t, env.db, "mimocode:"+sessionID, "initial mimo reply",
|
|
)
|
|
|
|
// Seed the skip cache for the session file at its current mtime.
|
|
info, err := os.Stat(sessionPath)
|
|
require.NoError(t, err, "stat session path")
|
|
sessionMtime := info.ModTime()
|
|
env.engine.InjectSkipCache(map[string]int64{
|
|
sessionPath: sessionMtime.UnixNano(),
|
|
})
|
|
|
|
// Rewrite the part while holding the session JSON mtime constant,
|
|
// so a stale skip-cache entry keyed on the session path would
|
|
// suppress the update if session files were treated as cacheable.
|
|
storage.addTextPart(
|
|
t, sessionID, "msg-a1", "part-a1",
|
|
"rewritten mimo reply", 1704067201000,
|
|
)
|
|
require.NoError(t,
|
|
os.Chtimes(sessionPath, sessionMtime, sessionMtime),
|
|
"restore session mtime")
|
|
|
|
env.engine.SyncPaths([]string{partPath})
|
|
|
|
assertMessageContent(
|
|
t, env.db, "mimocode:"+sessionID, "rewritten mimo reply",
|
|
)
|
|
}
|
|
|
|
func TestKiloPreservesStorageArchiveAgainstSQLiteFallback(t *testing.T) {
|
|
|
|
env := setupSingleAgentTestEnv(t, parser.AgentKilo)
|
|
storage := createOpenCodeStorageFixture(t, env.kiloDir)
|
|
const sessionID = "kilo-hybrid-preserve"
|
|
storage.addSession(
|
|
t, "global", sessionID,
|
|
"/home/user/code/kilo-app", "Kilo Preserve",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
storage.addMessage(
|
|
t, sessionID, "msg-storage-a1", "assistant",
|
|
1704067201000, nil,
|
|
)
|
|
storage.addTextPart(
|
|
t, sessionID, "msg-storage-a1", "part-storage-a1",
|
|
"canonical kilo storage reply", 1704067201000,
|
|
)
|
|
|
|
sqlite := createKiloDB(t, env.kiloDir)
|
|
sqlite.addProject(t, "proj-1", "/home/user/code/kilo-app")
|
|
sqlite.addSession(
|
|
t, sessionID, "proj-1",
|
|
1704067200000, 1704067201000,
|
|
)
|
|
sqlite.addMessage(
|
|
t, "sqlite-msg-a1", sessionID, "assistant",
|
|
1704067201000,
|
|
)
|
|
sqlite.addTextPart(
|
|
t, "sqlite-part-a1", sessionID, "sqlite-msg-a1",
|
|
"older sqlite fallback reply", 1704067201000,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
})
|
|
assertMessageContent(
|
|
t, env.db, "kilo:"+sessionID,
|
|
"canonical kilo storage reply",
|
|
)
|
|
|
|
storageSessionPath := filepath.Join(
|
|
env.kiloDir, "storage", "session", "global",
|
|
sessionID+".json",
|
|
)
|
|
require.NoError(t, os.Remove(storageSessionPath))
|
|
env.engine.SyncPaths([]string{sqlite.path})
|
|
|
|
assertMessageContent(
|
|
t, env.db, "kilo:"+sessionID,
|
|
"canonical kilo storage reply",
|
|
)
|
|
}
|
|
|
|
func TestSyncAllSinceOpenCodeStoragePicksUpUsagePartUpdate(t *testing.T) {
|
|
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
oc := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
|
|
sessionPath := oc.addSession(
|
|
t, "global", "oc-since-usage-part",
|
|
"/home/user/code/myapp", "Since Usage Part",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
oc.addMessage(
|
|
t, "oc-since-usage-part", "msg-a1", "assistant",
|
|
1704067201000, map[string]any{
|
|
"modelID": "Gemini 3.5 Flash (High)",
|
|
},
|
|
)
|
|
oc.addTextPart(
|
|
t, "oc-since-usage-part", "msg-a1", "part-a1",
|
|
"initial reply", 1704067201000,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
|
|
cutoff := time.Now()
|
|
info, err := os.Stat(sessionPath)
|
|
require.NoError(t, err, "stat session path")
|
|
sessionMtime := info.ModTime()
|
|
future := cutoff.Add(2 * time.Second)
|
|
|
|
usagePartPath := oc.writeJSON(t, filepath.Join(
|
|
env.opencodeDir, "storage", "part", "msg-a1", "part-finish.json",
|
|
), map[string]any{
|
|
"id": "part-finish",
|
|
"sessionID": "oc-since-usage-part",
|
|
"messageID": "msg-a1",
|
|
"type": "step-finish",
|
|
"tokens": map[string]any{
|
|
"input": 123,
|
|
"output": 45,
|
|
"cache": map[string]any{
|
|
"read": 67,
|
|
"write": 89,
|
|
},
|
|
},
|
|
"time": map[string]any{
|
|
"created": int64(1704067201000),
|
|
},
|
|
})
|
|
require.NoError(t, os.Chtimes(usagePartPath, future, future), "chtimes usage part")
|
|
require.NoError(t, os.Chtimes(sessionPath, sessionMtime, sessionMtime), "restore session mtime")
|
|
|
|
// Composite freshness includes the part file, so the part-only edit is
|
|
// fresh relative to the cutoff and re-syncs the updated reply.
|
|
stats := env.engine.SyncAllSince(context.Background(), cutoff, nil)
|
|
require.Equal(t, 1, stats.Synced, "SyncAllSince synced = %d, want 1", stats.Synced)
|
|
|
|
daily, err := env.db.GetDailyUsage(context.Background(), db.UsageFilter{
|
|
From: "2024-01-01",
|
|
To: "2024-01-01",
|
|
Timezone: "UTC",
|
|
})
|
|
require.NoError(t, err, "GetDailyUsage")
|
|
require.Len(t, daily.Daily, 1, "daily rows")
|
|
assert.Equal(t, 123, daily.Totals.InputTokens)
|
|
assert.Equal(t, 45, daily.Totals.OutputTokens)
|
|
assert.Equal(t, 67, daily.Totals.CacheReadTokens)
|
|
assert.Equal(t, 89, daily.Totals.CacheCreationTokens)
|
|
assert.Equal(t, []string{"Gemini 3.5 Flash (High)"}, daily.Daily[0].ModelsUsed)
|
|
}
|
|
|
|
// TestSyncAllOpenCodeStorageReparsesUnchangedSessionsIdempotently covers a
|
|
// re-sync of an unchanged OpenCode storage session. OpenCode is
|
|
// provider-authoritative, so a full SyncAll re-parses the source through
|
|
// the provider facade rather than taking the legacy DB-mtime skip; the
|
|
// re-parse must be idempotent and keep the same content.
|
|
func TestSyncAllOpenCodeStorageReparsesUnchangedSessionsIdempotently(t *testing.T) {
|
|
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
oc := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
|
|
oc.addSession(
|
|
t, "global", "oc-skip-unchanged",
|
|
"/home/user/code/myapp", "Skip Unchanged",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
oc.addMessage(
|
|
t, "oc-skip-unchanged", "msg-a1", "assistant",
|
|
1704067201000, nil,
|
|
)
|
|
oc.addTextPart(
|
|
t, "oc-skip-unchanged", "msg-a1", "part-a1",
|
|
"stable reply", 1704067201000,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
|
|
stats := env.engine.SyncAll(context.Background(), nil)
|
|
require.Equal(t, 1, stats.TotalSessions, "SyncAll stats = %+v", stats)
|
|
require.Equal(t, 0, stats.Failed, "SyncAll stats = %+v", stats)
|
|
assertMessageContent(
|
|
t, env.db, "opencode:oc-skip-unchanged", "stable reply",
|
|
)
|
|
}
|
|
|
|
func TestSyncAllOpenCodeStorageMissingMessagePreservesArchive(t *testing.T) {
|
|
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
oc := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
|
|
sessionPath := oc.addSession(
|
|
t, "global", "oc-missing-message",
|
|
"/home/user/code/myapp", "Missing Message",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
oc.addMessage(
|
|
t, "oc-missing-message", "msg-u1", "user",
|
|
1704067200000, nil,
|
|
)
|
|
oc.addTextPart(
|
|
t, "oc-missing-message", "msg-u1", "part-u1",
|
|
"question", 1704067200000,
|
|
)
|
|
messagePath := oc.addMessage(
|
|
t, "oc-missing-message", "msg-a1", "assistant",
|
|
1704067201000, nil,
|
|
)
|
|
oc.addTextPart(
|
|
t, "oc-missing-message", "msg-a1", "part-a1",
|
|
"answer", 1704067201000,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
|
|
require.NoError(t, os.Remove(messagePath), "remove message file")
|
|
future := time.Now().Add(2 * time.Second)
|
|
require.NoError(t, os.Chtimes(sessionPath, future, future), "touch session path")
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
assertMessageContent(
|
|
t, env.db, "opencode:oc-missing-message",
|
|
"question", "answer",
|
|
)
|
|
}
|
|
|
|
func TestSyncAllOpenCodeStoragePreservesLegacySQLiteArchive(
|
|
t *testing.T,
|
|
) {
|
|
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
sqlite := createOpenCodeDB(t, env.opencodeDir)
|
|
sqlite.addProject(t, "proj-1", "/home/user/code/myapp")
|
|
|
|
sessionID := "oc-storage-upgrade-legacy"
|
|
timeCreated := int64(1704067200000)
|
|
timeUpdated := int64(1704067205000)
|
|
|
|
sqlite.addSession(
|
|
t, sessionID, "proj-1",
|
|
timeCreated, timeUpdated,
|
|
)
|
|
sqlite.addMessage(
|
|
t, "msg-u1", sessionID, "user", timeCreated,
|
|
)
|
|
sqlite.addMessage(
|
|
t, "msg-a1", sessionID, "assistant", timeCreated+1,
|
|
)
|
|
sqlite.addTextPart(
|
|
t, "part-u1", sessionID, "msg-u1",
|
|
"legacy sqlite question", timeCreated,
|
|
)
|
|
sqlite.addTextPart(
|
|
t, "part-a1", sessionID, "msg-a1",
|
|
"legacy sqlite answer", timeCreated+1,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
|
|
storage := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
storage.addSession(
|
|
t, "global", sessionID,
|
|
"/home/user/code/myapp", "Storage Upgrade",
|
|
timeCreated, timeUpdated+1000,
|
|
)
|
|
storage.addMessage(
|
|
t, sessionID, "msg-u1", "user",
|
|
timeCreated, nil,
|
|
)
|
|
storage.addTextPart(
|
|
t, sessionID, "msg-u1", "part-u1",
|
|
"legacy sqlite question", timeCreated,
|
|
)
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+sessionID,
|
|
"legacy sqlite question",
|
|
"legacy sqlite answer",
|
|
)
|
|
}
|
|
|
|
func TestSyncAllOpenCodeStorageMissingPartDirPreservesArchive(t *testing.T) {
|
|
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
oc := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
|
|
sessionPath := oc.addSession(
|
|
t, "global", "oc-missing-part",
|
|
"/home/user/code/myapp", "Missing Part",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
oc.addMessage(
|
|
t, "oc-missing-part", "msg-u1", "user",
|
|
1704067200000, nil,
|
|
)
|
|
partPath := oc.addTextPart(
|
|
t, "oc-missing-part", "msg-u1", "part-u1",
|
|
"question", 1704067200000,
|
|
)
|
|
oc.addMessage(
|
|
t, "oc-missing-part", "msg-a1", "assistant",
|
|
1704067201000, nil,
|
|
)
|
|
oc.addTextPart(
|
|
t, "oc-missing-part", "msg-a1", "part-a1",
|
|
"answer", 1704067201000,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
|
|
require.NoError(t, os.RemoveAll(filepath.Dir(partPath)), "remove part dir")
|
|
future := time.Now().Add(2 * time.Second)
|
|
require.NoError(t, os.Chtimes(sessionPath, future, future), "touch session path")
|
|
|
|
stats := env.engine.SyncAll(context.Background(), nil)
|
|
require.Equal(t, 0, stats.Failed, "stats.Failed = %d, want 0", stats.Failed)
|
|
require.Equal(t, 0, stats.Synced, "stats.Synced = %d, want 0", stats.Synced)
|
|
|
|
assertMessageContent(
|
|
t, env.db, "opencode:oc-missing-part",
|
|
"question", "answer",
|
|
)
|
|
}
|
|
|
|
func TestSyncSingleSessionOpenCodeStorageMissingMessagePreservesArchive(
|
|
t *testing.T,
|
|
) {
|
|
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
oc := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
|
|
sessionID := "oc-missing-message-single"
|
|
sessionPath := oc.addSession(
|
|
t, "global", sessionID,
|
|
"/home/user/code/myapp", "Missing Message Single",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
oc.addMessage(
|
|
t, sessionID, "msg-u1", "user",
|
|
1704067200000, nil,
|
|
)
|
|
oc.addTextPart(
|
|
t, sessionID, "msg-u1", "part-u1",
|
|
"question", 1704067200000,
|
|
)
|
|
messagePath := oc.addMessage(
|
|
t, sessionID, "msg-a1", "assistant",
|
|
1704067201000, nil,
|
|
)
|
|
oc.addTextPart(
|
|
t, sessionID, "msg-a1", "part-a1",
|
|
"answer", 1704067201000,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
|
|
require.NoError(t, os.Remove(messagePath), "remove message file")
|
|
future := time.Now().Add(2 * time.Second)
|
|
require.NoError(t, os.Chtimes(sessionPath, future, future), "touch session path")
|
|
|
|
err := env.engine.SyncSingleSession(
|
|
"opencode:" + sessionID,
|
|
)
|
|
require.NoError(t, err, "SyncSingleSession")
|
|
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+sessionID,
|
|
"question", "answer",
|
|
)
|
|
}
|
|
|
|
func TestSyncSingleSessionOpenCodeStoragePreservedUpdateDoesNotEmit(
|
|
t *testing.T,
|
|
) {
|
|
em := &fakeEmitter{}
|
|
env := setupTestEnv(t, WithEmitter(em))
|
|
oc := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
|
|
sessionID := "oc-missing-message-no-emit"
|
|
sessionPath := oc.addSession(
|
|
t, "global", sessionID,
|
|
"/home/user/code/myapp", "Missing Message No Emit",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
oc.addMessage(
|
|
t, sessionID, "msg-u1", "user",
|
|
1704067200000, nil,
|
|
)
|
|
oc.addTextPart(
|
|
t, sessionID, "msg-u1", "part-u1",
|
|
"question", 1704067200000,
|
|
)
|
|
messagePath := oc.addMessage(
|
|
t, sessionID, "msg-a1", "assistant",
|
|
1704067201000, nil,
|
|
)
|
|
oc.addTextPart(
|
|
t, sessionID, "msg-a1", "part-a1",
|
|
"answer", 1704067201000,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
|
|
em.mu.Lock()
|
|
em.scopes = em.scopes[:0]
|
|
em.mu.Unlock()
|
|
|
|
require.NoError(t, os.Remove(messagePath), "remove message file")
|
|
future := time.Now().Add(2 * time.Second)
|
|
require.NoError(t, os.Chtimes(sessionPath, future, future), "touch session path")
|
|
|
|
err := env.engine.SyncSingleSession(
|
|
"opencode:" + sessionID,
|
|
)
|
|
require.NoError(t, err, "SyncSingleSession")
|
|
|
|
assert.Empty(t, em.got(), "expected no emissions for preserved update")
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+sessionID,
|
|
"question", "answer",
|
|
)
|
|
}
|
|
|
|
func TestSyncPathsOpenCodeStorageMissingMessagePreservesArchive(
|
|
t *testing.T,
|
|
) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
oc := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
|
|
sessionID := "oc-missing-message-paths"
|
|
oc.addSession(
|
|
t, "global", sessionID,
|
|
"/home/user/code/myapp", "Missing Message Paths",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
oc.addMessage(
|
|
t, sessionID, "msg-u1", "user",
|
|
1704067200000, nil,
|
|
)
|
|
oc.addTextPart(
|
|
t, sessionID, "msg-u1", "part-u1",
|
|
"question", 1704067200000,
|
|
)
|
|
messagePath := oc.addMessage(
|
|
t, sessionID, "msg-a1", "assistant",
|
|
1704067201000, nil,
|
|
)
|
|
oc.addTextPart(
|
|
t, sessionID, "msg-a1", "part-a1",
|
|
"answer", 1704067201000,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
|
|
require.NoError(t, os.Remove(messagePath), "remove message file")
|
|
|
|
env.engine.SyncPaths([]string{messagePath})
|
|
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+sessionID,
|
|
"question", "answer",
|
|
)
|
|
}
|
|
|
|
func TestSyncPathsOpenCodeStoragePreservedUpdateDoesNotEmitOrCountSynced(
|
|
t *testing.T,
|
|
) {
|
|
em := &fakeEmitter{}
|
|
env := setupTestEnv(t, WithEmitter(em))
|
|
oc := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
|
|
sessionID := "oc-missing-message-paths-no-emit"
|
|
oc.addSession(
|
|
t, "global", sessionID,
|
|
"/home/user/code/myapp", "Missing Message Paths No Emit",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
oc.addMessage(
|
|
t, sessionID, "msg-u1", "user",
|
|
1704067200000, nil,
|
|
)
|
|
oc.addTextPart(
|
|
t, sessionID, "msg-u1", "part-u1",
|
|
"question", 1704067200000,
|
|
)
|
|
messagePath := oc.addMessage(
|
|
t, sessionID, "msg-a1", "assistant",
|
|
1704067201000, nil,
|
|
)
|
|
oc.addTextPart(
|
|
t, sessionID, "msg-a1", "part-a1",
|
|
"answer", 1704067201000,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
|
|
em.mu.Lock()
|
|
em.scopes = em.scopes[:0]
|
|
em.mu.Unlock()
|
|
|
|
require.NoError(t, os.Remove(messagePath), "remove message file")
|
|
|
|
env.engine.SyncPaths([]string{messagePath})
|
|
|
|
assert.Empty(t, em.got(), "expected no emissions for preserved SyncPaths update")
|
|
stats := env.engine.LastSyncStats()
|
|
require.Equal(t, 0, stats.Synced, "LastSyncStats().Synced = %d, want 0", stats.Synced)
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+sessionID,
|
|
"question", "answer",
|
|
)
|
|
}
|
|
|
|
func TestSyncPathsOpenCodeStorageMissingPartDirPreservesArchive(
|
|
t *testing.T,
|
|
) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
oc := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
|
|
sessionID := "oc-missing-part-paths"
|
|
oc.addSession(
|
|
t, "global", sessionID,
|
|
"/home/user/code/myapp", "Missing Part Paths",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
oc.addMessage(
|
|
t, sessionID, "msg-u1", "user",
|
|
1704067200000, nil,
|
|
)
|
|
oc.addTextPart(
|
|
t, sessionID, "msg-u1", "part-u1",
|
|
"question", 1704067200000,
|
|
)
|
|
messagePath := oc.addMessage(
|
|
t, sessionID, "msg-a1", "assistant",
|
|
1704067201000, nil,
|
|
)
|
|
partPath := oc.addTextPart(
|
|
t, sessionID, "msg-a1", "part-a1",
|
|
"answer", 1704067201000,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
|
|
require.NoError(t, os.RemoveAll(filepath.Dir(partPath)), "remove part dir")
|
|
|
|
env.engine.SyncPaths([]string{messagePath})
|
|
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+sessionID,
|
|
"question", "answer",
|
|
)
|
|
}
|
|
|
|
func TestSyncSingleSessionOpenCodeStorageMissingPartPreservesArchive(
|
|
t *testing.T,
|
|
) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
oc := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
|
|
sessionID := "oc-missing-part-single"
|
|
sessionPath := oc.addSession(
|
|
t, "global", sessionID,
|
|
"/home/user/code/myapp", "Missing Part Single",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
oc.addMessage(
|
|
t, sessionID, "msg-a1", "assistant",
|
|
1704067201000, nil,
|
|
)
|
|
part1Path := oc.addTextPart(
|
|
t, sessionID, "msg-a1", "part-a1",
|
|
"first part", 1704067201000,
|
|
)
|
|
oc.addTextPart(
|
|
t, sessionID, "msg-a1", "part-a2",
|
|
"second part", 1704067201001,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
|
|
require.NoError(t, os.Remove(part1Path), "remove part file")
|
|
future := time.Now().Add(2 * time.Second)
|
|
require.NoError(t, os.Chtimes(sessionPath, future, future), "touch session path")
|
|
|
|
err := env.engine.SyncSingleSession(
|
|
"opencode:" + sessionID,
|
|
)
|
|
require.NoError(t, err, "SyncSingleSession")
|
|
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+sessionID,
|
|
"first part\nsecond part",
|
|
)
|
|
}
|
|
|
|
func TestSyncAllOpenCodeStorageContentRewritePreservesArchive(
|
|
t *testing.T,
|
|
) {
|
|
env := setupTestEnv(t)
|
|
oc := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
|
|
sessionID := "oc-content-rewrite"
|
|
sessionPath := oc.addSession(
|
|
t, "global", sessionID,
|
|
"/home/user/code/myapp", "Content Rewrite",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
oc.addMessage(
|
|
t, sessionID, "msg-u1", "user",
|
|
1704067200000, nil,
|
|
)
|
|
partPath := oc.addTextPart(
|
|
t, sessionID, "msg-u1", "part-u1",
|
|
"complete response", 1704067200000,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
|
|
dbtest.WriteTestFile(t, partPath, []byte(
|
|
`{"id":"part-u1","sessionID":"`+sessionID+`","messageID":"msg-u1","type":"text","text":"cut","time":{"created":1704067200000}}`,
|
|
))
|
|
future := time.Now().Add(2 * time.Second)
|
|
require.NoError(t, os.Chtimes(sessionPath, future, future), "touch session path")
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+sessionID,
|
|
"complete response",
|
|
)
|
|
}
|
|
|
|
func TestSyncAllOpenCodeStorageMissingStepFinishPreservesTokens(
|
|
t *testing.T,
|
|
) {
|
|
env := setupTestEnv(t)
|
|
oc := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
|
|
sessionID := "oc-missing-step-finish"
|
|
sessionPath := oc.addSession(
|
|
t, "global", sessionID,
|
|
"/home/user/code/myapp", "Missing Step Finish",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
oc.addMessage(
|
|
t, sessionID, "msg-a1", "assistant",
|
|
1704067201000, map[string]any{
|
|
"modelID": "gpt-5.2-codex",
|
|
},
|
|
)
|
|
oc.addTextPart(
|
|
t, sessionID, "msg-a1", "part-a1",
|
|
"answer", 1704067201000,
|
|
)
|
|
stepFinishPath := oc.writeJSON(t, filepath.Join(
|
|
env.opencodeDir, "storage", "part", "msg-a1", "part-a2.json",
|
|
), map[string]any{
|
|
"id": "part-a2",
|
|
"sessionID": sessionID,
|
|
"messageID": "msg-a1",
|
|
"type": "step-finish",
|
|
"tokens": map[string]any{
|
|
"input": 300,
|
|
"output": 200,
|
|
},
|
|
"time": map[string]any{
|
|
"created": 1704067201001,
|
|
},
|
|
})
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
|
|
require.NoError(t, os.Remove(stepFinishPath), "remove step-finish part")
|
|
future := time.Now().Add(2 * time.Second)
|
|
require.NoError(t, os.Chtimes(sessionPath, future, future), "touch session path")
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
full, err := env.db.GetSessionFull(
|
|
context.Background(), "opencode:"+sessionID,
|
|
)
|
|
require.NoError(t, err, "GetSessionFull")
|
|
require.NotNil(t, full, "session missing after preserve")
|
|
require.True(t, full.HasTotalOutputTokens, "session output tokens = (%v, %d), want (true, 200)", full.HasTotalOutputTokens, full.TotalOutputTokens)
|
|
require.Equal(t, 200, full.TotalOutputTokens, "session output tokens = (%v, %d), want (true, 200)", full.HasTotalOutputTokens, full.TotalOutputTokens)
|
|
require.True(t, full.HasPeakContextTokens, "session context tokens = (%v, %d), want (true, 300)", full.HasPeakContextTokens, full.PeakContextTokens)
|
|
require.Equal(t, 300, full.PeakContextTokens, "session context tokens = (%v, %d), want (true, 300)", full.HasPeakContextTokens, full.PeakContextTokens)
|
|
|
|
msgs := fetchMessages(t, env.db, "opencode:"+sessionID)
|
|
require.Len(t, msgs, 1, "len(msgs) = %d, want 1", len(msgs))
|
|
require.True(t, msgs[0].HasOutputTokens, "message output tokens = (%v, %d), want (true, 200)", msgs[0].HasOutputTokens, msgs[0].OutputTokens)
|
|
require.Equal(t, 200, msgs[0].OutputTokens, "message output tokens = (%v, %d), want (true, 200)", msgs[0].HasOutputTokens, msgs[0].OutputTokens)
|
|
require.True(t, msgs[0].HasContextTokens, "message context tokens = (%v, %d), want (true, 300)", msgs[0].HasContextTokens, msgs[0].ContextTokens)
|
|
require.Equal(t, 300, msgs[0].ContextTokens, "message context tokens = (%v, %d), want (true, 300)", msgs[0].HasContextTokens, msgs[0].ContextTokens)
|
|
}
|
|
|
|
// TestSyncEngineOpenCodeToolCallReplace verifies that tool
|
|
// call data is fully replaced during OpenCode bulk sync, not
|
|
// left stale from a previous sync.
|
|
func TestSyncEngineOpenCodeToolCallReplace(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
oc := createOpenCodeDB(t, env.opencodeDir)
|
|
oc.addProject(t, "proj-1", "/home/user/code/myapp")
|
|
|
|
sessionID := "oc-tool-sess"
|
|
var timeCreated int64 = 1704067200000
|
|
var timeUpdated int64 = 1704067205000
|
|
|
|
oc.addSession(
|
|
t, sessionID, "proj-1",
|
|
timeCreated, timeUpdated,
|
|
)
|
|
|
|
// Assistant message with a tool call.
|
|
oc.addMessage(
|
|
t, "msg-u1", sessionID, "user", timeCreated,
|
|
)
|
|
oc.addMessage(
|
|
t, "msg-a1", sessionID, "assistant",
|
|
timeCreated+1,
|
|
)
|
|
oc.addTextPart(
|
|
t, "part-u1", sessionID, "msg-u1",
|
|
"run ls", timeCreated,
|
|
)
|
|
oc.addToolPart(
|
|
t, "part-tool1", sessionID, "msg-a1",
|
|
"bash", "call-1", timeCreated+1,
|
|
)
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
agentviewID := "opencode:" + sessionID
|
|
assertToolCallCount(t, env.db, agentviewID, 1)
|
|
|
|
// Replace: remove tool call, add text instead.
|
|
oc.deleteMessages(t, sessionID)
|
|
oc.deleteParts(t, sessionID)
|
|
oc.addMessage(
|
|
t, "msg-u1-v2", sessionID, "user", timeCreated,
|
|
)
|
|
oc.addMessage(
|
|
t, "msg-a1-v2", sessionID, "assistant",
|
|
timeCreated+1,
|
|
)
|
|
oc.addTextPart(
|
|
t, "part-u1-v2", sessionID, "msg-u1-v2",
|
|
"run ls", timeCreated,
|
|
)
|
|
oc.addTextPart(
|
|
t, "part-a1-v2", sessionID, "msg-a1-v2",
|
|
"here are the files", timeCreated+1,
|
|
)
|
|
oc.updateSessionTime(t, sessionID, timeUpdated+1000)
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
assertMessageContent(
|
|
t, env.db, agentviewID,
|
|
"run ls", "here are the files",
|
|
)
|
|
assertToolCallCount(t, env.db, agentviewID, 0)
|
|
}
|
|
|
|
// TestSyncEngineConcurrentSerialization verifies that
|
|
// SyncAll and ResyncAll are serialized by syncMu.
|
|
//
|
|
// Strategy: SyncAll's progress callback blocks on a
|
|
// barrier channel, holding the mutex. A second goroutine
|
|
// launches ResyncAll and signals when it enters its own
|
|
// progress callback. If the mutex works, the second
|
|
// signal only arrives after the barrier is released.
|
|
func TestSyncEngineConcurrentSerialization(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
for i := range 3 {
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, fmt.Sprintf("msg %d", i)).
|
|
String()
|
|
env.writeClaudeSession(
|
|
t, "proj",
|
|
fmt.Sprintf("conc-%d.jsonl", i), content,
|
|
)
|
|
}
|
|
|
|
// barrier blocks SyncAll's progress callback,
|
|
// keeping syncMu held.
|
|
barrier := make(chan struct{})
|
|
// syncAllEntered signals that SyncAll is inside
|
|
// the mutex-protected section.
|
|
syncAllEntered := make(chan struct{})
|
|
// resyncEntered signals that ResyncAll reached its
|
|
// progress callback (i.e. acquired the mutex).
|
|
resyncEntered := make(chan struct{})
|
|
|
|
var syncOnce, resyncOnce gosync.Once
|
|
|
|
syncProgress := func(_ sync.Progress) {
|
|
syncOnce.Do(func() {
|
|
close(syncAllEntered)
|
|
<-barrier // hold mutex until released
|
|
})
|
|
}
|
|
|
|
resyncProgress := func(_ sync.Progress) {
|
|
resyncOnce.Do(func() {
|
|
close(resyncEntered)
|
|
})
|
|
}
|
|
|
|
var wg gosync.WaitGroup
|
|
wg.Add(2)
|
|
|
|
go func() {
|
|
defer wg.Done()
|
|
env.engine.SyncAll(context.Background(), syncProgress)
|
|
}()
|
|
|
|
// Wait until SyncAll is inside the locked section.
|
|
<-syncAllEntered
|
|
|
|
go func() {
|
|
defer wg.Done()
|
|
env.engine.ResyncAll(context.Background(), resyncProgress)
|
|
}()
|
|
|
|
// ResyncAll should be blocked on the mutex. Give it
|
|
// a moment to prove it can't enter.
|
|
select {
|
|
case <-resyncEntered:
|
|
t.Fatal(
|
|
"ResyncAll entered while SyncAll held mutex",
|
|
)
|
|
case <-time.After(50 * time.Millisecond):
|
|
// Expected: ResyncAll is blocked.
|
|
}
|
|
|
|
// Release the barrier so SyncAll finishes.
|
|
close(barrier)
|
|
|
|
// Now ResyncAll should proceed.
|
|
select {
|
|
case <-resyncEntered:
|
|
// Expected: ResyncAll acquired mutex.
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("ResyncAll never entered after barrier release")
|
|
}
|
|
|
|
wg.Wait()
|
|
}
|
|
|
|
// TestSyncEnginePostFilterCounts verifies that writeBatch
|
|
// stores post-filter message counts (after pairAndFilter
|
|
// removes empty user+tool_result messages), not the raw
|
|
// parser counts.
|
|
func TestSyncEnginePostFilterCounts(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
// Build a session with 4 raw messages:
|
|
// 1. user with content (kept)
|
|
// 2. assistant with tool_use (kept)
|
|
// 3. user with only tool_result, no text (filtered)
|
|
// 4. assistant with text (kept)
|
|
// Post-filter: 3 messages, 1 user message.
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "Read main.go").
|
|
AddRaw(testjsonl.ClaudeAssistantJSON(
|
|
[]map[string]any{{
|
|
"type": "tool_use",
|
|
"id": "toolu_1",
|
|
"name": "Read",
|
|
"input": map[string]string{
|
|
"file_path": "main.go",
|
|
},
|
|
}},
|
|
tsEarlyS1,
|
|
)).
|
|
AddRaw(testjsonl.ClaudeToolResultUserJSON(
|
|
"toolu_1", "package main", tsEarlyS5,
|
|
)).
|
|
AddClaudeAssistant(tsEarlyS5, "Here it is.").
|
|
String()
|
|
|
|
env.writeClaudeSession(
|
|
t, "test-proj",
|
|
"filter-count.jsonl", content,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 1 + 0, Synced: 1, Skipped: 0})
|
|
|
|
// Verify stored counts match post-filter values.
|
|
assertSessionMessageCount(t, env.db, "filter-count", 3)
|
|
assertSessionState(t, env.db, "filter-count", func(sess *db.Session) {
|
|
assert.Equal(t, 1, sess.UserMessageCount, "user_message_count = %d, want 1", sess.UserMessageCount)
|
|
})
|
|
}
|
|
|
|
// TestSyncSingleSessionPostFilterCounts verifies that
|
|
// writeSessionFull (used by SyncSingleSession) also stores
|
|
// post-filter counts.
|
|
func TestSyncSingleSessionPostFilterCounts(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
content2 := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "Read main.go").
|
|
AddRaw(testjsonl.ClaudeAssistantJSON(
|
|
[]map[string]any{{
|
|
"type": "tool_use",
|
|
"id": "toolu_1",
|
|
"name": "Read",
|
|
"input": map[string]string{
|
|
"file_path": "main.go",
|
|
},
|
|
}},
|
|
tsEarlyS1,
|
|
)).
|
|
AddRaw(testjsonl.ClaudeToolResultUserJSON(
|
|
"toolu_1", "package main", tsEarlyS5,
|
|
)).
|
|
AddClaudeAssistant(tsEarlyS5, "Here it is.").
|
|
String()
|
|
|
|
env.writeClaudeSession(
|
|
t, "test-proj",
|
|
"filter-single.jsonl", content2,
|
|
)
|
|
|
|
// SyncAll to populate the session in the DB.
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
// Corrupt stored counts and clear mtime so
|
|
// SyncSingleSession re-parses via writeSessionFull.
|
|
err := env.db.Update(func(tx *sql.Tx) error {
|
|
res, err := tx.Exec(
|
|
"UPDATE sessions"+
|
|
" SET message_count = 999,"+
|
|
" user_message_count = 999,"+
|
|
" file_mtime = NULL"+
|
|
" WHERE id = ?",
|
|
"filter-single",
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n != 1 {
|
|
return fmt.Errorf(
|
|
"expected 1 row affected, got %d", n,
|
|
)
|
|
}
|
|
return nil
|
|
})
|
|
require.NoError(t, err, "corrupt counts")
|
|
|
|
err = env.engine.SyncSingleSession(
|
|
"filter-single",
|
|
)
|
|
require.NoError(t, err, "SyncSingleSession")
|
|
|
|
// Counts should be corrected by writeSessionFull.
|
|
assertSessionMessageCount(t, env.db, "filter-single", 3)
|
|
assertSessionState(t, env.db, "filter-single", func(sess *db.Session) {
|
|
assert.Equal(t, 1, sess.UserMessageCount, "user_message_count = %d, want 1", sess.UserMessageCount)
|
|
})
|
|
}
|
|
|
|
func TestSyncEngineMultiClaudeDir(t *testing.T) {
|
|
claudeDir1 := t.TempDir()
|
|
claudeDir2 := t.TempDir()
|
|
env := setupTestEnv(t, WithClaudeDirs([]string{claudeDir1, claudeDir2}))
|
|
|
|
content1 := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "Hello from dir1").
|
|
String()
|
|
content2 := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "Hello from dir2").
|
|
String()
|
|
|
|
// Write sessions to different directories
|
|
path1 := filepath.Join(claudeDir1, "proj1", "sess1.jsonl")
|
|
dbtest.WriteTestFile(t, path1, []byte(content1))
|
|
|
|
path2 := filepath.Join(claudeDir2, "proj2", "sess2.jsonl")
|
|
dbtest.WriteTestFile(t, path2, []byte(content2))
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 2, Synced: 2, Skipped: 0})
|
|
|
|
assertSessionMessageCount(t, env.db, "sess1", 1)
|
|
assertSessionMessageCount(t, env.db, "sess2", 1)
|
|
|
|
// SyncPaths should work across directories
|
|
appended := content1 + testjsonl.NewSessionBuilder().
|
|
AddClaudeAssistant(tsEarlyS5, "Reply").
|
|
String()
|
|
os.WriteFile(path1, []byte(appended), 0o644)
|
|
env.engine.SyncPaths([]string{path1})
|
|
|
|
assertSessionMessageCount(t, env.db, "sess1", 2)
|
|
|
|
// FindSourceFile should search across directories
|
|
src := env.engine.FindSourceFile("sess2")
|
|
assert.NotEmpty(t, src, "FindSourceFile failed for sess2 in second directory")
|
|
}
|
|
|
|
// TestSyncEngineMultiCursorDir verifies that SyncAll and
|
|
// SyncPaths work when multiple Cursor project directories
|
|
// are configured, and that the containment check in
|
|
// processCursor correctly identifies the containing root
|
|
// for files under non-first directories.
|
|
func TestSyncEngineMultiCursorDir(t *testing.T) {
|
|
cursorDir1 := t.TempDir()
|
|
cursorDir2 := t.TempDir()
|
|
env := setupTestEnv(
|
|
t, WithCursorDirs([]string{cursorDir1, cursorDir2}),
|
|
)
|
|
|
|
transcript1 := "user:\nHello from cursor dir1\n" +
|
|
"assistant:\nHi from dir1!\n"
|
|
transcript2 := "user:\nHello from cursor dir2\n" +
|
|
"assistant:\nHi from dir2!\n"
|
|
|
|
// Write sessions to different Cursor directories.
|
|
// Cursor project dir uses hyphenated encoding.
|
|
env.writeCursorSession(
|
|
t, cursorDir1,
|
|
"Users-alice-code-proj1",
|
|
"sess1.txt", transcript1,
|
|
)
|
|
path2 := env.writeCursorSession(
|
|
t, cursorDir2,
|
|
"Users-alice-code-proj2",
|
|
"sess2.txt", transcript2,
|
|
)
|
|
|
|
// SyncAll should discover sessions from both dirs.
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 2, Synced: 2, Skipped: 0,
|
|
})
|
|
|
|
assertSessionState(
|
|
t, env.db, "cursor:sess1",
|
|
func(sess *db.Session) {
|
|
assert.Equal(t, "cursor", sess.Agent, "agent = %q, want cursor", sess.Agent)
|
|
},
|
|
)
|
|
assertSessionState(
|
|
t, env.db, "cursor:sess2",
|
|
func(sess *db.Session) {
|
|
assert.Equal(t, "cursor", sess.Agent, "agent = %q, want cursor", sess.Agent)
|
|
},
|
|
)
|
|
|
|
// SyncPaths should handle a file from the second dir.
|
|
updated := "user:\nHello from cursor dir2\n" +
|
|
"assistant:\nHi from dir2!\n" +
|
|
"user:\nFollow-up\n" +
|
|
"assistant:\nGot it.\n"
|
|
os.WriteFile(path2, []byte(updated), 0o644)
|
|
env.engine.SyncPaths([]string{path2})
|
|
|
|
assertSessionMessageCount(t, env.db, "cursor:sess2", 4)
|
|
|
|
// FindSourceFile should work across directories.
|
|
src := env.engine.FindSourceFile("cursor:sess2")
|
|
assert.NotEmpty(t, src, "FindSourceFile failed for cursor:sess2 "+"in second directory")
|
|
}
|
|
|
|
func TestSyncPathsCursorNestedLayout(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
path := env.writeNestedCursorSession(
|
|
t, env.cursorDir,
|
|
"Users-alice-code-nested-proj",
|
|
"nested-sync", ".jsonl",
|
|
"user:\nHello nested cursor\nassistant:\nHi there!\n",
|
|
)
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
assertSessionProject(
|
|
t, env.db, "cursor:nested-sync", "nested_proj",
|
|
)
|
|
assertSessionMessageCount(
|
|
t, env.db, "cursor:nested-sync", 2,
|
|
)
|
|
}
|
|
|
|
func TestSyncSingleSessionCursorNestedLayoutPreservesProject(
|
|
t *testing.T,
|
|
) {
|
|
env := setupTestEnv(t)
|
|
|
|
path := env.writeNestedCursorSession(
|
|
t, env.cursorDir,
|
|
"Users-alice-code-nested-proj",
|
|
"nested-resync", ".jsonl",
|
|
"user:\nHello nested cursor\nassistant:\nHi there!\n",
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1, Synced: 1, Skipped: 0,
|
|
})
|
|
assertSessionProject(
|
|
t, env.db, "cursor:nested-resync", "nested_proj",
|
|
)
|
|
|
|
updated := "user:\nHello nested cursor\n" +
|
|
"assistant:\nHi there!\n" +
|
|
"user:\nFollow-up\n" +
|
|
"assistant:\nGot it.\n"
|
|
require.NoError(t, os.WriteFile(path, []byte(updated), 0o644), "WriteFile")
|
|
|
|
err := env.engine.SyncSingleSession(
|
|
"cursor:nested-resync",
|
|
)
|
|
require.NoError(t, err, "SyncSingleSession")
|
|
|
|
assertSessionProject(
|
|
t, env.db, "cursor:nested-resync", "nested_proj",
|
|
)
|
|
assertSessionMessageCount(
|
|
t, env.db, "cursor:nested-resync", 4,
|
|
)
|
|
}
|
|
|
|
func TestSyncForkDetection(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
// Main branch: a->b->c->d->e->f->g->h->k->l (5 user turns)
|
|
// Fork from b: i->j (1 user turn on fork branch)
|
|
// First branch from b has 4 user turns (c,e,g,k) > 3 = large gap
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUserWithUUID("2024-01-01T10:00:00Z", "start", "a", "").
|
|
AddClaudeAssistantWithUUID("2024-01-01T10:00:01Z", "ok", "b", "a").
|
|
AddClaudeUserWithUUID("2024-01-01T10:00:02Z", "step2", "c", "b").
|
|
AddClaudeAssistantWithUUID("2024-01-01T10:00:03Z", "ok2", "d", "c").
|
|
AddClaudeUserWithUUID("2024-01-01T10:00:04Z", "step3", "e", "d").
|
|
AddClaudeAssistantWithUUID("2024-01-01T10:00:05Z", "ok3", "f", "e").
|
|
AddClaudeUserWithUUID("2024-01-01T10:00:06Z", "step4", "g", "f").
|
|
AddClaudeAssistantWithUUID("2024-01-01T10:00:07Z", "ok4", "h", "g").
|
|
AddClaudeUserWithUUID("2024-01-01T10:00:08Z", "step5", "k", "h").
|
|
AddClaudeAssistantWithUUID("2024-01-01T10:00:09Z", "ok5", "l", "k").
|
|
AddClaudeUserWithUUID("2024-01-01T10:01:00Z", "fork-start", "i", "b").
|
|
AddClaudeAssistantWithUUID("2024-01-01T10:01:01Z", "fork-ok", "j", "i").
|
|
String()
|
|
|
|
env.writeClaudeSession(t, "test-proj", "parent-uuid.jsonl", content)
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 1, Synced: 2, Skipped: 0})
|
|
|
|
assertSessionMessageCount(t, env.db, "parent-uuid", 10)
|
|
assertSessionMessageCount(t, env.db, "parent-uuid-i", 2)
|
|
|
|
assertSessionState(t, env.db, "parent-uuid-i", func(sess *db.Session) {
|
|
require.NotNil(t, sess.ParentSessionID, "fork parent = nil, want parent-uuid")
|
|
assert.Equal(t, "parent-uuid", *sess.ParentSessionID, "fork parent = %v, want parent-uuid", sess.ParentSessionID)
|
|
assert.Equal(t, "fork", sess.RelationshipType, "fork relationship_type = %q, want fork", sess.RelationshipType)
|
|
})
|
|
}
|
|
|
|
func TestSyncSmallGapRetry(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
// Main: a->b->c->d (1 user turn after fork point = small gap)
|
|
// Retry from b: e->f
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUserWithUUID("2024-01-01T10:00:00Z", "start", "a", "").
|
|
AddClaudeAssistantWithUUID("2024-01-01T10:00:01Z", "ok", "b", "a").
|
|
AddClaudeUserWithUUID("2024-01-01T10:00:02Z", "try1", "c", "b").
|
|
AddClaudeAssistantWithUUID("2024-01-01T10:00:03Z", "resp1", "d", "c").
|
|
AddClaudeUserWithUUID("2024-01-01T10:01:00Z", "try2", "e", "b").
|
|
AddClaudeAssistantWithUUID("2024-01-01T10:01:01Z", "resp2", "f", "e").
|
|
String()
|
|
|
|
env.writeClaudeSession(t, "test-proj", "retry-uuid.jsonl", content)
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{TotalSessions: 1, Synced: 1, Skipped: 0})
|
|
|
|
assertSessionMessageCount(t, env.db, "retry-uuid", 4)
|
|
}
|
|
|
|
func TestResyncAllReplacesMessageContent(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
sessionID := "gem-resync-test"
|
|
hash := "resync123"
|
|
|
|
content := testjsonl.GeminiSessionJSON(
|
|
sessionID, hash, tsEarly, tsEarlyS5,
|
|
[]map[string]any{
|
|
testjsonl.GeminiUserMsg(
|
|
"u1", tsEarly, "Explain this code",
|
|
),
|
|
testjsonl.GeminiAssistantMsg(
|
|
"a1", tsEarlyS5, "Here is the explanation.",
|
|
&testjsonl.GeminiMsgOpts{
|
|
Thoughts: []testjsonl.GeminiThought{{
|
|
Subject: "Analysis",
|
|
Description: "Reading the code",
|
|
Timestamp: tsEarlyS1,
|
|
}},
|
|
},
|
|
),
|
|
},
|
|
)
|
|
|
|
relPath := filepath.Join(
|
|
"tmp", hash, "chats", "session-001.json",
|
|
)
|
|
env.writeGeminiSession(t, relPath, content)
|
|
|
|
// Initial sync.
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1, Synced: 1,
|
|
})
|
|
|
|
fullID := "gemini:" + sessionID
|
|
msgs := fetchMessages(t, env.db, fullID)
|
|
require.Equal(t, 2, len(msgs), "got %d messages, want 2", len(msgs))
|
|
|
|
// Simulate a parser change by directly modifying message
|
|
// content in the DB. This mirrors what happens when the Go
|
|
// parser is updated (e.g. thinking format change) but the
|
|
// source files on disk are unchanged.
|
|
err := env.db.Update(func(tx *sql.Tx) error {
|
|
_, err := tx.Exec(
|
|
"UPDATE messages SET content = ?"+
|
|
" WHERE session_id = ? AND ordinal = 1",
|
|
"stale content from old parser",
|
|
fullID,
|
|
)
|
|
return err
|
|
})
|
|
require.NoError(t, err, "update message content")
|
|
|
|
// Capture FTS state before resync so a regression that
|
|
// breaks FTS isn't masked by HasFTS() returning false
|
|
// post-resync.
|
|
hadFTS := env.db.HasFTS()
|
|
|
|
// ResyncAll should re-parse and replace message content. Gemini is
|
|
// provider-authoritative, so it has no DB-backed mtime skip; a plain
|
|
// SyncAll would also re-parse the unchanged file. ResyncAll additionally
|
|
// drops and rebuilds the FTS index, which is what this test guards.
|
|
env.engine.ResyncAll(context.Background(), nil)
|
|
msgs = fetchMessages(t, env.db, fullID)
|
|
require.Equal(t, 2, len(msgs), "got %d messages after resync, want 2", len(msgs))
|
|
assert.NotContains(t, msgs[1].Content, "stale content", "ResyncAll did not replace message content")
|
|
assert.Contains(t, msgs[1].Content, "Here is the explanation.",
|
|
"unexpected content after resync: %q", msgs[1].Content)
|
|
|
|
// FTS search should work after resync (index was dropped
|
|
// and rebuilt).
|
|
if hadFTS {
|
|
require.True(t, env.db.HasFTS(), "FTS available before resync but not after")
|
|
page, err := env.db.Search(
|
|
context.Background(),
|
|
db.SearchFilter{Query: "explanation"},
|
|
)
|
|
require.NoError(t, err, "search after resync")
|
|
assert.NotZero(t, len(page.Results), "FTS search returned no results after resync")
|
|
}
|
|
}
|
|
|
|
func TestResyncAllPreservesTrashedSessionData(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
original := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "original trashed prompt").
|
|
AddClaudeAssistant(tsZeroS5, "original trashed reply").
|
|
String()
|
|
path := env.writeClaudeSession(
|
|
t, "test-proj", "resync-trash.jsonl", original,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1, Synced: 1,
|
|
})
|
|
orphanContent := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "orphan prompt").
|
|
AddClaudeAssistant(tsZeroS5, "orphan reply").
|
|
String()
|
|
orphanPath := env.writeClaudeSession(
|
|
t, "test-proj", "active-orphan.jsonl", orphanContent,
|
|
)
|
|
env.engine.SyncPaths([]string{orphanPath})
|
|
assertSessionMessageCount(t, env.db, "active-orphan", 2)
|
|
require.NoError(t, env.db.UpdateSessionSignals(
|
|
"active-orphan",
|
|
db.SessionSignalUpdate{
|
|
Outcome: "completed",
|
|
OutcomeConfidence: "high",
|
|
EndedWithRole: "assistant",
|
|
HealthScore: new(94),
|
|
HealthGrade: new("A"),
|
|
QualitySignals: db.QualitySignals{
|
|
Version: db.CurrentQualitySignalVersion,
|
|
ShortPromptCount: 1,
|
|
MissingSuccessCriteriaCount: 1,
|
|
},
|
|
},
|
|
), "UpdateSessionSignals orphan")
|
|
require.NoError(t, os.Remove(orphanPath), "remove orphan source")
|
|
|
|
require.NoError(t, env.db.SoftDeleteSession("resync-trash"), "SoftDeleteSession")
|
|
|
|
replacement := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "replacement prompt").
|
|
AddClaudeAssistant(tsZeroS5, "replacement reply").
|
|
String()
|
|
dbtest.WriteTestFile(t, path, []byte(replacement))
|
|
|
|
stats := env.engine.ResyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "ResyncAll aborted: %+v", stats)
|
|
assertSessionMessageCount(t, env.db, "active-orphan", 2)
|
|
assertSessionState(t, env.db, "active-orphan", func(sess *db.Session) {
|
|
if sess.HealthScore == nil || *sess.HealthScore != 94 {
|
|
t.Fatalf("orphan health score = %v, want 94", sess.HealthScore)
|
|
}
|
|
qs := sess.StoredQualitySignals()
|
|
if qs == nil {
|
|
t.Fatal("orphan quality signals were not preserved")
|
|
}
|
|
if qs.Version != db.CurrentQualitySignalVersion ||
|
|
qs.ShortPromptCount != 1 ||
|
|
qs.MissingSuccessCriteriaCount != 1 {
|
|
t.Fatalf("orphan quality signals = %+v, want preserved prompt signals", qs)
|
|
}
|
|
})
|
|
|
|
full, err := env.db.GetSessionFull(
|
|
context.Background(), "resync-trash",
|
|
)
|
|
require.NoError(t, err, "GetSessionFull")
|
|
require.NotNil(t, full, "trashed session was not preserved as trashed")
|
|
require.NotNil(t, full.DeletedAt, "trashed session was not preserved as trashed")
|
|
msgs := fetchMessages(t, env.db, "resync-trash")
|
|
require.Equal(t, 2, len(msgs), "messages = %d, want 2", len(msgs))
|
|
require.Equal(t, "original trashed prompt", msgs[0].Content, "trashed content = %q, want original content", msgs[0].Content)
|
|
}
|
|
|
|
// TestResyncAllSurfacesQueuedCommands locks in that bumping
|
|
// dataVersion (which forces a full resync) recovers Claude
|
|
// queued_command attachments dropped by older parser versions.
|
|
// Old DBs synced before the parser fix have no row for the
|
|
// mid-flight user message; ResyncAll must replay the file and
|
|
// reinstate it.
|
|
func TestResyncAllSurfacesQueuedCommands(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("first", tsEarly),
|
|
testjsonl.ClaudeAssistantJSON([]map[string]any{
|
|
{"type": "text", "text": "starting"},
|
|
}, tsEarlyS1),
|
|
testjsonl.ClaudeQueuedCommandJSON(
|
|
"also do X", "2024-01-01T10:00:02Z",
|
|
),
|
|
testjsonl.ClaudeAssistantJSON([]map[string]any{
|
|
{"type": "text", "text": "done"},
|
|
}, tsEarlyS5),
|
|
)
|
|
|
|
env.writeClaudeSession(
|
|
t, "test-proj", "queued-resync.jsonl", content,
|
|
)
|
|
|
|
// Initial sync uses the current parser, which surfaces the
|
|
// queued_command as message ordinal 2.
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1, Synced: 1,
|
|
})
|
|
|
|
const sessionID = "queued-resync"
|
|
msgs := fetchMessages(t, env.db, sessionID)
|
|
require.Equal(t, 4, len(msgs), "initial sync: got %d messages, want 4", len(msgs))
|
|
|
|
// Simulate an old-parser DB by removing the queued_command
|
|
// row directly. Older versions of the parser would never
|
|
// have stored it.
|
|
err := env.db.Update(func(tx *sql.Tx) error {
|
|
_, err := tx.Exec(
|
|
"DELETE FROM messages WHERE session_id = ?"+
|
|
" AND source_subtype = 'queued_command'",
|
|
sessionID,
|
|
)
|
|
return err
|
|
})
|
|
require.NoError(t, err, "delete queued_command row")
|
|
msgs = fetchMessages(t, env.db, sessionID)
|
|
require.Equal(t, 3, len(msgs), "after stale simulation: got %d, want 3", len(msgs))
|
|
|
|
// SyncAll must NOT recover the dropped row: the source
|
|
// file is unchanged on disk, so the engine skips it.
|
|
stats := env.engine.SyncAll(context.Background(), nil)
|
|
require.Equal(t, 1, stats.Skipped, "SyncAll: expected Skipped=1, got %d", stats.Skipped)
|
|
msgs = fetchMessages(t, env.db, sessionID)
|
|
require.Equal(t, 3, len(msgs), "after SyncAll: got %d, want 3", len(msgs))
|
|
|
|
// ResyncAll re-parses every session from scratch and the
|
|
// queued_command reappears.
|
|
env.engine.ResyncAll(context.Background(), nil)
|
|
|
|
msgs = fetchMessages(t, env.db, sessionID)
|
|
require.Equal(t, 4, len(msgs), "after ResyncAll: got %d, want 4", len(msgs))
|
|
|
|
var queued *db.Message
|
|
for i := range msgs {
|
|
if msgs[i].SourceSubtype == "queued_command" {
|
|
queued = &msgs[i]
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, queued, "ResyncAll did not restore queued_command row")
|
|
assert.Equal(t, "also do X", queued.Content, "queued_command content = %q, want %q", queued.Content, "also do X")
|
|
assert.Equal(t, "user", queued.Role, "queued_command role = %q, want user", queued.Role)
|
|
assert.False(t, queued.IsSystem, "queued_command should not be is_system=true")
|
|
}
|
|
|
|
func TestResyncAllPreservesInsights(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "Hello").
|
|
AddClaudeAssistant(tsEarlyS5, "Hi there!").
|
|
String()
|
|
|
|
env.writeClaudeSession(
|
|
t, "test-proj", "insight-test.jsonl", content,
|
|
)
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
assertSessionMessageCount(t, env.db, "insight-test", 2)
|
|
|
|
// Insert an insight into the DB.
|
|
_, err := env.db.InsertInsight(db.Insight{
|
|
Type: "daily_activity",
|
|
DateFrom: "2025-01-15",
|
|
DateTo: "2025-01-15",
|
|
Agent: "claude",
|
|
Content: "test insight survives resync",
|
|
})
|
|
require.NoError(t, err, "InsertInsight")
|
|
|
|
// ResyncAll should rebuild sessions and preserve
|
|
// insights.
|
|
stats := env.engine.ResyncAll(context.Background(), nil)
|
|
require.NotZero(t, stats.Synced, "expected at least 1 synced session")
|
|
|
|
assertSessionMessageCount(t, env.db, "insight-test", 2)
|
|
|
|
insights, err := env.db.ListInsights(
|
|
context.Background(), db.InsightFilter{},
|
|
)
|
|
require.NoError(t, err, "ListInsights")
|
|
require.Equal(t, 1, len(insights), "got %d insights, want 1", len(insights))
|
|
assert.Equal(t, "test insight survives resync", insights[0].Content, "insight content = %q, want preserved", insights[0].Content)
|
|
}
|
|
|
|
func TestResyncAllPreservesModelPricing(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "Hello").
|
|
AddClaudeAssistant(tsEarlyS5, "Hi there!").
|
|
String()
|
|
|
|
env.writeClaudeSession(
|
|
t, "test-proj", "pricing-test.jsonl", content,
|
|
)
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
assertSessionMessageCount(t, env.db, "pricing-test", 2)
|
|
|
|
require.NoError(t, env.db.UpsertModelPricing([]db.ModelPricing{
|
|
{
|
|
ModelPattern: "claude-opus-4-8",
|
|
InputPerMTok: 15,
|
|
OutputPerMTok: 75,
|
|
},
|
|
}), "UpsertModelPricing")
|
|
|
|
stats := env.engine.ResyncAll(context.Background(), nil)
|
|
require.NotZero(t, stats.Synced, "expected at least 1 synced session")
|
|
|
|
pricing, err := env.db.GetModelPricing("claude-opus-4-8")
|
|
require.NoError(t, err, "GetModelPricing")
|
|
require.NotNil(t, pricing,
|
|
"model pricing must survive the resync swap")
|
|
assert.Equal(t, 15.0, pricing.InputPerMTok, "input rate")
|
|
assert.Equal(t, 75.0, pricing.OutputPerMTok, "output rate")
|
|
}
|
|
|
|
func TestResyncAllAbortsWhenSessionSnapshotMetadataCannotCopy(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "original content").
|
|
AddClaudeAssistant(tsEarlyS5, "original reply").
|
|
String()
|
|
env.writeClaudeSession(t, "test-proj", "metadata-copy.jsonl", content)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
assertSessionMessageCount(t, env.db, "metadata-copy", 2)
|
|
|
|
dbPath := env.db.Path()
|
|
require.NoError(t, env.db.Close())
|
|
raw, err := sql.Open("sqlite3", dbPath)
|
|
require.NoError(t, err)
|
|
_, err = raw.Exec(`DROP TABLE starred_sessions;
|
|
CREATE TABLE starred_sessions (broken_column TEXT)`)
|
|
require.NoError(t, err)
|
|
require.NoError(t, raw.Close())
|
|
require.NoError(t, env.db.Reopen())
|
|
|
|
stats := env.engine.ResyncAll(context.Background(), nil)
|
|
|
|
assert.True(t, stats.Aborted)
|
|
assert.Contains(t, strings.Join(stats.Warnings, "\n"),
|
|
"session metadata copy failed, aborting swap")
|
|
assertSessionMessageCount(t, env.db, "metadata-copy", 2)
|
|
}
|
|
|
|
// TestResyncAllAbortsOnFailures verifies that ResyncAll
|
|
// does not swap the DB when sync has more failures than
|
|
// successes.
|
|
func TestResyncAllAbortsOnFailures(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "original content").
|
|
AddClaudeAssistant(tsEarlyS5, "original reply").
|
|
String()
|
|
|
|
env.writeClaudeSession(
|
|
t, "test-proj", "abort-test.jsonl", content,
|
|
)
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
assertSessionMessageCount(t, env.db, "abort-test", 2)
|
|
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("chmod(0) does not prevent reads on Windows")
|
|
}
|
|
if os.Getuid() == 0 {
|
|
t.Skip("root can read mode-0 files")
|
|
}
|
|
|
|
// Make the file unreadable so the parser returns a hard
|
|
// error. This is deterministic: os.Open will fail with
|
|
// a permission error on every attempt.
|
|
sessionPath := filepath.Join(
|
|
env.claudeDir, "test-proj", "abort-test.jsonl",
|
|
)
|
|
require.NoError(t, os.Chmod(sessionPath, 0), "chmod")
|
|
t.Cleanup(func() {
|
|
os.Chmod(sessionPath, 0o644)
|
|
})
|
|
|
|
stats := env.engine.ResyncAll(context.Background(), nil)
|
|
|
|
require.NotEqual(t, 0, stats.Failed, "expected failures, got 0")
|
|
require.NotZero(t, stats.TotalSessions, "expected TotalSessions > 0")
|
|
|
|
assert.True(t, stats.Aborted, "expected Aborted = true")
|
|
|
|
hasAbortWarning := false
|
|
for _, w := range stats.Warnings {
|
|
if strings.Contains(w, "resync aborted") {
|
|
hasAbortWarning = true
|
|
}
|
|
}
|
|
assert.True(t, hasAbortWarning, "expected 'resync aborted' warning")
|
|
|
|
// Original data should be preserved since swap was
|
|
// aborted.
|
|
assertSessionMessageCount(t, env.db, "abort-test", 2)
|
|
assertMessageContent(
|
|
t, env.db, "abort-test",
|
|
"original content", "original reply",
|
|
)
|
|
}
|
|
|
|
// TestResyncAllAbortsWithForkAndFailures exercises the abort
|
|
// guard's file-level counting. A fork-producing file yields
|
|
// Synced=2 from filesOK=1. Two unreadable files add Failed=2.
|
|
// The abort guard should fire because Failed(2) > filesOK(1),
|
|
// even though Failed(2) == Synced(2) would pass a naive
|
|
// session-level comparison.
|
|
func TestResyncAllAbortsWithForkAndFailures(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("chmod(0) does not prevent reads on Windows")
|
|
}
|
|
if os.Getuid() == 0 {
|
|
t.Skip("root can read mode-0 files")
|
|
}
|
|
|
|
env := setupTestEnv(t)
|
|
|
|
// File 1: fork-producing session (1 file → 2 sessions).
|
|
// Main branch has 5 user turns; fork from b creates a
|
|
// 4-turn gap (>3) which triggers fork detection.
|
|
forkContent := testjsonl.NewSessionBuilder().
|
|
AddClaudeUserWithUUID(
|
|
"2024-01-01T10:00:00Z", "start", "a", "",
|
|
).
|
|
AddClaudeAssistantWithUUID(
|
|
"2024-01-01T10:00:01Z", "ok", "b", "a",
|
|
).
|
|
AddClaudeUserWithUUID(
|
|
"2024-01-01T10:00:02Z", "s2", "c", "b",
|
|
).
|
|
AddClaudeAssistantWithUUID(
|
|
"2024-01-01T10:00:03Z", "ok2", "d", "c",
|
|
).
|
|
AddClaudeUserWithUUID(
|
|
"2024-01-01T10:00:04Z", "s3", "e", "d",
|
|
).
|
|
AddClaudeAssistantWithUUID(
|
|
"2024-01-01T10:00:05Z", "ok3", "f", "e",
|
|
).
|
|
AddClaudeUserWithUUID(
|
|
"2024-01-01T10:00:06Z", "s4", "g", "f",
|
|
).
|
|
AddClaudeAssistantWithUUID(
|
|
"2024-01-01T10:00:07Z", "ok4", "h", "g",
|
|
).
|
|
AddClaudeUserWithUUID(
|
|
"2024-01-01T10:00:08Z", "s5", "k", "h",
|
|
).
|
|
AddClaudeAssistantWithUUID(
|
|
"2024-01-01T10:00:09Z", "ok5", "l", "k",
|
|
).
|
|
AddClaudeUserWithUUID(
|
|
"2024-01-01T10:01:00Z", "fork", "i", "b",
|
|
).
|
|
AddClaudeAssistantWithUUID(
|
|
"2024-01-01T10:01:01Z", "fork-ok", "j", "i",
|
|
).
|
|
String()
|
|
|
|
env.writeClaudeSession(
|
|
t, "proj", "forked.jsonl", forkContent,
|
|
)
|
|
|
|
// Files 2 & 3: normal sessions that we'll make unreadable.
|
|
for _, name := range []string{"bad1.jsonl", "bad2.jsonl"} {
|
|
c := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "hello").
|
|
String()
|
|
env.writeClaudeSession(t, "proj", name, c)
|
|
}
|
|
|
|
// Initial sync: all 3 files parse fine.
|
|
// Fork file produces 2 sessions: "forked" (10 msgs)
|
|
// and "forked-i" (2 msgs).
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
assertSessionMessageCount(t, env.db, "forked", 10)
|
|
assertSessionMessageCount(t, env.db, "forked-i", 2)
|
|
|
|
// Make both normal files unreadable.
|
|
for _, name := range []string{"bad1.jsonl", "bad2.jsonl"} {
|
|
p := filepath.Join(env.claudeDir, "proj", name)
|
|
require.NoError(t, os.Chmod(p, 0), "chmod %s", name)
|
|
t.Cleanup(func() { os.Chmod(p, 0o644) })
|
|
}
|
|
|
|
stats := env.engine.ResyncAll(context.Background(), nil)
|
|
|
|
// Expect: filesOK=1, Failed=2, Synced=2.
|
|
// Abort should fire because Failed(2) > filesOK(1).
|
|
require.Equal(t, 2, stats.Failed, "Failed = %d, want 2", stats.Failed)
|
|
require.Equal(t, 2, stats.Synced, "Synced = %d, want 2", stats.Synced)
|
|
|
|
hasAbortWarning := false
|
|
for _, w := range stats.Warnings {
|
|
if strings.Contains(w, "resync aborted") {
|
|
hasAbortWarning = true
|
|
}
|
|
}
|
|
assert.True(t, hasAbortWarning, "expected abort: Failed(2) > filesOK(1) "+"should trigger even though Failed == Synced")
|
|
|
|
// Original data preserved.
|
|
assertSessionMessageCount(t, env.db, "forked", 10)
|
|
assertSessionMessageCount(t, env.db, "forked-i", 2)
|
|
}
|
|
|
|
// TestResyncAllPostReopenAvailability verifies that reads and
|
|
// writes work through the DB handle after ResyncAll completes
|
|
// the close-rename-reopen cycle.
|
|
func TestResyncAllPostReopenAvailability(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "availability check").
|
|
AddClaudeAssistant(tsEarlyS5, "still here").
|
|
String()
|
|
|
|
env.writeClaudeSession(
|
|
t, "avail-proj", "avail.jsonl", content,
|
|
)
|
|
|
|
// Initial sync.
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1, Synced: 1,
|
|
})
|
|
|
|
// Resync triggers the full close-rename-reopen cycle.
|
|
stats := env.engine.ResyncAll(context.Background(), nil)
|
|
require.Equal(t, 1, stats.Synced, "resync: synced = %d, want 1", stats.Synced)
|
|
assert.False(t, stats.Aborted, "unexpected Aborted = true on successful resync")
|
|
for _, w := range stats.Warnings {
|
|
assert.Fail(t, "unexpected warning", w)
|
|
}
|
|
|
|
// Verify reads work on the reopened DB.
|
|
s, err := env.db.GetSession(
|
|
context.Background(), "avail",
|
|
)
|
|
require.NoError(t, err, "GetSession after resync")
|
|
require.NotNil(t, s, "session missing after resync")
|
|
|
|
msgs := fetchMessages(t, env.db, "avail")
|
|
require.Equal(t, 2, len(msgs), "got %d messages, want 2", len(msgs))
|
|
|
|
// Verify writes work on the reopened DB.
|
|
err = env.db.UpsertSession(db.Session{
|
|
ID: "post-resync-write",
|
|
Project: "avail-proj",
|
|
Machine: "local",
|
|
Agent: "claude",
|
|
MessageCount: 1,
|
|
})
|
|
require.NoError(t, err, "UpsertSession after resync")
|
|
s2, err := env.db.GetSession(
|
|
context.Background(), "post-resync-write",
|
|
)
|
|
require.NoError(t, err, "GetSession post-write")
|
|
require.NotNil(t, s2, "session written after resync not found")
|
|
|
|
// Verify a subsequent SyncAll still works (engine state
|
|
// is consistent with the reopened DB).
|
|
stats2 := env.engine.SyncAll(context.Background(), nil)
|
|
assert.Equal(t, 0, stats2.Synced, "post-resync SyncAll: synced=%d skipped=%d", stats2.Synced, stats2.Skipped)
|
|
assert.Equal(t, 1, stats2.Skipped, "post-resync SyncAll: synced=%d skipped=%d", stats2.Synced, stats2.Skipped)
|
|
}
|
|
|
|
// TestResyncAllConcurrentReads verifies that concurrent reads
|
|
// through the DB handle don't panic or deadlock while ResyncAll
|
|
// runs the close-rename-reopen cycle.
|
|
func TestResyncAllConcurrentReads(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "concurrent engine").
|
|
AddClaudeAssistant(tsEarlyS5, "response").
|
|
String()
|
|
|
|
env.writeClaudeSession(
|
|
t, "conc-proj", "conc.jsonl", content,
|
|
)
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
var wg gosync.WaitGroup
|
|
|
|
// Start readers before resync.
|
|
readersReady := make(chan struct{})
|
|
var readyCount gosync.WaitGroup
|
|
readyCount.Add(4)
|
|
|
|
for range 4 {
|
|
wg.Go(func() {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
s, _ := env.db.GetSession(ctx, "conc")
|
|
// Signal ready after first successful read.
|
|
if s != nil {
|
|
readyCount.Done()
|
|
break
|
|
}
|
|
}
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
// Ignore errors; the test verifies no
|
|
// panics/deadlocks and post-resync health.
|
|
env.db.GetSession(ctx, "conc")
|
|
}
|
|
})
|
|
}
|
|
|
|
// Wait for all readers to complete one successful read.
|
|
go func() {
|
|
readyCount.Wait()
|
|
close(readersReady)
|
|
}()
|
|
<-readersReady
|
|
|
|
// Run resync while readers are active.
|
|
stats := env.engine.ResyncAll(context.Background(), nil)
|
|
cancel()
|
|
wg.Wait()
|
|
|
|
require.Equal(t, 1, stats.Synced, "resync: synced = %d, want 1", stats.Synced)
|
|
|
|
// Post-resync reads must succeed.
|
|
s, err := env.db.GetSession(
|
|
context.Background(), "conc",
|
|
)
|
|
require.NoError(t, err, "GetSession after resync")
|
|
require.NotNil(t, s, "session missing after resync")
|
|
}
|
|
|
|
// TestResyncAllAbortsOnEmptyDiscovery verifies that resync does
|
|
// not replace a populated DB with an empty one when discovery
|
|
// returns zero files (e.g. session directories are temporarily
|
|
// inaccessible or misconfigured).
|
|
func TestResyncAllAbortsOnEmptyDiscovery(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
// Seed existing data via initial sync.
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "keep me").
|
|
AddClaudeAssistant(tsEarlyS5, "ok").
|
|
String()
|
|
env.writeClaudeSession(t, "proj", "keep.jsonl", content)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
assertSessionMessageCount(t, env.db, "keep", 2)
|
|
|
|
// Remove all session files to simulate empty discovery.
|
|
entries, err := os.ReadDir(
|
|
filepath.Join(env.claudeDir, "proj"),
|
|
)
|
|
require.NoError(t, err, "reading dir")
|
|
for _, e := range entries {
|
|
p := filepath.Join(env.claudeDir, "proj", e.Name())
|
|
os.Remove(p)
|
|
}
|
|
|
|
stats := env.engine.ResyncAll(context.Background(), nil)
|
|
|
|
// Swap must be aborted.
|
|
hasAbortWarning := false
|
|
for _, w := range stats.Warnings {
|
|
if strings.Contains(w, "resync aborted") {
|
|
hasAbortWarning = true
|
|
}
|
|
}
|
|
assert.True(t, hasAbortWarning, "expected abort when discovery returns zero files "+"but old DB has sessions")
|
|
|
|
// Original data must be preserved.
|
|
assertSessionMessageCount(t, env.db, "keep", 2)
|
|
}
|
|
|
|
// TestResyncAllKiroSQLiteOnly verifies that current-store Kiro
|
|
// sessions do not trip the empty-discovery guard simply because
|
|
// they are DB-backed rather than JSONL-backed.
|
|
func TestResyncAllKiroSQLiteOnly(t *testing.T) {
|
|
|
|
env := setupSingleAgentTestEnv(t, parser.AgentKiro)
|
|
ks := createKiroSQLiteDB(t, env.kiroDir)
|
|
ks.addSession(
|
|
t, "/home/user/code/kiro-app", "kiro-resync-only",
|
|
readKiroSQLiteFixture(t, "standard_payload.json"),
|
|
1779012000000, 1779012030000,
|
|
)
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
agentviewID := "kiro:kiro-resync-only"
|
|
assertSessionMessageCount(t, env.db, agentviewID, 4)
|
|
|
|
stats := env.engine.ResyncAll(context.Background(), nil)
|
|
for _, w := range stats.Warnings {
|
|
require.False(t, strings.Contains(w, "resync aborted"), "ResyncAll aborted for Kiro SQLite-only dataset: %s", w)
|
|
}
|
|
require.NotZero(t, stats.Synced, "expected Kiro SQLite sessions to be synced")
|
|
assertSessionMessageCount(t, env.db, agentviewID, 4)
|
|
}
|
|
|
|
func TestResyncAllMixedOpenCodeRootsKeepsSQLiteFallback(t *testing.T) {
|
|
storageBase := t.TempDir()
|
|
storageRoot := filepath.Join(storageBase, "storage#root")
|
|
sqliteRoot := t.TempDir()
|
|
err := os.MkdirAll(filepath.Join(
|
|
storageRoot, "storage", "session", "global",
|
|
), 0o755)
|
|
require.NoError(t, err, "mkdir storage root")
|
|
|
|
env := setupTestEnv(
|
|
t, WithOpenCodeDirs([]string{storageRoot, sqliteRoot}),
|
|
)
|
|
|
|
oc := createOpenCodeDB(t, sqliteRoot)
|
|
oc.addProject(t, "proj-1", "/home/user/code/myapp")
|
|
|
|
sessionID := "oc-resync-sqlite-fallback"
|
|
var timeCreated int64 = 1704067200000
|
|
var timeUpdated int64 = 1704067205000
|
|
|
|
oc.addSession(
|
|
t, sessionID, "proj-1",
|
|
timeCreated, timeUpdated,
|
|
)
|
|
oc.addMessage(
|
|
t, "msg-u1", sessionID, "user", timeCreated,
|
|
)
|
|
oc.addMessage(
|
|
t, "msg-a1", sessionID, "assistant",
|
|
timeCreated+1,
|
|
)
|
|
oc.addTextPart(
|
|
t, "part-u1", sessionID, "msg-u1",
|
|
"hello sqlite fallback", timeCreated,
|
|
)
|
|
oc.addTextPart(
|
|
t, "part-a1", sessionID, "msg-a1",
|
|
"hi sqlite fallback", timeCreated+1,
|
|
)
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
agentviewID := "opencode:" + sessionID
|
|
assertSessionMessageCount(t, env.db, agentviewID, 2)
|
|
|
|
stats := env.engine.ResyncAll(context.Background(), nil)
|
|
|
|
for _, w := range stats.Warnings {
|
|
require.False(t, strings.Contains(w, "resync aborted"), "ResyncAll aborted for mixed OpenCode roots: %s", w)
|
|
}
|
|
require.NotZero(t, stats.Synced, "expected SQLite fallback OpenCode session to be synced")
|
|
|
|
assertSessionMessageCount(t, env.db, agentviewID, 2)
|
|
assertMessageContent(
|
|
t, env.db, agentviewID,
|
|
"hello sqlite fallback",
|
|
"hi sqlite fallback",
|
|
)
|
|
}
|
|
|
|
func TestResyncAllOpenCodeStorageArchiveHandlesSQLiteFallbackFreshness(
|
|
t *testing.T,
|
|
) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
storage := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
|
|
staleSessionID := "oc-storage-to-stale-sqlite"
|
|
storage.addSession(
|
|
t, "global", staleSessionID,
|
|
"/home/user/code/myapp", "Storage Then Stale SQLite",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
storage.addMessage(
|
|
t, staleSessionID, "msg-stale-u1", "user",
|
|
1704067200000, nil,
|
|
)
|
|
storage.addTextPart(
|
|
t, staleSessionID, "msg-stale-u1", "part-stale-u1",
|
|
"hello stale storage", 1704067200000,
|
|
)
|
|
newerSessionID := "oc-storage-to-newer-sqlite"
|
|
storage.addSession(
|
|
t, "global", newerSessionID,
|
|
"/home/user/code/myapp", "Storage Then Newer SQLite",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
storage.addMessage(
|
|
t, newerSessionID, "msg-newer-u1", "user",
|
|
1704067200000, nil,
|
|
)
|
|
storage.addTextPart(
|
|
t, newerSessionID, "msg-newer-u1", "part-newer-u1",
|
|
"hello newer storage", 1704067200000,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 2,
|
|
Synced: 2,
|
|
Skipped: 0,
|
|
})
|
|
|
|
err := os.RemoveAll(
|
|
filepath.Join(env.opencodeDir, "storage"),
|
|
)
|
|
require.NoError(t, err, "remove storage tree")
|
|
|
|
oc := createOpenCodeDB(t, env.opencodeDir)
|
|
oc.addProject(t, "proj-1", "/home/user/code/myapp")
|
|
oc.addSession(
|
|
t, staleSessionID, "proj-1",
|
|
1704067200000, 1704067209000,
|
|
)
|
|
oc.addMessage(
|
|
t, "msg-stale-u1", staleSessionID, "user",
|
|
1704067200000,
|
|
)
|
|
oc.addTextPart(
|
|
t, "part-stale-u1", staleSessionID, "msg-stale-u1",
|
|
"hello stale sqlite fallback", 1704067200000,
|
|
)
|
|
|
|
sqliteUpdatedAt := time.Now().Add(2 * time.Second).UnixMilli()
|
|
oc.addSession(
|
|
t, newerSessionID, "proj-1",
|
|
1704067200000, sqliteUpdatedAt,
|
|
)
|
|
oc.addMessage(
|
|
t, "msg-newer-u1", newerSessionID, "user",
|
|
1704067200000,
|
|
)
|
|
oc.addTextPart(
|
|
t, "part-newer-u1", newerSessionID, "msg-newer-u1",
|
|
"hello newer sqlite fallback", 1704067200000,
|
|
)
|
|
|
|
stats := env.engine.ResyncAll(context.Background(), nil)
|
|
for _, w := range stats.Warnings {
|
|
require.False(t, strings.Contains(w, "resync aborted"), "ResyncAll aborted for storage->sqlite freshness: %s", w)
|
|
}
|
|
require.NotZero(t, stats.Synced, "expected newer sqlite fallback to be synced")
|
|
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+staleSessionID,
|
|
"hello stale storage",
|
|
)
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+newerSessionID,
|
|
"hello newer sqlite fallback",
|
|
)
|
|
}
|
|
|
|
func TestResyncAllKiloStorageArchivePreservesStaleSQLiteFallback(
|
|
t *testing.T,
|
|
) {
|
|
|
|
env := setupSingleAgentTestEnv(t, parser.AgentKilo)
|
|
storage := createOpenCodeStorageFixture(t, env.kiloDir)
|
|
|
|
sessionID := "kilo-storage-to-sqlite"
|
|
storage.addSession(
|
|
t, "global", sessionID,
|
|
"/home/user/code/kilo-app", "Storage Then SQLite",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
storage.addMessage(
|
|
t, sessionID, "msg-u1", "user",
|
|
1704067200000, nil,
|
|
)
|
|
storage.addTextPart(
|
|
t, sessionID, "msg-u1", "part-u1",
|
|
"hello kilo storage", 1704067200000,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
|
|
err := os.RemoveAll(
|
|
filepath.Join(env.kiloDir, "storage"),
|
|
)
|
|
require.NoError(t, err, "remove storage tree")
|
|
|
|
sqlite := createKiloDB(t, env.kiloDir)
|
|
sqlite.addProject(t, "proj-1", "/home/user/code/kilo-app")
|
|
sqlite.addSession(
|
|
t, sessionID, "proj-1",
|
|
1704067200000, 1704067209000,
|
|
)
|
|
sqlite.addMessage(
|
|
t, "msg-u1", sessionID, "user",
|
|
1704067200000,
|
|
)
|
|
sqlite.addTextPart(
|
|
t, "part-u1", sessionID, "msg-u1",
|
|
"hello kilo sqlite fallback", 1704067200000,
|
|
)
|
|
|
|
stats := env.engine.ResyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "ResyncAll aborted for Kilo storage archive")
|
|
for _, w := range stats.Warnings {
|
|
require.False(t, strings.Contains(w, "resync aborted"), "ResyncAll aborted for Kilo storage->sqlite fallback: %s", w)
|
|
}
|
|
require.Equal(t, 0, stats.Synced, "stats.Synced = %d, want 0", stats.Synced)
|
|
|
|
assertMessageContent(
|
|
t, env.db, "kilo:"+sessionID,
|
|
"hello kilo storage",
|
|
)
|
|
|
|
sqliteOnlyID := "kilo-sqlite-only"
|
|
sqlite.addSession(
|
|
t, sqliteOnlyID, "proj-1",
|
|
1704067210000, 1704067215000,
|
|
)
|
|
sqlite.addMessage(
|
|
t, "sqlite-only-msg-a1", sqliteOnlyID, "assistant",
|
|
1704067211000,
|
|
)
|
|
sqlite.addTextPart(
|
|
t, "sqlite-only-part-a1", sqliteOnlyID, "sqlite-only-msg-a1",
|
|
"sqlite-only kilo reply", 1704067211000,
|
|
)
|
|
|
|
stats = env.engine.ResyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "ResyncAll aborted for Kilo SQLite-only session")
|
|
for _, w := range stats.Warnings {
|
|
require.False(t, strings.Contains(w, "resync aborted"), "ResyncAll aborted for Kilo SQLite-only session: %s", w)
|
|
}
|
|
require.NotZero(t, stats.Synced, "expected Kilo SQLite-only session to be synced")
|
|
assertMessageContent(
|
|
t, env.db, "kilo:"+sqliteOnlyID,
|
|
"sqlite-only kilo reply",
|
|
)
|
|
assertMessageContent(
|
|
t, env.db, "kilo:"+sessionID,
|
|
"hello kilo storage",
|
|
)
|
|
}
|
|
|
|
func TestResyncAllOpenCodeStorageMissingMessagePreservesArchive(
|
|
t *testing.T,
|
|
) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
oc := createOpenCodeStorageFixture(t, env.opencodeDir)
|
|
|
|
sessionID := "oc-resync-missing-message"
|
|
sessionPath := oc.addSession(
|
|
t, "global", sessionID,
|
|
"/home/user/code/myapp", "Resync Missing Message",
|
|
1704067200000, 1704067205000,
|
|
)
|
|
oc.addMessage(
|
|
t, sessionID, "msg-u1", "user",
|
|
1704067200000, nil,
|
|
)
|
|
oc.addTextPart(
|
|
t, sessionID, "msg-u1", "part-u1",
|
|
"question", 1704067200000,
|
|
)
|
|
messagePath := oc.addMessage(
|
|
t, sessionID, "msg-a1", "assistant",
|
|
1704067201000, nil,
|
|
)
|
|
oc.addTextPart(
|
|
t, sessionID, "msg-a1", "part-a1",
|
|
"answer", 1704067201000,
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: 1,
|
|
Skipped: 0,
|
|
})
|
|
|
|
require.NoError(t, os.Remove(messagePath), "remove message file")
|
|
future := time.Now().Add(2 * time.Second)
|
|
require.NoError(t, os.Chtimes(sessionPath, future, future), "touch session path")
|
|
|
|
stats := env.engine.ResyncAll(context.Background(), nil)
|
|
for _, w := range stats.Warnings {
|
|
require.False(t, strings.Contains(w, "resync aborted"), "ResyncAll aborted for missing OpenCode message: %s", w)
|
|
}
|
|
|
|
assertMessageContent(
|
|
t, env.db, "opencode:"+sessionID,
|
|
"question", "answer",
|
|
)
|
|
}
|
|
|
|
// TestResyncAllAbortsMixedSourceEmptyFiles verifies that
|
|
// ResyncAll aborts when the old DB has both file-backed and
|
|
// OpenCode sessions but file discovery returns zero (e.g.
|
|
// file dirs temporarily inaccessible). OpenCode sync
|
|
// succeeding must not mask the loss of file-backed sessions.
|
|
func TestResyncAllAbortsMixedSourceEmptyFiles(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
// Seed file-backed sessions.
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "file session").
|
|
AddClaudeAssistant(tsEarlyS5, "file reply").
|
|
String()
|
|
env.writeClaudeSession(
|
|
t, "proj", "mixed-file.jsonl", content,
|
|
)
|
|
|
|
// Seed OpenCode sessions.
|
|
oc := createOpenCodeDB(t, env.opencodeDir)
|
|
oc.addProject(t, "proj-1", "/home/user/code/myapp")
|
|
|
|
sessionID := "oc-mixed"
|
|
var timeCreated int64 = 1704067200000
|
|
var timeUpdated int64 = 1704067205000
|
|
|
|
oc.addSession(
|
|
t, sessionID, "proj-1",
|
|
timeCreated, timeUpdated,
|
|
)
|
|
oc.addMessage(
|
|
t, "msg-u1", sessionID, "user", timeCreated,
|
|
)
|
|
oc.addMessage(
|
|
t, "msg-a1", sessionID, "assistant",
|
|
timeCreated+1,
|
|
)
|
|
oc.addTextPart(
|
|
t, "part-u1", sessionID, "msg-u1",
|
|
"oc question", timeCreated,
|
|
)
|
|
oc.addTextPart(
|
|
t, "part-a1", sessionID, "msg-a1",
|
|
"oc answer", timeCreated+1,
|
|
)
|
|
|
|
// Initial sync: both sources.
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
assertSessionMessageCount(t, env.db, "mixed-file", 2)
|
|
assertSessionMessageCount(
|
|
t, env.db, "opencode:"+sessionID, 2,
|
|
)
|
|
|
|
// Remove all file-based sessions to simulate empty
|
|
// file discovery. OpenCode data remains.
|
|
entries, err := os.ReadDir(
|
|
filepath.Join(env.claudeDir, "proj"),
|
|
)
|
|
require.NoError(t, err, "reading dir")
|
|
for _, e := range entries {
|
|
p := filepath.Join(env.claudeDir, "proj", e.Name())
|
|
os.Remove(p)
|
|
}
|
|
|
|
stats := env.engine.ResyncAll(context.Background(), nil)
|
|
|
|
// Must abort: file-backed sessions would be lost.
|
|
hasAbortWarning := false
|
|
for _, w := range stats.Warnings {
|
|
if strings.Contains(w, "resync aborted") {
|
|
hasAbortWarning = true
|
|
}
|
|
}
|
|
assert.True(t, hasAbortWarning, "expected abort when file dirs are empty "+"but old DB has file-backed sessions")
|
|
|
|
// Both file-backed and OpenCode data preserved.
|
|
assertSessionMessageCount(t, env.db, "mixed-file", 2)
|
|
assertSessionMessageCount(
|
|
t, env.db, "opencode:"+sessionID, 2,
|
|
)
|
|
}
|
|
|
|
// TestNewEngineDefensiveCopy verifies that NewEngine deep-copies
|
|
// the AgentDirs map so that external mutations after construction
|
|
// do not affect the engine's behavior.
|
|
func TestNewEngineDefensiveCopy(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test")
|
|
}
|
|
|
|
claudeDir := t.TempDir()
|
|
database := dbtest.OpenTestDB(t)
|
|
|
|
dirs := map[parser.AgentType][]string{
|
|
parser.AgentClaude: {claudeDir},
|
|
}
|
|
engine := sync.NewEngine(database, sync.EngineConfig{
|
|
AgentDirs: dirs,
|
|
Machine: "local",
|
|
})
|
|
|
|
// Write a session the engine should find.
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "hello").
|
|
String()
|
|
path := filepath.Join(
|
|
claudeDir, "proj", "copy-test.jsonl",
|
|
)
|
|
dbtest.WriteTestFile(t, path, []byte(content))
|
|
|
|
// Mutate the original map after construction: clear
|
|
// the Claude dirs and add a bogus entry.
|
|
dirs[parser.AgentClaude] = nil
|
|
dirs[parser.AgentCodex] = []string{"/bogus"}
|
|
|
|
// Engine should still find the session via its own copy.
|
|
stats := engine.SyncAll(context.Background(), nil)
|
|
require.Equal(t, 1, stats.Synced, "Synced = %d, want 1 (engine used mutated map)", stats.Synced)
|
|
assertSessionMessageCount(t, database, "copy-test", 1)
|
|
|
|
// Verify slice-level aliasing is also prevented.
|
|
// Build a fresh engine where we mutate an element
|
|
// inside the original slice (not replace the slice).
|
|
claudeDir2 := t.TempDir()
|
|
sliceDirs := []string{claudeDir2}
|
|
dirs2 := map[parser.AgentType][]string{
|
|
parser.AgentClaude: sliceDirs,
|
|
}
|
|
db2 := dbtest.OpenTestDB(t)
|
|
engine2 := sync.NewEngine(db2, sync.EngineConfig{
|
|
AgentDirs: dirs2,
|
|
Machine: "local",
|
|
})
|
|
|
|
content2 := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "slice test").
|
|
String()
|
|
path2 := filepath.Join(
|
|
claudeDir2, "proj", "slice-test.jsonl",
|
|
)
|
|
dbtest.WriteTestFile(t, path2, []byte(content2))
|
|
|
|
// Mutate the element inside the original slice.
|
|
sliceDirs[0] = "/nonexistent"
|
|
|
|
stats2 := engine2.SyncAll(context.Background(), nil)
|
|
require.Equal(t, 1, stats2.Synced, "Synced = %d, want 1 (engine used aliased slice)", stats2.Synced)
|
|
assertSessionMessageCount(t, db2, "slice-test", 1)
|
|
}
|
|
|
|
// TestSyncPathsClaudeFallsThrough verifies that a file under
|
|
// a Claude root that fails the subagent shape check (non-agent-
|
|
// prefix in a subagents dir) is still checked against later
|
|
// agents when roots overlap.
|
|
func TestSyncPathsClaudeFallsThrough(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test")
|
|
}
|
|
|
|
// Claude root contains a path that looks like a subagent
|
|
// dir but the file doesn't have an agent- prefix. The Amp
|
|
// root is nested so the same file matches Amp's structure.
|
|
parent := t.TempDir()
|
|
claudeDir := parent
|
|
// Amp root: <claudeDir>/proj/sess/subagents
|
|
ampDir := filepath.Join(
|
|
claudeDir, "proj", "sess", "subagents",
|
|
)
|
|
|
|
database := dbtest.OpenTestDB(t)
|
|
engine := sync.NewEngine(database, sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentClaude: {claudeDir},
|
|
parser.AgentAmp: {ampDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
content := `{"id":"T-019ca26f-eeee-dddd-cccc-bbbbbbbbbbbb","created":1704103200000,"title":"Claude overlap","env":{"initial":{"trees":[{"displayName":"proj"}]}},"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]},{"role":"assistant","content":[{"type":"text","text":"hi"}]}]}`
|
|
|
|
// This path is 4 parts under claudeDir
|
|
// (proj/sess/subagents/T-*.json) and matches the
|
|
// subagent shape check, but the filename doesn't start
|
|
// with "agent-", so Claude rejects it. It should fall
|
|
// through to Amp.
|
|
ampPath := filepath.Join(
|
|
ampDir,
|
|
"T-019ca26f-eeee-dddd-cccc-bbbbbbbbbbbb.json",
|
|
)
|
|
dbtest.WriteTestFile(t, ampPath, []byte(content))
|
|
|
|
engine.SyncPaths([]string{ampPath})
|
|
|
|
assertSessionState(
|
|
t, database,
|
|
"amp:T-019ca26f-eeee-dddd-cccc-bbbbbbbbbbbb",
|
|
func(sess *db.Session) {
|
|
assert.Equal(t, "amp", sess.Agent, "agent = %q, want amp", sess.Agent)
|
|
},
|
|
)
|
|
}
|
|
|
|
// TestSyncPathsClassifyFallsThrough verifies that a file
|
|
// under a Cursor root that doesn't match the Cursor transcript
|
|
// structure is still checked against later agents (e.g. Amp).
|
|
// Before the fix, the Cursor block returned false immediately,
|
|
// preventing any subsequent agent from matching.
|
|
func TestSyncPathsClassifyFallsThrough(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test")
|
|
}
|
|
|
|
// Use a shared parent dir so both agent roots overlap:
|
|
// cursorDir = parent/cursor
|
|
// ampDir = parent/cursor/nested-amp
|
|
// A valid Amp file under ampDir also lives under
|
|
// cursorDir but doesn't match the Cursor structure.
|
|
parent := t.TempDir()
|
|
cursorDir := filepath.Join(parent, "cursor")
|
|
ampDir := filepath.Join(cursorDir, "nested-amp")
|
|
|
|
database := dbtest.OpenTestDB(t)
|
|
engine := sync.NewEngine(database, sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentCursor: {cursorDir},
|
|
parser.AgentAmp: {ampDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
content := `{"id":"T-019ca26f-ffff-aaaa-bbbb-cccccccccccc","created":1704103200000,"title":"Overlap test","env":{"initial":{"trees":[{"displayName":"proj"}]}},"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]},{"role":"assistant","content":[{"type":"text","text":"hi"}]}]}`
|
|
|
|
ampPath := filepath.Join(
|
|
ampDir,
|
|
"T-019ca26f-ffff-aaaa-bbbb-cccccccccccc.json",
|
|
)
|
|
dbtest.WriteTestFile(t, ampPath, []byte(content))
|
|
|
|
engine.SyncPaths([]string{ampPath})
|
|
|
|
assertSessionState(
|
|
t, database,
|
|
"amp:T-019ca26f-ffff-aaaa-bbbb-cccccccccccc",
|
|
func(sess *db.Session) {
|
|
assert.Equal(t, "amp", sess.Agent, "agent = %q, want amp", sess.Agent)
|
|
},
|
|
)
|
|
}
|
|
|
|
func TestSyncPathsVSCodeCopilotJSONLPriority(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
vscDir := filepath.Join(dir, "vscode")
|
|
chatDir := filepath.Join(
|
|
vscDir, "workspaceStorage", "abc123",
|
|
"chatSessions",
|
|
)
|
|
|
|
database := dbtest.OpenTestDB(t)
|
|
engine := sync.NewEngine(database, sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentVSCodeCopilot: {vscDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
uuid := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
|
session := fmt.Sprintf(
|
|
`{"version":1,"sessionId":"%s",`+
|
|
`"creationDate":1704103200000,`+
|
|
`"lastMessageDate":1704103260000,`+
|
|
`"requests":[{"requestId":"r1",`+
|
|
`"message":{"text":"hello"},`+
|
|
`"response":[{"value":"hi"}],`+
|
|
`"timestamp":1704103200000}]}`,
|
|
uuid,
|
|
)
|
|
|
|
jsonPath := filepath.Join(chatDir, uuid+".json")
|
|
jsonlPath := filepath.Join(chatDir, uuid+".jsonl")
|
|
dbtest.WriteTestFile(t, jsonPath, []byte(session))
|
|
dbtest.WriteTestFile(
|
|
t, jsonlPath,
|
|
[]byte(`{"kind":0,"v":`+session+`}`),
|
|
)
|
|
|
|
// Sync the .json path; classifier should skip it
|
|
// because a .jsonl sibling exists.
|
|
engine.SyncPaths([]string{jsonPath})
|
|
|
|
ctx := context.Background()
|
|
page, err := database.ListSessions(
|
|
ctx, db.SessionFilter{Limit: 10},
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 0, len(page.Sessions), "expected 0 sessions (.json skipped), got %d", len(page.Sessions))
|
|
}
|
|
|
|
func TestSyncPathsVSCodeCopilotWorkspaceMetadataRefreshesProject(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
vscDir := filepath.Join(dir, "vscode")
|
|
hashDir := filepath.Join(vscDir, "workspaceStorage", "abc123")
|
|
chatDir := filepath.Join(hashDir, "chatSessions")
|
|
workspacePath := filepath.Join(hashDir, "workspace.json")
|
|
|
|
database := dbtest.OpenTestDB(t)
|
|
engine := sync.NewEngine(database, sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentVSCodeCopilot: {vscDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
writeWorkspace := func(name string) {
|
|
t.Helper()
|
|
dbtest.WriteTestFile(t, workspacePath, fmt.Appendf(nil,
|
|
`{"folder":"file:///Users/alice/code/%s"}`,
|
|
name,
|
|
))
|
|
}
|
|
|
|
uuid := "bbbbbbbb-cccc-dddd-eeee-ffffffffffff"
|
|
session := fmt.Sprintf(
|
|
`{"version":1,"sessionId":"%s",`+
|
|
`"creationDate":1704103200000,`+
|
|
`"lastMessageDate":1704103260000,`+
|
|
`"requests":[{"requestId":"r1",`+
|
|
`"message":{"text":"hello"},`+
|
|
`"response":[{"value":"hi"}],`+
|
|
`"timestamp":1704103200000}]}`,
|
|
uuid,
|
|
)
|
|
jsonlPath := filepath.Join(chatDir, uuid+".jsonl")
|
|
|
|
writeWorkspace("one")
|
|
dbtest.WriteTestFile(
|
|
t, jsonlPath,
|
|
[]byte(`{"kind":0,"v":`+session+`}`),
|
|
)
|
|
|
|
engine.SyncPaths([]string{jsonlPath})
|
|
assertSessionState(
|
|
t, database, "vscode-copilot:"+uuid,
|
|
func(sess *db.Session) {
|
|
assert.Equal(t, "one", sess.Project)
|
|
},
|
|
)
|
|
|
|
info, err := os.Stat(jsonlPath)
|
|
require.NoError(t, err, "stat vscode copilot session")
|
|
engine.InjectSkipCache(map[string]int64{
|
|
jsonlPath: info.ModTime().UnixNano(),
|
|
})
|
|
|
|
writeWorkspace("two")
|
|
engine.SyncPaths([]string{workspacePath})
|
|
assertSessionState(
|
|
t, database, "vscode-copilot:"+uuid,
|
|
func(sess *db.Session) {
|
|
assert.Equal(t, "two", sess.Project)
|
|
},
|
|
)
|
|
|
|
writeWorkspace("three")
|
|
engine.SyncPaths([]string{jsonlPath, workspacePath})
|
|
assertSessionState(
|
|
t, database, "vscode-copilot:"+uuid,
|
|
func(sess *db.Session) {
|
|
assert.Equal(t, "three", sess.Project)
|
|
},
|
|
)
|
|
}
|
|
|
|
func TestSyncPathsVSCodeCopilotPersistsUsageEvents(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
vscDir := filepath.Join(dir, "vscode")
|
|
chatDir := filepath.Join(
|
|
vscDir, "workspaceStorage", "abc123",
|
|
"chatSessions",
|
|
)
|
|
|
|
database := dbtest.OpenTestDB(t)
|
|
engine := sync.NewEngine(database, sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentVSCodeCopilot: {vscDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
uuid := "cccccccc-dddd-eeee-ffff-000000000000"
|
|
session := fmt.Sprintf(
|
|
`{"version":3,"sessionId":"%s",`+
|
|
`"creationDate":1704103200000,`+
|
|
`"lastMessageDate":1704103260000,`+
|
|
`"requests":[{"requestId":"r1",`+
|
|
`"message":{"text":"hello"},`+
|
|
`"response":[{"value":"hi"}],`+
|
|
`"timestamp":1704103200000,`+
|
|
`"modelId":"copilot/claude-opus-4.8",`+
|
|
`"result":{"metadata":{`+
|
|
`"promptTokens":12,`+
|
|
`"outputTokens":3,`+
|
|
`"resolvedModel":"claude-opus-4-8"}}}]}`,
|
|
uuid,
|
|
)
|
|
jsonPath := filepath.Join(chatDir, uuid+".json")
|
|
dbtest.WriteTestFile(t, jsonPath, []byte(session))
|
|
|
|
engine.SyncPaths([]string{jsonPath})
|
|
|
|
ctx := context.Background()
|
|
sessionID := "vscode-copilot:" + uuid
|
|
events, err := database.GetUsageEvents(ctx, sessionID)
|
|
require.NoError(t, err)
|
|
require.Len(t, events, 1)
|
|
assert.Equal(t, "vscode-copilot", events[0].Source)
|
|
assert.Equal(t, "claude-opus-4-8", events[0].Model)
|
|
assert.Equal(t, 12, events[0].InputTokens)
|
|
assert.Equal(t, 3, events[0].OutputTokens)
|
|
|
|
require.NoError(t, engine.SyncSingleSession(sessionID))
|
|
events, err = database.GetUsageEvents(ctx, sessionID)
|
|
require.NoError(t, err)
|
|
require.Len(t, events, 1)
|
|
assert.Equal(t, "claude-opus-4-8", events[0].Model)
|
|
}
|
|
|
|
func TestSyncPathsPositronJSONLPriority(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
positronDir := filepath.Join(dir, "positron")
|
|
chatDir := filepath.Join(
|
|
positronDir, "workspaceStorage", "abc123",
|
|
"chatSessions",
|
|
)
|
|
|
|
database := dbtest.OpenTestDB(t)
|
|
engine := sync.NewEngine(database, sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentPositron: {positronDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
uuid := "cccccccc-dddd-eeee-ffff-aaaaaaaaaaaa"
|
|
session := fmt.Sprintf(
|
|
`{"version":1,"sessionId":"%s",`+
|
|
`"creationDate":1704103200000,`+
|
|
`"lastMessageDate":1704103260000,`+
|
|
`"requests":[{"requestId":"r1",`+
|
|
`"message":{"text":"hello"},`+
|
|
`"response":[{"value":"hi"}],`+
|
|
`"timestamp":1704103200000}]}`,
|
|
uuid,
|
|
)
|
|
|
|
jsonPath := filepath.Join(chatDir, uuid+".json")
|
|
jsonlPath := filepath.Join(chatDir, uuid+".jsonl")
|
|
dbtest.WriteTestFile(t, jsonPath, []byte(session))
|
|
dbtest.WriteTestFile(
|
|
t, jsonlPath,
|
|
[]byte(`{"kind":0,"v":`+session+`}`),
|
|
)
|
|
|
|
engine.SyncPaths([]string{jsonPath})
|
|
|
|
page, err := database.ListSessions(
|
|
context.Background(), db.SessionFilter{Limit: 10},
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 0, len(page.Sessions), "expected 0 sessions (.json skipped), got %d", len(page.Sessions))
|
|
}
|
|
|
|
func TestSyncAllPositronJSONLPriority(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
positronDir := filepath.Join(dir, "positron")
|
|
hashDir := filepath.Join(positronDir, "workspaceStorage", "abc123")
|
|
chatDir := filepath.Join(hashDir, "chatSessions")
|
|
|
|
database := dbtest.OpenTestDB(t)
|
|
engine := sync.NewEngine(database, sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentPositron: {positronDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
uuid := "cccccccc-dddd-eeee-ffff-bbbbbbbbbbbb"
|
|
jsonSession := fmt.Sprintf(
|
|
`{"version":1,"sessionId":"%s",`+
|
|
`"creationDate":1704103200000,`+
|
|
`"lastMessageDate":1704103260000,`+
|
|
`"requests":[{"requestId":"r1",`+
|
|
`"message":{"text":"json fallback"},`+
|
|
`"response":[{"value":"json response"}],`+
|
|
`"timestamp":1704103200000}]}`,
|
|
uuid,
|
|
)
|
|
jsonlSession := fmt.Sprintf(
|
|
`{"version":1,"sessionId":"%s",`+
|
|
`"creationDate":1704103200000,`+
|
|
`"lastMessageDate":1704103260000,`+
|
|
`"requests":[{"requestId":"r1",`+
|
|
`"message":{"text":"jsonl preferred"},`+
|
|
`"response":[{"value":"jsonl response"}],`+
|
|
`"timestamp":1704103200000}]}`,
|
|
uuid,
|
|
)
|
|
|
|
jsonPath := filepath.Join(chatDir, uuid+".json")
|
|
jsonlPath := filepath.Join(chatDir, uuid+".jsonl")
|
|
dbtest.WriteTestFile(t, jsonPath, []byte(jsonSession))
|
|
dbtest.WriteTestFile(
|
|
t, jsonlPath,
|
|
[]byte(`{"kind":0,"v":`+jsonlSession+`}`),
|
|
)
|
|
|
|
stats := engine.SyncAll(context.Background(), nil)
|
|
assert.Equal(t, 1, stats.Synced, "synced = %d, want 1", stats.Synced)
|
|
|
|
sess, err := database.GetSession(context.Background(), "positron:"+uuid)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
assertSessionMessageCount(t, database, "positron:"+uuid, 2)
|
|
msgs := fetchMessages(t, database, "positron:"+uuid)
|
|
require.NotEmpty(t, msgs)
|
|
assert.Equal(t, "jsonl preferred", msgs[0].Content)
|
|
}
|
|
|
|
func TestSyncPathsPositronWorkspaceMetadataRefreshesProject(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
positronDir := filepath.Join(dir, "positron")
|
|
hashDir := filepath.Join(positronDir, "workspaceStorage", "abc123")
|
|
chatDir := filepath.Join(hashDir, "chatSessions")
|
|
workspacePath := filepath.Join(hashDir, "workspace.json")
|
|
|
|
database := dbtest.OpenTestDB(t)
|
|
engine := sync.NewEngine(database, sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentPositron: {positronDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
writeWorkspace := func(name string) {
|
|
t.Helper()
|
|
dbtest.WriteTestFile(t, workspacePath, fmt.Appendf(nil,
|
|
`{"folder":"file:///Users/alice/code/%s"}`,
|
|
name,
|
|
))
|
|
}
|
|
|
|
uuid := "dddddddd-eeee-ffff-aaaa-bbbbbbbbbbbb"
|
|
session := fmt.Sprintf(
|
|
`{"version":1,"sessionId":"%s",`+
|
|
`"creationDate":1704103200000,`+
|
|
`"lastMessageDate":1704103260000,`+
|
|
`"requests":[{"requestId":"r1",`+
|
|
`"message":{"text":"hello"},`+
|
|
`"response":[{"value":"hi"}],`+
|
|
`"timestamp":1704103200000}]}`,
|
|
uuid,
|
|
)
|
|
jsonlPath := filepath.Join(chatDir, uuid+".jsonl")
|
|
|
|
writeWorkspace("one")
|
|
dbtest.WriteTestFile(
|
|
t, jsonlPath,
|
|
[]byte(`{"kind":0,"v":`+session+`}`),
|
|
)
|
|
|
|
engine.SyncPaths([]string{jsonlPath})
|
|
assertSessionState(
|
|
t, database, "positron:"+uuid,
|
|
func(sess *db.Session) {
|
|
assert.Equal(t, "one", sess.Project)
|
|
},
|
|
)
|
|
|
|
info, err := os.Stat(jsonlPath)
|
|
require.NoError(t, err, "stat positron session")
|
|
engine.InjectSkipCache(map[string]int64{
|
|
jsonlPath: info.ModTime().UnixNano(),
|
|
})
|
|
|
|
writeWorkspace("two")
|
|
engine.SyncPaths([]string{workspacePath})
|
|
assertSessionState(
|
|
t, database, "positron:"+uuid,
|
|
func(sess *db.Session) {
|
|
assert.Equal(t, "two", sess.Project)
|
|
},
|
|
)
|
|
|
|
writeWorkspace("three")
|
|
engine.SyncPaths([]string{jsonlPath, workspacePath})
|
|
assertSessionState(
|
|
t, database, "positron:"+uuid,
|
|
func(sess *db.Session) {
|
|
assert.Equal(t, "three", sess.Project)
|
|
},
|
|
)
|
|
}
|
|
|
|
func TestSyncAllSincePositronWorkspaceMetadataRefreshesProject(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
positronDir := filepath.Join(dir, "positron")
|
|
hashDir := filepath.Join(positronDir, "workspaceStorage", "abc123")
|
|
chatDir := filepath.Join(hashDir, "chatSessions")
|
|
workspacePath := filepath.Join(hashDir, "workspace.json")
|
|
|
|
database := dbtest.OpenTestDB(t)
|
|
engine := sync.NewEngine(database, sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentPositron: {positronDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
writeWorkspace := func(name string) {
|
|
t.Helper()
|
|
dbtest.WriteTestFile(t, workspacePath, fmt.Appendf(nil,
|
|
`{"folder":"file:///Users/alice/code/%s"}`,
|
|
name,
|
|
))
|
|
}
|
|
|
|
uuid := "dddddddd-eeee-ffff-aaaa-cccccccccccc"
|
|
session := fmt.Sprintf(
|
|
`{"version":1,"sessionId":"%s",`+
|
|
`"creationDate":1704103200000,`+
|
|
`"lastMessageDate":1704103260000,`+
|
|
`"requests":[{"requestId":"r1",`+
|
|
`"message":{"text":"hello"},`+
|
|
`"response":[{"value":"hi"}],`+
|
|
`"timestamp":1704103200000}]}`,
|
|
uuid,
|
|
)
|
|
jsonlPath := filepath.Join(chatDir, uuid+".jsonl")
|
|
|
|
writeWorkspace("one")
|
|
dbtest.WriteTestFile(
|
|
t, jsonlPath,
|
|
[]byte(`{"kind":0,"v":`+session+`}`),
|
|
)
|
|
|
|
engine.SyncAll(context.Background(), nil)
|
|
assertSessionState(
|
|
t, database, "positron:"+uuid,
|
|
func(sess *db.Session) {
|
|
assert.Equal(t, "one", sess.Project)
|
|
},
|
|
)
|
|
|
|
oldTime := time.Now().Add(-48 * time.Hour)
|
|
require.NoError(t, os.Chtimes(jsonlPath, oldTime, oldTime), "chtimes session")
|
|
require.NoError(t, os.Chtimes(workspacePath, oldTime, oldTime), "chtimes workspace")
|
|
cutoff := time.Now().Add(-1 * time.Hour)
|
|
|
|
writeWorkspace("two")
|
|
stats := engine.SyncAllSince(context.Background(), cutoff, nil)
|
|
assert.Equal(t, 1, stats.Synced, "synced = %d, want 1", stats.Synced)
|
|
|
|
assertSessionState(
|
|
t, database, "positron:"+uuid,
|
|
func(sess *db.Session) {
|
|
assert.Equal(t, "two", sess.Project)
|
|
},
|
|
)
|
|
}
|
|
|
|
func TestPiSessionIntegration(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test")
|
|
}
|
|
|
|
// Build a temp pi session directory:
|
|
// <piDir>/<encoded-cwd>/<session-id>.jsonl
|
|
piDir := t.TempDir()
|
|
cwdSubdir := filepath.Join(piDir, "--Users-alice-code-my-project")
|
|
require.NoError(t, os.MkdirAll(cwdSubdir, 0o755))
|
|
|
|
// Use the existing pi session fixture from the parser testdata.
|
|
// The fixture has id="pi-test-session-uuid".
|
|
_, callerFile, _, _ := runtime.Caller(0)
|
|
fixtureDir := filepath.Join(
|
|
filepath.Dir(callerFile), "..", "parser", "testdata", "pi",
|
|
)
|
|
fixtureContent, err := os.ReadFile(
|
|
filepath.Join(fixtureDir, "session.jsonl"),
|
|
)
|
|
require.NoError(t, err, "reading pi fixture")
|
|
|
|
sessionFile := filepath.Join(cwdSubdir, "pi-test-session-uuid.jsonl")
|
|
dbtest.WriteTestFile(t, sessionFile, fixtureContent)
|
|
|
|
database := dbtest.OpenTestDB(t)
|
|
engine := sync.NewEngine(database, sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentPi: {piDir},
|
|
},
|
|
Machine: "local",
|
|
})
|
|
|
|
stats := engine.SyncAll(context.Background(), nil)
|
|
require.Equal(t, 1, stats.Synced, "expected 1 synced session, got %d (failed=%d)", stats.Synced, stats.Failed)
|
|
|
|
assertSessionState(t, database, "pi:pi-test-session-uuid",
|
|
func(sess *db.Session) {
|
|
assert.Equal(t, "pi", sess.Agent, "agent = %q, want %q", sess.Agent, "pi")
|
|
// The fixture has 2 real user messages. model_change and
|
|
// compaction entries must not inflate the count after
|
|
// postFilterCounts re-counts role="user" messages.
|
|
assert.Equal(t, 2, sess.UserMessageCount, "UserMessageCount = %d, want 2", sess.UserMessageCount)
|
|
},
|
|
)
|
|
|
|
// FindSourceFile should locate pi sessions via the "pi:" prefix.
|
|
src := engine.FindSourceFile("pi:pi-test-session-uuid")
|
|
assert.NotEmpty(t, src, "FindSourceFile returned empty for pi session")
|
|
|
|
// SyncSingleSession should work for pi sessions.
|
|
require.NoError(t, engine.SyncSingleSession("pi:pi-test-session-uuid"), "SyncSingleSession pi")
|
|
assertSessionState(t, database, "pi:pi-test-session-uuid",
|
|
func(sess *db.Session) {
|
|
assert.Equal(t, "pi", sess.Agent, "after SyncSingleSession: agent = %q, want %q", sess.Agent, "pi")
|
|
},
|
|
)
|
|
}
|
|
|
|
func TestOMPSyncAllAndChangedPathUseProvider(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
path := env.writeSession(
|
|
t,
|
|
env.ompDir,
|
|
filepath.Join("encoded-cwd", "omp-sync.jsonl"),
|
|
piLikeProviderFixture("omp-sync", "/Users/alice/code/omp-app"),
|
|
)
|
|
|
|
runSyncAndAssert(t, env.engine, sync.SyncStats{
|
|
TotalSessions: 1, Synced: 1,
|
|
})
|
|
assertSessionState(t, env.db, "omp:omp-sync", func(sess *db.Session) {
|
|
assert.Equal(t, "omp", sess.Agent)
|
|
assert.Equal(t, "omp_app", sess.Project)
|
|
})
|
|
assert.Equal(t, path, env.engine.FindSourceFile("omp:omp-sync"))
|
|
|
|
updated := piLikeProviderFixture("omp-sync", "/Users/alice/code/omp-renamed")
|
|
dbtest.WriteTestFile(t, path, []byte(updated))
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
assertSessionState(t, env.db, "omp:omp-sync", func(sess *db.Session) {
|
|
assert.Equal(t, "omp", sess.Agent)
|
|
assert.Equal(t, "omp_renamed", sess.Project)
|
|
})
|
|
}
|
|
|
|
func piLikeProviderFixture(sessionID, cwd string) string {
|
|
return strings.Join([]string{
|
|
`{"type":"session","version":3,"id":"` + sessionID + `","timestamp":"2025-01-01T10:00:00Z","cwd":"` + cwd + `"}`,
|
|
`{"type":"message","id":"msg-1","timestamp":"2025-01-01T10:00:01Z","message":{"role":"user","content":"Inspect the source."}}`,
|
|
`{"type":"message","id":"msg-2","timestamp":"2025-01-01T10:00:02Z","message":{"role":"assistant","content":"Ready.","model":"claude-opus-4-5"}}`,
|
|
}, "\n")
|
|
}
|
|
|
|
func TestIncrementalSync_ClaudeAppend(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
// Initial sync: one user message.
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("hello", tsZero),
|
|
)
|
|
path := env.writeClaudeSession(
|
|
t, "proj", "inc-test.jsonl", initial,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
assertSessionMessageCount(t, env.db, "inc-test", 1)
|
|
assertMessageRoles(t, env.db, "inc-test", "user")
|
|
msgs := fetchMessages(t, env.db, "inc-test")
|
|
require.Equal(t, "inc-test", msgs[0].SessionID, "msgs[0].SessionID = %q, want inc-test", msgs[0].SessionID)
|
|
|
|
// Verify metadata is set from full parse.
|
|
full, err := env.db.GetSessionFull(
|
|
context.Background(), "inc-test",
|
|
)
|
|
require.NoError(t, err, "GetSessionFull")
|
|
require.NotNil(t, full.FileHash, "file_hash not set after full parse")
|
|
require.NotEmpty(t, *full.FileHash, "file_hash not set after full parse")
|
|
origHash := *full.FileHash
|
|
|
|
// Append an assistant response.
|
|
appendedJSON, err := json.Marshal(map[string]any{
|
|
"type": "assistant",
|
|
"timestamp": tsZeroS5,
|
|
"message": map[string]any{
|
|
"model": "claude-sonnet-4-20250514",
|
|
"usage": map[string]any{
|
|
"input_tokens": 100,
|
|
"cache_creation_input_tokens": 200,
|
|
"cache_read_input_tokens": 200,
|
|
"output_tokens": 200,
|
|
},
|
|
"content": []map[string]any{
|
|
{"type": "text", "text": "world"},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err, "marshal assistant fixture")
|
|
appended := string(appendedJSON) + "\n"
|
|
f, err := os.OpenFile(
|
|
path, os.O_APPEND|os.O_WRONLY, 0o644,
|
|
)
|
|
require.NoError(t, err, "open for append")
|
|
_, err = f.WriteString(appended)
|
|
f.Close()
|
|
require.NoError(t, err, "append")
|
|
|
|
// SyncPaths triggers incremental parse.
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
// Session count updated.
|
|
assertSessionMessageCount(t, env.db, "inc-test", 2)
|
|
assertMessageRoles(
|
|
t, env.db, "inc-test", "user", "assistant",
|
|
)
|
|
|
|
// New message has correct session_id.
|
|
msgs = fetchMessages(t, env.db, "inc-test")
|
|
for i, m := range msgs {
|
|
assert.Equal(t, "inc-test", m.SessionID, "msgs[%d].SessionID = %q, want inc-test", i, m.SessionID)
|
|
}
|
|
|
|
// file_hash is refreshed to the appended content's fingerprint (not
|
|
// cleared, not left on the pre-append snapshot) so the freshness gate can
|
|
// use it to detect a later same-size in-place rewrite.
|
|
updated, err := env.db.GetSessionFull(
|
|
context.Background(), "inc-test",
|
|
)
|
|
require.NoError(t, err, "GetSessionFull after incremental")
|
|
require.NotNil(t, updated.FileHash, "file_hash = nil, want appended-content hash")
|
|
require.NotEmpty(t, *updated.FileHash, "file_hash empty after incremental append")
|
|
assert.NotEqual(t, origHash, *updated.FileHash,
|
|
"file_hash = %q, want it refreshed away from the pre-append snapshot", origHash)
|
|
wantHash, err := sync.ComputeFileHash(path)
|
|
require.NoError(t, err, "hash appended file")
|
|
assert.Equal(t, wantHash, *updated.FileHash,
|
|
"file_hash does not match the appended file content")
|
|
assert.True(t, updated.HasTotalOutputTokens, "HasTotalOutputTokens = false, want true")
|
|
assert.True(t, updated.HasPeakContextTokens, "HasPeakContextTokens = false, want true")
|
|
assert.Equal(t, 200, updated.TotalOutputTokens, "TotalOutputTokens = %d, want 200", updated.TotalOutputTokens)
|
|
assert.Equal(t, 500, updated.PeakContextTokens, "PeakContextTokens = %d, want 500", updated.PeakContextTokens)
|
|
assert.True(t, msgs[1].HasContextTokens, "assistant HasContextTokens = false, want true")
|
|
assert.True(t, msgs[1].HasOutputTokens, "assistant HasOutputTokens = false, want true")
|
|
assert.Equal(t, 200, msgs[1].OutputTokens, "assistant OutputTokens = %d, want 200", msgs[1].OutputTokens)
|
|
assert.Equal(t, 500, msgs[1].ContextTokens, "assistant ContextTokens = %d, want 500", msgs[1].ContextTokens)
|
|
}
|
|
|
|
func TestIncrementalSync_ClaudeFilteredTailAdvancesNextOrdinal(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
initial := testjsonl.JoinJSONL(
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:00Z","uuid":"u1","message":{"content":"go"},"cwd":"/tmp"}`,
|
|
)
|
|
path := env.writeClaudeSession(
|
|
t, "proj", "filtered-tail.jsonl", initial,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
firstAppend := testjsonl.JoinJSONL(
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:01Z","uuid":"a1","parentUuid":"u1","message":{"content":[{"type":"tool_use","id":"toolu_pair","name":"Read","input":{"file_path":"README.md"}}],"usage":{"input_tokens":1,"output_tokens":1},"stop_reason":"tool_use"}}`,
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:02Z","uuid":"r1","parentUuid":"a1","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_pair","content":"ok"}]}}`,
|
|
) + "\n"
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err, "open first append")
|
|
_, err = f.WriteString(firstAppend)
|
|
require.NoError(t, err, "write first append")
|
|
require.NoError(t, f.Close())
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
secondAppend := testjsonl.JoinJSONL(
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:03Z","uuid":"a2","parentUuid":"r1","message":{"content":[{"type":"text","text":"done"}],"usage":{"input_tokens":1,"output_tokens":2},"stop_reason":"end_turn"}}`,
|
|
) + "\n"
|
|
f, err = os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err, "open second append")
|
|
_, err = f.WriteString(secondAppend)
|
|
require.NoError(t, err, "write second append")
|
|
require.NoError(t, f.Close())
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
msgs := fetchMessages(t, env.db, "filtered-tail")
|
|
require.Len(t, msgs, 3)
|
|
assert.Equal(t, 0, msgs[0].Ordinal, "ordinal 0")
|
|
assert.Equal(t, 1, msgs[1].Ordinal, "ordinal 1")
|
|
assert.Equal(t, 3, msgs[2].Ordinal, "ordinal 2")
|
|
}
|
|
|
|
func TestIncrementalSync_ClaudeQueueOperationPreservesSubagentMapping(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
initial := testjsonl.JoinJSONL(
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:00Z","uuid":"u1","message":{"content":"go"},"cwd":"/tmp"}`,
|
|
)
|
|
path := env.writeClaudeSession(
|
|
t, "proj", "queued-subagent.jsonl", initial,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
appended := testjsonl.JoinJSONL(
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:01Z","uuid":"a1","parentUuid":"u1","message":{"content":[{"type":"tool_use","id":"toolu_queue","name":"Agent","input":{"description":"inspect","subagent_type":"Explore","prompt":"inspect"}}],"usage":{"input_tokens":1,"output_tokens":1},"stop_reason":"tool_use"}}`,
|
|
`{"type":"queue-operation","operation":"enqueue","timestamp":"2024-01-01T10:00:02Z","sessionId":"queued-subagent","content":"{\"task_id\":\"childqueue\",\"tool_use_id\":\"toolu_queue\",\"description\":\"inspect\",\"task_type\":\"local_agent\"}"}`,
|
|
) + "\n"
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err, "open for append")
|
|
_, err = f.WriteString(appended)
|
|
require.NoError(t, err, "append queue mapping")
|
|
require.NoError(t, f.Close())
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
msgs := fetchMessages(t, env.db, "queued-subagent")
|
|
require.Len(t, msgs, 2)
|
|
require.Len(t, msgs[1].ToolCalls, 1)
|
|
assert.Equal(
|
|
t,
|
|
"agent-childqueue",
|
|
msgs[1].ToolCalls[0].SubagentSessionID,
|
|
"subagent_session_id",
|
|
)
|
|
}
|
|
|
|
func TestIncrementalSync_ClaudeQueueOperationOnlyRepairsStoredSubagentMapping(
|
|
t *testing.T,
|
|
) {
|
|
env := setupTestEnv(t)
|
|
|
|
initial := testjsonl.JoinJSONL(
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:00Z","uuid":"u1","message":{"content":"go"},"cwd":"/tmp"}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:01Z","uuid":"a1","parentUuid":"u1","message":{"content":[{"type":"tool_use","id":"toolu_queue_only","name":"Agent","input":{"description":"inspect","subagent_type":"Explore","prompt":"inspect"}}],"usage":{"input_tokens":1,"output_tokens":1},"stop_reason":"tool_use"}}`,
|
|
)
|
|
path := env.writeClaudeSession(
|
|
t, "proj", "queued-subagent-split.jsonl", initial,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
appended := testjsonl.JoinJSONL(
|
|
`{"type":"queue-operation","operation":"enqueue","timestamp":"2024-01-01T10:00:02Z","sessionId":"queued-subagent-split","content":"{\"task_id\":\"childqueueonly\",\"tool_use_id\":\"toolu_queue_only\",\"description\":\"inspect\",\"task_type\":\"local_agent\"}"}`,
|
|
) + "\n"
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err, "open for append")
|
|
_, err = f.WriteString(appended)
|
|
require.NoError(t, err, "append queue mapping")
|
|
require.NoError(t, f.Close())
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
msgs := fetchMessages(t, env.db, "queued-subagent-split")
|
|
require.Len(t, msgs, 2)
|
|
require.Len(t, msgs[1].ToolCalls, 1)
|
|
assert.Equal(
|
|
t,
|
|
"agent-childqueueonly",
|
|
msgs[1].ToolCalls[0].SubagentSessionID,
|
|
"subagent_session_id",
|
|
)
|
|
}
|
|
|
|
func TestIncrementalSync_ClaudeProgressOnlyRepairsStoredSubagentMapping(
|
|
t *testing.T,
|
|
) {
|
|
env := setupTestEnv(t)
|
|
|
|
initial := testjsonl.JoinJSONL(
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:00Z","uuid":"u1","message":{"content":"go"},"cwd":"/tmp"}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:01Z","uuid":"a1","parentUuid":"u1","message":{"content":[{"type":"tool_use","id":"toolu_progress_only","name":"Agent","input":{"description":"inspect","subagent_type":"Explore","prompt":"inspect"}}],"usage":{"input_tokens":1,"output_tokens":1},"stop_reason":"tool_use"}}`,
|
|
)
|
|
path := env.writeClaudeSession(
|
|
t, "proj", "progress-subagent-split.jsonl", initial,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
appended := testjsonl.JoinJSONL(
|
|
`{"type":"progress","timestamp":"2024-01-01T10:00:02Z","parentToolUseID":"toolu_progress_only","data":{"type":"agent_progress","agentId":"childprogressonly"}}`,
|
|
) + "\n"
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err, "open for append")
|
|
_, err = f.WriteString(appended)
|
|
require.NoError(t, err, "append progress mapping")
|
|
require.NoError(t, f.Close())
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
msgs := fetchMessages(t, env.db, "progress-subagent-split")
|
|
require.Len(t, msgs, 2)
|
|
require.Len(t, msgs[1].ToolCalls, 1)
|
|
assert.Equal(
|
|
t,
|
|
"agent-childprogressonly",
|
|
msgs[1].ToolCalls[0].SubagentSessionID,
|
|
"subagent_session_id",
|
|
)
|
|
}
|
|
|
|
// TestIncrementalSync_ClaudeFileReplaced verifies that when a
|
|
// session file is replaced atomically (new inode/device), the
|
|
// sync engine detects the identity change and falls back to a
|
|
// full parse instead of treating the new content as an append.
|
|
func TestIncrementalSync_ClaudeFileReplaced(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("identity tracking is a no-op on Windows")
|
|
}
|
|
env := setupTestEnv(t)
|
|
|
|
original := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("first", tsZero),
|
|
)
|
|
path := env.writeClaudeSession(
|
|
t, "proj", "replaced.jsonl", original,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
assertSessionMessageCount(t, env.db, "replaced", 1)
|
|
|
|
full, err := env.db.GetSessionFull(
|
|
context.Background(), "replaced",
|
|
)
|
|
require.NoError(t, err, "GetSessionFull")
|
|
require.NotNil(t, full.FileInode, "file_inode not populated after initial sync")
|
|
require.NotZero(t, *full.FileInode, "file_inode not populated after initial sync")
|
|
origInode := *full.FileInode
|
|
|
|
// Atomically replace the file. The content is longer than the
|
|
// original so an incremental parse would mistakenly append the
|
|
// new file's bytes past the old offset.
|
|
replacement := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("second", tsZero),
|
|
testjsonl.ClaudeUserJSON("third", tsZeroS5),
|
|
)
|
|
tmp := path + ".tmp"
|
|
require.NoError(t, os.WriteFile(tmp, []byte(replacement), 0o644), "write replacement")
|
|
require.NoError(t, os.Rename(tmp, path), "rename replacement")
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
// The stored inode must track the new file (i.e. a full
|
|
// parse re-ran and overwrote the identity). If the incremental
|
|
// path had run instead, the old inode would still be stored
|
|
// and the appended bytes would be interpreted as continuation
|
|
// of the original file.
|
|
full, err = env.db.GetSessionFull(
|
|
context.Background(), "replaced",
|
|
)
|
|
require.NoError(t, err, "GetSessionFull after replace")
|
|
require.NotNil(t, full.FileInode, "file_inode cleared after replace")
|
|
assert.NotEqual(t, origInode, *full.FileInode, "file_inode = %d, want change from original", *full.FileInode)
|
|
// File size in the DB should match the replacement, not the
|
|
// pre-replacement size that an incremental parse would have
|
|
// left in place.
|
|
newInfo, err := os.Stat(path)
|
|
require.NoError(t, err, "stat replacement")
|
|
require.NotNil(t, full.FileSize, "file_size = nil, want %d (full-parse size)", newInfo.Size())
|
|
assert.Equal(t, newInfo.Size(), *full.FileSize, "file_size = %v, want %d (full-parse size)", *full.FileSize, newInfo.Size())
|
|
}
|
|
|
|
func TestIncrementalSync_ClaudeTruncatedFileReplacesStoredMessages(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
original := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("first", tsZero),
|
|
testjsonl.ClaudeAssistantJSON("stale assistant", tsZeroS5),
|
|
)
|
|
path := env.writeClaudeSession(
|
|
t, "proj", "truncated-replace.jsonl", original,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
assertSessionMessageCount(t, env.db, "truncated-replace", 2)
|
|
|
|
replacement := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("replacement", tsZero),
|
|
)
|
|
require.Less(t, len(replacement), len(original), "replacement must truncate file")
|
|
require.NoError(t, os.WriteFile(path, []byte(replacement), 0o644), "write truncated replacement")
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
assertSessionMessageCount(t, env.db, "truncated-replace", 1)
|
|
msgs := fetchMessages(t, env.db, "truncated-replace")
|
|
require.Len(t, msgs, 1)
|
|
assert.Equal(t, "replacement", msgs[0].Content)
|
|
}
|
|
|
|
func TestIncrementalSync_ClaudeSameSizeFileReplaceUsesFullParse(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("identity tracking is a no-op on Windows")
|
|
}
|
|
env := setupTestEnv(t)
|
|
|
|
original := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("first", tsZero),
|
|
testjsonl.ClaudeAssistantJSON("alpha", tsZeroS5),
|
|
)
|
|
path := env.writeClaudeSession(
|
|
t, "proj", "same-size-replace.jsonl", original,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
replacement := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("third", tsZero),
|
|
testjsonl.ClaudeAssistantJSON("bravo", tsZeroS5),
|
|
)
|
|
require.Len(t, replacement, len(original), "replacement fixture must keep same byte size")
|
|
tmp := path + ".tmp"
|
|
require.NoError(t, os.WriteFile(tmp, []byte(replacement), 0o644), "write replacement")
|
|
require.NoError(t, os.Rename(tmp, path), "rename replacement")
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
msgs := fetchMessages(t, env.db, "same-size-replace")
|
|
require.Len(t, msgs, 2)
|
|
assert.Equal(t, "third", msgs[0].Content)
|
|
assert.Equal(t, "bravo", msgs[1].Content)
|
|
}
|
|
|
|
func TestIncrementalSync_ClaudeSameSizeSameMtimeFileReplaceUsesFullParse(
|
|
t *testing.T,
|
|
) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("identity tracking is a no-op on Windows")
|
|
}
|
|
env := setupTestEnv(t)
|
|
|
|
original := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("first", tsZero),
|
|
testjsonl.ClaudeAssistantJSON("alpha", tsZeroS5),
|
|
)
|
|
path := env.writeClaudeSession(
|
|
t, "proj", "same-size-same-mtime-replace.jsonl", original,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
full, err := env.db.GetSessionFull(
|
|
context.Background(), "same-size-same-mtime-replace",
|
|
)
|
|
require.NoError(t, err, "GetSessionFull")
|
|
require.NotNil(t, full.FileInode, "file_inode not populated after initial sync")
|
|
origInode := *full.FileInode
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err, "stat original")
|
|
originalMtime := info.ModTime()
|
|
|
|
replacement := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("third", tsZero),
|
|
testjsonl.ClaudeAssistantJSON("bravo", tsZeroS5),
|
|
)
|
|
require.Len(t, replacement, len(original), "replacement fixture must keep same byte size")
|
|
tmp := path + ".tmp"
|
|
require.NoError(t, os.WriteFile(tmp, []byte(replacement), 0o644), "write replacement")
|
|
require.NoError(t, os.Rename(tmp, path), "rename replacement")
|
|
require.NoError(t, os.Chtimes(path, originalMtime, originalMtime), "restore replacement mtime")
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
msgs := fetchMessages(t, env.db, "same-size-same-mtime-replace")
|
|
require.Len(t, msgs, 2)
|
|
assert.Equal(t, "third", msgs[0].Content)
|
|
assert.Equal(t, "bravo", msgs[1].Content)
|
|
full, err = env.db.GetSessionFull(
|
|
context.Background(), "same-size-same-mtime-replace",
|
|
)
|
|
require.NoError(t, err, "GetSessionFull after replace")
|
|
require.NotNil(t, full.FileInode, "file_inode cleared after replace")
|
|
assert.NotEqual(t, origInode, *full.FileInode)
|
|
}
|
|
|
|
func TestIncrementalSync_ClaudeForkSameSizeSameMtimeFileReplaceUsesFullParse(
|
|
t *testing.T,
|
|
) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("identity tracking is a no-op on Windows")
|
|
}
|
|
env := setupTestEnv(t)
|
|
|
|
original := testjsonl.NewSessionBuilder().
|
|
AddClaudeUserWithUUID("2024-01-01T10:00:00Z", "start", "a", "").
|
|
AddClaudeAssistantWithUUID("2024-01-01T10:00:01Z", "ok", "b", "a").
|
|
AddClaudeUserWithUUID("2024-01-01T10:00:02Z", "step2", "c", "b").
|
|
AddClaudeAssistantWithUUID("2024-01-01T10:00:03Z", "ok2", "d", "c").
|
|
AddClaudeUserWithUUID("2024-01-01T10:00:04Z", "step3", "e", "d").
|
|
AddClaudeAssistantWithUUID("2024-01-01T10:00:05Z", "ok3", "f", "e").
|
|
AddClaudeUserWithUUID("2024-01-01T10:00:06Z", "step4", "g", "f").
|
|
AddClaudeAssistantWithUUID("2024-01-01T10:00:07Z", "ok4", "h", "g").
|
|
AddClaudeUserWithUUID("2024-01-01T10:00:08Z", "step5", "k", "h").
|
|
AddClaudeAssistantWithUUID("2024-01-01T10:00:09Z", "ok5", "l", "k").
|
|
AddClaudeUserWithUUID("2024-01-01T10:01:00Z", "fork-start", "i", "b").
|
|
AddClaudeAssistantWithUUID("2024-01-01T10:01:01Z", "fork-ok", "j", "i").
|
|
String()
|
|
path := env.writeClaudeSession(
|
|
t, "proj", "same-size-same-mtime-fork-replace.jsonl", original,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
assertSessionMessageCount(t, env.db, "same-size-same-mtime-fork-replace", 10)
|
|
assertSessionMessageCount(t, env.db, "same-size-same-mtime-fork-replace-i", 2)
|
|
full, err := env.db.GetSessionFull(
|
|
context.Background(), "same-size-same-mtime-fork-replace",
|
|
)
|
|
require.NoError(t, err, "GetSessionFull")
|
|
require.NotNil(t, full.FileInode, "file_inode not populated after initial sync")
|
|
origInode := *full.FileInode
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err, "stat original")
|
|
originalMtime := info.ModTime()
|
|
|
|
replacement := testjsonl.NewSessionBuilder().
|
|
AddClaudeUserWithUUID("2024-01-01T10:00:00Z", "START", "a", "").
|
|
AddClaudeAssistantWithUUID("2024-01-01T10:00:01Z", "no", "b", "a").
|
|
AddClaudeUserWithUUID("2024-01-01T10:00:02Z", "STEP2", "c", "b").
|
|
AddClaudeAssistantWithUUID("2024-01-01T10:00:03Z", "NO2", "d", "c").
|
|
AddClaudeUserWithUUID("2024-01-01T10:00:04Z", "STEP3", "e", "d").
|
|
AddClaudeAssistantWithUUID("2024-01-01T10:00:05Z", "NO3", "f", "e").
|
|
AddClaudeUserWithUUID("2024-01-01T10:00:06Z", "STEP4", "g", "f").
|
|
AddClaudeAssistantWithUUID("2024-01-01T10:00:07Z", "NO4", "h", "g").
|
|
AddClaudeUserWithUUID("2024-01-01T10:00:08Z", "STEP5", "k", "h").
|
|
AddClaudeAssistantWithUUID("2024-01-01T10:00:09Z", "NO5", "l", "k").
|
|
AddClaudeUserWithUUID("2024-01-01T10:01:00Z", "fork-other", "i", "b").
|
|
AddClaudeAssistantWithUUID("2024-01-01T10:01:01Z", "FORK-NO", "j", "i").
|
|
String()
|
|
require.Len(t, replacement, len(original), "replacement fixture must keep same byte size")
|
|
tmp := path + ".tmp"
|
|
require.NoError(t, os.WriteFile(tmp, []byte(replacement), 0o644), "write replacement")
|
|
require.NoError(t, os.Rename(tmp, path), "rename replacement")
|
|
require.NoError(t, os.Chtimes(path, originalMtime, originalMtime), "restore replacement mtime")
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
msgs := fetchMessages(t, env.db, "same-size-same-mtime-fork-replace")
|
|
require.Len(t, msgs, 10)
|
|
assert.Equal(t, "START", msgs[0].Content)
|
|
assert.Equal(t, "NO5", msgs[9].Content)
|
|
forkMsgs := fetchMessages(t, env.db, "same-size-same-mtime-fork-replace-i")
|
|
require.Len(t, forkMsgs, 2)
|
|
assert.Equal(t, "fork-other", forkMsgs[0].Content)
|
|
assert.Equal(t, "FORK-NO", forkMsgs[1].Content)
|
|
full, err = env.db.GetSessionFull(
|
|
context.Background(), "same-size-same-mtime-fork-replace",
|
|
)
|
|
require.NoError(t, err, "GetSessionFull after replace")
|
|
require.NotNil(t, full.FileInode, "file_inode cleared after replace")
|
|
assert.NotEqual(t, origInode, *full.FileInode)
|
|
}
|
|
|
|
func TestIncrementalSync_ClaudePathRewriterIgnoresTempInodeChange(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("identity tracking is a no-op on Windows")
|
|
}
|
|
db := dbtest.OpenTestDB(t)
|
|
firstRoot := t.TempDir()
|
|
secondRoot := t.TempDir()
|
|
const remoteRoot = "/home/test/.claude/projects"
|
|
rewriter := func(p string) string {
|
|
for _, root := range []string{firstRoot, secondRoot} {
|
|
rel, err := filepath.Rel(root, p)
|
|
if err == nil && !strings.HasPrefix(rel, "..") {
|
|
return "host:" + filepath.ToSlash(
|
|
filepath.Join(remoteRoot, rel),
|
|
)
|
|
}
|
|
}
|
|
return "host:" + filepath.ToSlash(p)
|
|
}
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("first", tsZero),
|
|
testjsonl.ClaudeAssistantJSON("alpha", tsZeroS5),
|
|
)
|
|
firstPath := filepath.Join(firstRoot, "proj", "remote-same.jsonl")
|
|
dbtest.WriteTestFile(t, firstPath, []byte(content))
|
|
firstInfo, err := os.Stat(firstPath)
|
|
require.NoError(t, err, "stat first temp copy")
|
|
firstEngine := sync.NewEngine(db, sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentClaude: {firstRoot},
|
|
},
|
|
Machine: "host",
|
|
IDPrefix: "host~",
|
|
PathRewriter: rewriter,
|
|
Ephemeral: true,
|
|
})
|
|
runSyncAndAssert(t, firstEngine, sync.SyncStats{
|
|
TotalSessions: 1, Synced: 1,
|
|
})
|
|
|
|
secondPath := filepath.Join(secondRoot, "proj", "remote-same.jsonl")
|
|
dbtest.WriteTestFile(t, secondPath, []byte(content))
|
|
require.NoError(t,
|
|
os.Chtimes(secondPath, firstInfo.ModTime(), firstInfo.ModTime()),
|
|
"preserve remote mtime on second temp copy",
|
|
)
|
|
secondEngine := sync.NewEngine(db, sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentClaude: {secondRoot},
|
|
},
|
|
Machine: "host",
|
|
IDPrefix: "host~",
|
|
PathRewriter: rewriter,
|
|
Ephemeral: true,
|
|
})
|
|
runSyncAndAssert(t, secondEngine, sync.SyncStats{
|
|
TotalSessions: 1, Synced: 0, Skipped: 1,
|
|
})
|
|
}
|
|
|
|
// TestIncrementalSync_ClaudePathRewriterSameSizeSameMtimeRewrite covers the
|
|
// remote (path-rewritten) equivalent of the same-size, same-mtime in-place
|
|
// rewrite. A remote re-download always lands on a fresh inode, so the inode net
|
|
// is disabled for path-rewritten sources; only the content hash can tell an
|
|
// unchanged re-download from a rewrite that kept the same size and mtime. The
|
|
// content guard hashes the materialized download, so an unchanged copy must
|
|
// still skip while a genuine same-size same-mtime rewrite must full-parse.
|
|
func TestIncrementalSync_ClaudePathRewriterSameSizeSameMtimeRewrite(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("identity tracking is a no-op on Windows")
|
|
}
|
|
|
|
original := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("first", tsZero),
|
|
testjsonl.ClaudeAssistantJSON("alpha", tsZeroS5),
|
|
)
|
|
changed := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("third", tsZero),
|
|
testjsonl.ClaudeAssistantJSON("bravo", tsZeroS5),
|
|
)
|
|
require.Len(t, changed, len(original), "fixtures must keep same byte size")
|
|
|
|
const remoteRoot = "/home/test/.claude/projects"
|
|
mkRewriter := func(roots ...string) func(string) string {
|
|
return func(p string) string {
|
|
for _, root := range roots {
|
|
rel, err := filepath.Rel(root, p)
|
|
if err == nil && !strings.HasPrefix(rel, "..") {
|
|
return "host:" + filepath.ToSlash(
|
|
filepath.Join(remoteRoot, rel),
|
|
)
|
|
}
|
|
}
|
|
return "host:" + filepath.ToSlash(p)
|
|
}
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
second string
|
|
wantSynced int
|
|
wantSkipped int
|
|
wantUser string
|
|
wantAssistant string
|
|
}{
|
|
{
|
|
name: "unchanged re-download skips",
|
|
second: original,
|
|
wantSynced: 0,
|
|
wantSkipped: 1,
|
|
wantUser: "first",
|
|
wantAssistant: "alpha",
|
|
},
|
|
{
|
|
name: "same-size same-mtime rewrite full-parses",
|
|
second: changed,
|
|
wantSynced: 1,
|
|
wantSkipped: 0,
|
|
wantUser: "third",
|
|
wantAssistant: "bravo",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
database := dbtest.OpenTestDB(t)
|
|
firstRoot := t.TempDir()
|
|
secondRoot := t.TempDir()
|
|
rewriter := mkRewriter(firstRoot, secondRoot)
|
|
|
|
firstPath := filepath.Join(firstRoot, "proj", "remote-rewrite.jsonl")
|
|
dbtest.WriteTestFile(t, firstPath, []byte(original))
|
|
firstInfo, err := os.Stat(firstPath)
|
|
require.NoError(t, err, "stat first temp copy")
|
|
firstEngine := sync.NewEngine(database, sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentClaude: {firstRoot},
|
|
},
|
|
Machine: "host",
|
|
IDPrefix: "host~",
|
|
PathRewriter: rewriter,
|
|
Ephemeral: true,
|
|
})
|
|
runSyncAndAssert(t, firstEngine, sync.SyncStats{
|
|
TotalSessions: 1, Synced: 1,
|
|
})
|
|
|
|
// Second temp copy: same byte size, same mtime, different inode.
|
|
// Only the content differs (or not, per the case).
|
|
secondPath := filepath.Join(secondRoot, "proj", "remote-rewrite.jsonl")
|
|
dbtest.WriteTestFile(t, secondPath, []byte(tt.second))
|
|
require.NoError(t,
|
|
os.Chtimes(secondPath, firstInfo.ModTime(), firstInfo.ModTime()),
|
|
"preserve remote mtime on second temp copy",
|
|
)
|
|
secondEngine := sync.NewEngine(database, sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentClaude: {secondRoot},
|
|
},
|
|
Machine: "host",
|
|
IDPrefix: "host~",
|
|
PathRewriter: rewriter,
|
|
Ephemeral: true,
|
|
})
|
|
runSyncAndAssert(t, secondEngine, sync.SyncStats{
|
|
TotalSessions: 1,
|
|
Synced: tt.wantSynced,
|
|
Skipped: tt.wantSkipped,
|
|
})
|
|
|
|
msgs := fetchMessages(t, database, "host~remote-rewrite")
|
|
require.Len(t, msgs, 2)
|
|
assert.Equal(t, tt.wantUser, msgs[0].Content)
|
|
assert.Equal(t, tt.wantAssistant, msgs[1].Content)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIncrementalSync_ClaudeSameSizeInPlaceRewriteClearsStaleRows(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
original := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("first", tsZero),
|
|
testjsonl.ClaudeAssistantJSON("stale assistant", tsZeroS5),
|
|
)
|
|
path := env.writeClaudeSession(
|
|
t, "proj", "same-size-in-place.jsonl", original,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
assertSessionMessageCount(t, env.db, "same-size-in-place", 2)
|
|
|
|
replacement := ""
|
|
for padding := range 4096 {
|
|
candidate := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON(
|
|
"replacement"+strings.Repeat("x", padding),
|
|
tsZero,
|
|
),
|
|
)
|
|
if len(candidate) == len(original) {
|
|
replacement = candidate
|
|
break
|
|
}
|
|
}
|
|
require.NotEmpty(t, replacement, "failed to build same-size replacement fixture")
|
|
require.Len(t, replacement, len(original), "replacement fixture must keep same byte size")
|
|
|
|
require.NoError(t, os.WriteFile(path, []byte(replacement), 0o644), "write in-place replacement")
|
|
now := time.Now().Add(time.Second)
|
|
require.NoError(t, os.Chtimes(path, now, now), "bump replacement mtime")
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
assertSessionMessageCount(t, env.db, "same-size-in-place", 1)
|
|
msgs := fetchMessages(t, env.db, "same-size-in-place")
|
|
require.Len(t, msgs, 1)
|
|
assert.Contains(t, msgs[0].Content, "replacement")
|
|
}
|
|
|
|
// TestIncrementalSync_ClaudeSameSizeSameMtimeInPlaceRewriteUsesFullParse pins
|
|
// the worst case for the stat-only fast-skip: a file rewritten in place (same
|
|
// inode and device) with the same byte size and the same mtime. Two fast
|
|
// successive writes landing in one filesystem mtime granule produce exactly
|
|
// this state in the wild, and neither the size/mtime skip nor the inode net can
|
|
// see the change. The engine must still detect the new content and full-parse
|
|
// instead of serving the stale rows.
|
|
func TestIncrementalSync_ClaudeSameSizeSameMtimeInPlaceRewriteUsesFullParse(
|
|
t *testing.T,
|
|
) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("identity tracking is a no-op on Windows")
|
|
}
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
original := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("first", tsZero),
|
|
testjsonl.ClaudeAssistantJSON("alpha", tsZeroS5),
|
|
)
|
|
path := env.writeClaudeSession(
|
|
t, "proj", "same-size-same-mtime-in-place.jsonl", original,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
assertSessionMessageCount(t, env.db, "same-size-same-mtime-in-place", 2)
|
|
|
|
full, err := env.db.GetSessionFull(
|
|
context.Background(), "same-size-same-mtime-in-place",
|
|
)
|
|
require.NoError(t, err, "GetSessionFull")
|
|
require.NotNil(t, full.FileInode, "file_inode not populated after initial sync")
|
|
origInode := *full.FileInode
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err, "stat original")
|
|
originalMtime := info.ModTime()
|
|
|
|
replacement := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("third", tsZero),
|
|
testjsonl.ClaudeAssistantJSON("bravo", tsZeroS5),
|
|
)
|
|
require.Len(t, replacement, len(original), "replacement fixture must keep same byte size")
|
|
|
|
// Rewrite in place so the inode/device do not change, then restore the
|
|
// original mtime so size, mtime, inode, and device all match what the
|
|
// initial sync stored. Only the content differs.
|
|
require.NoError(t, os.WriteFile(path, []byte(replacement), 0o644), "in-place rewrite")
|
|
require.NoError(t, os.Chtimes(path, originalMtime, originalMtime), "restore mtime")
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
msgs := fetchMessages(t, env.db, "same-size-same-mtime-in-place")
|
|
require.Len(t, msgs, 2)
|
|
assert.Equal(t, "third", msgs[0].Content)
|
|
assert.Equal(t, "bravo", msgs[1].Content)
|
|
|
|
full, err = env.db.GetSessionFull(
|
|
context.Background(), "same-size-same-mtime-in-place",
|
|
)
|
|
require.NoError(t, err, "GetSessionFull after replace")
|
|
require.NotNil(t, full.FileInode, "file_inode cleared after replace")
|
|
// The rewrite was in place, so the inode is unchanged: the fix must catch
|
|
// the content change through the stored hash, not the identity net.
|
|
assert.Equal(t, origInode, *full.FileInode)
|
|
}
|
|
|
|
// TestIncrementalSync_ClaudeMidStreamSplitFallsBackToFullParse covers
|
|
// the cross-sync split case: the first sync stores a partial assistant
|
|
// snapshot (one of several streaming snapshots) and the next sync
|
|
// appends a later snapshot of the SAME response (same message.id).
|
|
// The engine must detect the shared id and fall back to a full parse
|
|
// so the chunk merge collapses both snapshots into one assistant
|
|
// message instead of two.
|
|
func TestIncrementalSync_ClaudeMidStreamSplitFallsBackToFullParse(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
first, err := json.Marshal(map[string]any{
|
|
"type": "assistant",
|
|
"timestamp": tsZeroS5,
|
|
"uuid": "a1",
|
|
"message": map[string]any{
|
|
"id": "msg_split",
|
|
"model": "claude-sonnet-4-20250514",
|
|
"usage": map[string]any{
|
|
"input_tokens": 10,
|
|
"output_tokens": 1,
|
|
},
|
|
"content": []map[string]any{
|
|
{"type": "text", "text": "Hello"},
|
|
},
|
|
"stop_reason": "tool_use",
|
|
},
|
|
})
|
|
require.NoError(t, err, "marshal first snapshot")
|
|
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON("hello", tsZero),
|
|
string(first),
|
|
)
|
|
path := env.writeClaudeSession(
|
|
t, "proj", "split-stream.jsonl", initial,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
assertSessionMessageCount(t, env.db, "split-stream", 2)
|
|
|
|
// Append a continuation snapshot with the same message.id —
|
|
// this is the second half of the same streaming response.
|
|
second, err := json.Marshal(map[string]any{
|
|
"type": "assistant",
|
|
"timestamp": tsEarly,
|
|
"uuid": "a2",
|
|
"parentUuid": "a1",
|
|
"message": map[string]any{
|
|
"id": "msg_split",
|
|
"model": "claude-sonnet-4-20250514",
|
|
"usage": map[string]any{
|
|
"input_tokens": 10,
|
|
"output_tokens": 2,
|
|
},
|
|
"content": []map[string]any{
|
|
{"type": "text", "text": "Hello world"},
|
|
},
|
|
"stop_reason": "end_turn",
|
|
},
|
|
})
|
|
require.NoError(t, err, "marshal second snapshot")
|
|
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err, "open for append")
|
|
_, writeErr := f.WriteString(string(second) + "\n")
|
|
f.Close()
|
|
require.NoError(t, writeErr, "append")
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
// After the full-parse fallback, the two same-message.id
|
|
// snapshots are merged into ONE assistant message — total
|
|
// message count stays at 2 (user + merged assistant).
|
|
assertSessionMessageCount(t, env.db, "split-stream", 2)
|
|
|
|
msgs := fetchMessages(t, env.db, "split-stream")
|
|
require.Equal(t, 2, len(msgs), "len(msgs) = %d, want 2", len(msgs))
|
|
require.Equal(t, "assistant", string(msgs[1].Role), "msgs[1].Role = %q, want assistant", msgs[1].Role)
|
|
// The partial snapshot ("Hello") must be REPLACED by the final
|
|
// snapshot ("Hello world"), not concatenated as additive content.
|
|
assert.Equal(t, "Hello world", msgs[1].Content, "msgs[1].Content = %q, want exactly %q", msgs[1].Content, "Hello world")
|
|
}
|
|
|
|
// TestIncrementalSync_ClaudeAgentIDLinksIncrementally covers the cross-
|
|
// sync subagent linkage case: the first sync stores an assistant
|
|
// tool_use row with no subagent_session_id, and a later sync appends a
|
|
// tool_result whose toolUseResult.agentId should populate the
|
|
// already-stored tool_call without forcing a full message replacement.
|
|
func TestIncrementalSync_ClaudeAgentIDLinksIncrementally(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
parentInitial := testjsonl.JoinJSONL(
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:00Z","uuid":"u1","message":{"content":"go"},"cwd":"/tmp"}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:01Z","uuid":"a1","parentUuid":"u1","message":{"id":"msg_one","content":[{"type":"tool_use","id":"toolu_late","name":"Agent","input":{"description":"d","subagent_type":"Explore","prompt":"p"}}],"usage":{"input_tokens":1,"output_tokens":1},"stop_reason":"tool_use"}}`,
|
|
)
|
|
|
|
path := env.writeClaudeSession(
|
|
t, "proj-late-link", "parent-late-link.jsonl", parentInitial,
|
|
)
|
|
|
|
subContent := testjsonl.NewSessionBuilder().
|
|
AddClaudeUserWithSessionID(
|
|
tsEarly, "do thing", "parent-late-link",
|
|
).
|
|
AddClaudeAssistant(tsEarlyS5, "done").
|
|
String()
|
|
env.writeSession(
|
|
t, env.claudeDir,
|
|
filepath.Join(
|
|
"proj-late-link", "parent-late-link",
|
|
"subagents", "agent-childlate.jsonl",
|
|
),
|
|
subContent,
|
|
)
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
// Linkage starts empty (the toolUseResult hasn't appeared yet).
|
|
var assistantMessageID int64
|
|
require.NoError(t, env.db.Reader().QueryRow(`
|
|
SELECT id
|
|
FROM messages
|
|
WHERE session_id = ? AND ordinal = 1`,
|
|
"parent-late-link",
|
|
).Scan(&assistantMessageID), "query message id before append")
|
|
|
|
var got sql.NullString
|
|
require.NoError(t, env.db.Reader().QueryRow(`
|
|
SELECT subagent_session_id
|
|
FROM tool_calls
|
|
WHERE session_id = ? AND tool_use_id = ?`,
|
|
"parent-late-link", "toolu_late",
|
|
).Scan(&got), "query before append")
|
|
if got.Valid {
|
|
require.Equal(t, "", got.String, "subagent_session_id = %q before tool_result, want empty", got.String)
|
|
}
|
|
|
|
// Append a tool_result with toolUseResult.agentId pointing at
|
|
// the existing subagent session. Incremental parse will return
|
|
// ErrClaudeIncrementalNeedsFullParse so the engine must full-
|
|
// parse with forceReplace to update the stored tool_call row.
|
|
toolResult := `{"type":"user","timestamp":"2024-01-01T10:00:02Z","uuid":"r1","parentUuid":"a1","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_late","content":"done\u0000\u0001"}]},"toolUseResult":{"status":"completed","agentId":"childlate"}}`
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err, "open for append")
|
|
_, writeErr := f.WriteString(toolResult + "\n")
|
|
f.Close()
|
|
require.NoError(t, writeErr, "append")
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
var gotMessageID int64
|
|
require.NoError(t, env.db.Reader().QueryRow(`
|
|
SELECT id
|
|
FROM messages
|
|
WHERE session_id = ? AND ordinal = 1`,
|
|
"parent-late-link",
|
|
).Scan(&gotMessageID), "query message id after append")
|
|
|
|
require.NoError(t, env.db.Reader().QueryRow(`
|
|
SELECT subagent_session_id
|
|
FROM tool_calls
|
|
WHERE session_id = ? AND tool_use_id = ?`,
|
|
"parent-late-link", "toolu_late",
|
|
).Scan(&got), "query after append")
|
|
assert.Equal(t, assistantMessageID, gotMessageID, "assistant message row should remain in place during incremental linkage")
|
|
assert.Equal(t, "agent-childlate", got.String, "subagent_session_id = %q, want %q", got.String, "agent-childlate")
|
|
msgs := fetchMessages(t, env.db, "parent-late-link")
|
|
require.Len(t, msgs, 2)
|
|
require.Len(t, msgs[1].ToolCalls, 1)
|
|
assert.Equal(t, "done", msgs[1].ToolCalls[0].ResultContent)
|
|
assert.Equal(t, len("done"), msgs[1].ToolCalls[0].ResultContentLength)
|
|
}
|
|
|
|
func TestIncrementalSync_ClaudeAgentIDLinkUsesRemotePrefix(t *testing.T) {
|
|
claudeDir := t.TempDir()
|
|
database := dbtest.OpenTestDB(t)
|
|
env := &testEnv{claudeDir: claudeDir, db: database}
|
|
env.engine = sync.NewEngine(database, sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{
|
|
parser.AgentClaude: {claudeDir},
|
|
},
|
|
Machine: "host",
|
|
IDPrefix: "host~",
|
|
})
|
|
|
|
initial := testjsonl.JoinJSONL(
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:00Z","uuid":"u1","message":{"content":"go"},"cwd":"/tmp"}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:01Z","uuid":"a1","parentUuid":"u1","message":{"content":[{"type":"tool_use","id":"toolu_prefix","name":"Agent","input":{"description":"d","subagent_type":"Explore","prompt":"p"}}]}}`,
|
|
)
|
|
path := env.writeClaudeSession(
|
|
t, "proj-prefix-link", "parent-prefix-link.jsonl", initial,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
toolResult := `{"type":"user","timestamp":"2024-01-01T10:00:02Z","uuid":"r1","parentUuid":"a1","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_prefix","content":"done"}]},"toolUseResult":{"status":"completed","agentId":"childprefix"}}`
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err, "open for append")
|
|
_, writeErr := f.WriteString(toolResult + "\n")
|
|
require.NoError(t, f.Close(), "close append")
|
|
require.NoError(t, writeErr, "append")
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
var got string
|
|
require.NoError(t, database.Reader().QueryRow(`
|
|
SELECT subagent_session_id
|
|
FROM tool_calls
|
|
WHERE session_id = ? AND tool_use_id = ?`,
|
|
"host~parent-prefix-link", "toolu_prefix",
|
|
).Scan(&got), "query prefixed link")
|
|
assert.Equal(t, "host~agent-childprefix", got)
|
|
}
|
|
|
|
func TestIncrementalSync_ClaudeAgentIDLinksToolUseFromSameAppend(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
initial := testjsonl.JoinJSONL(
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:00Z","uuid":"u1","message":{"content":"go"},"cwd":"/tmp"}`,
|
|
)
|
|
path := env.writeClaudeSession(
|
|
t, "proj-same-append-link", "parent-same-append-link.jsonl", initial,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
appended := testjsonl.JoinJSONL(
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:01Z","uuid":"a1","parentUuid":"u1","message":{"id":"msg_one","content":[{"type":"tool_use","id":"toolu_same_append","name":"Agent","input":{"description":"d","subagent_type":"Explore","prompt":"p"}}],"usage":{"input_tokens":1,"output_tokens":1},"stop_reason":"tool_use"}}`,
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:02Z","uuid":"r1","parentUuid":"a1","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_same_append","content":"done"}]},"toolUseResult":{"status":"completed","agentId":"childsameappend"}}`,
|
|
)
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err, "open for append")
|
|
_, writeErr := f.WriteString(appended)
|
|
require.NoError(t, f.Close(), "close append")
|
|
require.NoError(t, writeErr, "append")
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
assertSessionMessageCount(t, env.db, "parent-same-append-link", 2)
|
|
var got sql.NullString
|
|
require.NoError(t, env.db.Reader().QueryRow(`
|
|
SELECT subagent_session_id
|
|
FROM tool_calls
|
|
WHERE session_id = ? AND tool_use_id = ?`,
|
|
"parent-same-append-link", "toolu_same_append",
|
|
).Scan(&got), "query after append")
|
|
assert.Equal(t, "agent-childsameappend", got.String)
|
|
}
|
|
|
|
func TestIncrementalSync_ClaudeAgentIDUsesFirstLinkAndLatestResult(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
initial := testjsonl.JoinJSONL(
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:00Z","uuid":"u1","message":{"content":"go"},"cwd":"/tmp"}`,
|
|
)
|
|
path := env.writeClaudeSession(
|
|
t, "proj-multiple-links", "parent-multiple-links.jsonl", initial,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
appended := testjsonl.JoinJSONL(
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:01Z","uuid":"a1","parentUuid":"u1","message":{"content":[{"type":"tool_use","id":"toolu_multiple_links","name":"Agent","input":{"description":"d","subagent_type":"Explore","prompt":"p"}}]}}`,
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:02Z","uuid":"r1","parentUuid":"a1","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_multiple_links","content":"partial"}]},"toolUseResult":{"status":"running","agentId":"firstchild"}}`,
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:03Z","uuid":"r2","parentUuid":"r1","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_multiple_links","content":"final"}]},"toolUseResult":{"status":"completed","agentId":"laterchild"}}`,
|
|
)
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err, "open for append")
|
|
_, writeErr := f.WriteString(appended)
|
|
require.NoError(t, f.Close(), "close append")
|
|
require.NoError(t, writeErr, "append")
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
msgs := fetchMessages(t, env.db, "parent-multiple-links")
|
|
require.Len(t, msgs, 2)
|
|
require.Len(t, msgs[1].ToolCalls, 1)
|
|
assert.Equal(t, "agent-firstchild", msgs[1].ToolCalls[0].SubagentSessionID)
|
|
assert.Equal(t, "final", msgs[1].ToolCalls[0].ResultContent)
|
|
assert.Equal(t, len("final"), msgs[1].ToolCalls[0].ResultContentLength)
|
|
}
|
|
|
|
func TestIncrementalSync_ClaudeAgentIDPreservesExistingSubagentLink(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
initial := testjsonl.JoinJSONL(
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:00Z","uuid":"u1","message":{"content":"go"},"cwd":"/tmp"}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:01Z","uuid":"a1","parentUuid":"u1","message":{"content":[{"type":"tool_use","id":"toolu_first_link","name":"Agent","input":{"description":"d","subagent_type":"Explore","prompt":"p"}}]}}`,
|
|
`{"type":"queue-operation","operation":"enqueue","timestamp":"2024-01-01T10:00:02Z","sessionId":"parent-first-link","content":"{\"task_id\":\"queuefirst\",\"tool_use_id\":\"toolu_first_link\",\"description\":\"d\",\"task_type\":\"local_agent\"}"}`,
|
|
)
|
|
path := env.writeClaudeSession(
|
|
t, "proj-first-link", "parent-first-link.jsonl", initial,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
toolResult := `{"type":"user","timestamp":"2024-01-01T10:00:03Z","uuid":"r1","parentUuid":"a1","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_first_link","content":"done"}]},"toolUseResult":{"status":"completed","agentId":"laterresult"}}`
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err, "open for append")
|
|
_, writeErr := f.WriteString(toolResult + "\n")
|
|
require.NoError(t, f.Close(), "close append")
|
|
require.NoError(t, writeErr, "append")
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
var got string
|
|
require.NoError(t, env.db.Reader().QueryRow(`
|
|
SELECT subagent_session_id
|
|
FROM tool_calls
|
|
WHERE session_id = ? AND tool_use_id = ?`,
|
|
"parent-first-link", "toolu_first_link",
|
|
).Scan(&got), "query after append")
|
|
assert.Equal(t, "agent-queuefirst", got)
|
|
}
|
|
|
|
func TestIncrementalSync_ClaudeAgentIDDoesNotLinkNonSubagentTool(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
initial := testjsonl.JoinJSONL(
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:00Z","uuid":"u1","message":{"content":"go"},"cwd":"/tmp"}`,
|
|
)
|
|
path := env.writeClaudeSession(
|
|
t, "proj-read-link", "parent-read-link.jsonl", initial,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
appended := testjsonl.JoinJSONL(
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:01Z","uuid":"a1","parentUuid":"u1","message":{"content":[{"type":"tool_use","id":"toolu_read_link","name":"Read","input":{"file_path":"README.md"}}]}}`,
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:02Z","uuid":"r1","parentUuid":"a1","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_read_link","content":"done"}]},"toolUseResult":{"status":"completed","agentId":"notasubagent"}}`,
|
|
)
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err, "open for append")
|
|
_, writeErr := f.WriteString(appended)
|
|
require.NoError(t, f.Close(), "close append")
|
|
require.NoError(t, writeErr, "append")
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
var got sql.NullString
|
|
require.NoError(t, env.db.Reader().QueryRow(`
|
|
SELECT subagent_session_id
|
|
FROM tool_calls
|
|
WHERE session_id = ? AND tool_use_id = ?`,
|
|
"parent-read-link", "toolu_read_link",
|
|
).Scan(&got), "query after append")
|
|
assert.False(t, got.Valid)
|
|
}
|
|
|
|
func TestIncrementalSync_ClaudeAgentIDMissingToolCallAdvancesCursor(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
initial := testjsonl.JoinJSONL(
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:00Z","uuid":"u1","message":{"content":"go"},"cwd":"/tmp"}`,
|
|
)
|
|
path := env.writeClaudeSession(
|
|
t, "proj-missing-link", "parent-missing-link.jsonl", initial,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
appended := `{"type":"user","isMeta":true,"timestamp":"2024-01-01T10:00:01Z","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_missing","content":"done"}]},"toolUseResult":{"status":"completed","agentId":"missingchild"}}` + "\n"
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err, "open for append")
|
|
_, writeErr := f.WriteString(appended)
|
|
require.NoError(t, f.Close(), "close append")
|
|
require.NoError(t, writeErr, "append")
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err, "stat appended transcript")
|
|
sess, err := env.db.GetSessionFull(context.Background(), "parent-missing-link")
|
|
require.NoError(t, err, "get session after append")
|
|
require.NotNil(t, sess.FileSize)
|
|
assert.Equal(t, info.Size(), *sess.FileSize)
|
|
assert.True(t, sess.LastWriteIncremental)
|
|
assertSessionMessageCount(t, env.db, "parent-missing-link", 1)
|
|
}
|
|
|
|
func TestIncrementalSync_ClaudeMetaAgentIDResultMatchesFullParse(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
initial := testjsonl.JoinJSONL(
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:00Z","uuid":"u1","message":{"content":"go"},"cwd":"/tmp"}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:01Z","uuid":"a1","parentUuid":"u1","message":{"content":[{"type":"tool_use","id":"toolu_meta_link","name":"Agent","input":{"description":"d","subagent_type":"Explore","prompt":"p"}}]}}`,
|
|
)
|
|
path := env.writeClaudeSession(
|
|
t, "proj-meta-link", "parent-meta-link.jsonl", initial,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
appended := `{"type":"user","isMeta":true,"timestamp":"2024-01-01T10:00:02Z","uuid":"r1","parentUuid":"a1","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_meta_link","content":"meta-only output"}]},"toolUseResult":{"status":"completed","agentId":"metachild"}}` + "\n"
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err, "open for append")
|
|
_, writeErr := f.WriteString(appended)
|
|
require.NoError(t, f.Close(), "close append")
|
|
require.NoError(t, writeErr, "append")
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
assertToolCall := func(stage string) {
|
|
msgs := fetchMessages(t, env.db, "parent-meta-link")
|
|
require.Len(t, msgs, 2, stage)
|
|
require.Len(t, msgs[1].ToolCalls, 1, stage)
|
|
assert.Equal(t, "agent-metachild", msgs[1].ToolCalls[0].SubagentSessionID, stage)
|
|
assert.Empty(t, msgs[1].ToolCalls[0].ResultContent, stage)
|
|
assert.Zero(t, msgs[1].ToolCalls[0].ResultContentLength, stage)
|
|
}
|
|
assertToolCall("incremental parse")
|
|
|
|
env.engine.ResyncAll(context.Background(), nil)
|
|
assertToolCall("full parse")
|
|
}
|
|
|
|
func TestIncrementalSync_ClaudeCrossSyncToolResultFallback(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
initial := testjsonl.JoinJSONL(
|
|
`{"type":"user","timestamp":"2024-01-01T10:00:00Z","uuid":"u1","message":{"content":"go"},"cwd":"/tmp"}`,
|
|
`{"type":"assistant","timestamp":"2024-01-01T10:00:01Z","uuid":"a1","parentUuid":"u1","message":{"id":"msg_tool","content":[{"type":"tool_use","id":"toolu_cross","name":"Read","input":{"file_path":"README.md"}}],"usage":{"input_tokens":1,"output_tokens":1},"stop_reason":"tool_use"}}`,
|
|
)
|
|
path := env.writeClaudeSession(
|
|
t, "proj-cross-tool", "cross-tool.jsonl", initial,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
appended := `{"type":"user","timestamp":"2024-01-01T10:00:02Z","uuid":"r1","parentUuid":"a1","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_cross","content":"done"}]}}` + "\n"
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err, "open for append")
|
|
_, err = f.WriteString(appended)
|
|
require.NoError(t, err, "append tool_result")
|
|
require.NoError(t, f.Close())
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
msgs := fetchMessages(t, env.db, "cross-tool")
|
|
require.Len(t, msgs, 2)
|
|
require.Len(t, msgs[1].ToolCalls, 1)
|
|
assert.Equal(t, "done", msgs[1].ToolCalls[0].ResultContent, "result_content")
|
|
}
|
|
|
|
func TestIncrementalSync_CodexAppend(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON(
|
|
"inc-cx", "/tmp/proj",
|
|
"codex_cli_rs", tsEarly,
|
|
),
|
|
testjsonl.CodexMsgJSON("user", "hello", tsEarlyS1),
|
|
)
|
|
path := env.writeCodexSession(
|
|
t, filepath.Join("2024", "01", "01"),
|
|
"rollout-20240101-inc-cx.jsonl", initial,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
before := fetchMessages(t, env.db, "codex:inc-cx")
|
|
require.Len(t, before, 1)
|
|
firstMessageID := before[0].ID
|
|
|
|
// Append new messages.
|
|
appended := testjsonl.JoinJSONL(
|
|
testjsonl.CodexMsgJSON(
|
|
"assistant", "world", tsEarlyS5,
|
|
),
|
|
)
|
|
f, err := os.OpenFile(
|
|
path, os.O_APPEND|os.O_WRONLY, 0o644,
|
|
)
|
|
require.NoError(t, err, "open for append")
|
|
_, err = f.WriteString(appended)
|
|
f.Close()
|
|
require.NoError(t, err, "append")
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
msgs := fetchMessages(t, env.db, "codex:inc-cx")
|
|
require.Len(t, msgs, 2)
|
|
assert.Equal(t, firstMessageID, msgs[0].ID,
|
|
"incremental append must preserve the existing message row")
|
|
assert.Equal(t, "hello", msgs[0].Content)
|
|
assert.Equal(t, "world", msgs[1].Content)
|
|
assert.Equal(t, []string{"user", "assistant"},
|
|
[]string{msgs[0].Role, msgs[1].Role})
|
|
|
|
// Verify session_id on all messages.
|
|
for i, m := range msgs {
|
|
assert.Equal(t, "codex:inc-cx", m.SessionID, "msgs[%d].SessionID = %q, want codex:inc-cx", i, m.SessionID)
|
|
}
|
|
|
|
sess, err := env.db.GetSessionFull(context.Background(), "codex:inc-cx")
|
|
require.NoError(t, err, "GetSessionFull after append")
|
|
require.NotNil(t, sess)
|
|
assert.True(t, sess.LastWriteIncremental)
|
|
assert.Equal(t, 2, sess.NextOrdinal)
|
|
assert.Equal(t, 2, sess.MessageCount)
|
|
assert.Equal(t, 1, sess.UserMessageCount)
|
|
}
|
|
|
|
func TestSyncPathsCodexSameStatInPlaceRewriteUsesContentHash(t *testing.T) {
|
|
env := setupSingleAgentTestEnv(t, parser.AgentCodex)
|
|
|
|
const uuid = "019eb791-cf7d-75c1-8439-9ed74c1229f5"
|
|
original := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON(
|
|
uuid, "/tmp/proj", "codex_cli_rs", tsEarly,
|
|
),
|
|
testjsonl.CodexMsgJSON("user", "alpha request", tsEarlyS1),
|
|
)
|
|
rewritten := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON(
|
|
uuid, "/tmp/proj", "codex_cli_rs", tsEarly,
|
|
),
|
|
testjsonl.CodexMsgJSON("user", "bravo request", tsEarlyS1),
|
|
)
|
|
require.Len(t, rewritten, len(original), "transcript fixtures must have equal length")
|
|
path := env.writeCodexSession(
|
|
t, filepath.Join("2024", "01", "01"),
|
|
"rollout-2024-01-01T10-00-00-"+uuid+".jsonl", original,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
before, err := env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, before)
|
|
require.NotNil(t, before.FileHash)
|
|
beforeHash := *before.FileHash
|
|
beforeInfo, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
env.engine.InjectSkipCache(map[string]int64{
|
|
path: beforeInfo.ModTime().UnixNano(),
|
|
})
|
|
|
|
require.NoError(t, os.WriteFile(path, []byte(rewritten), 0o644))
|
|
require.NoError(t, os.Chtimes(path, beforeInfo.ModTime(), beforeInfo.ModTime()))
|
|
afterInfo, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, beforeInfo.Size(), afterInfo.Size(), "rewrite must keep file size")
|
|
require.Equal(t, beforeInfo.ModTime(), afterInfo.ModTime(), "rewrite must keep mtime")
|
|
require.True(t, os.SameFile(beforeInfo, afterInfo), "rewrite must keep file identity")
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
msgs := fetchMessages(t, env.db, "codex:"+uuid)
|
|
require.Len(t, msgs, 1)
|
|
assert.Equal(t, "bravo request", msgs[0].Content)
|
|
after, err := env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, after)
|
|
require.NotNil(t, after.FileHash)
|
|
assert.False(t, after.LastWriteIncremental,
|
|
"same-size rewrite must use a full replacement")
|
|
assert.NotEqual(t, beforeHash, *after.FileHash)
|
|
wantHash, err := sync.ComputeFileHash(path)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, wantHash, *after.FileHash)
|
|
}
|
|
|
|
func TestSyncAllCodexPathRewriterSameStatRewriteUsesContentHash(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test")
|
|
}
|
|
database := dbtest.OpenTestDB(t)
|
|
firstRoot := filepath.Join(t.TempDir(), "sessions")
|
|
secondRoot := filepath.Join(t.TempDir(), "sessions")
|
|
const (
|
|
uuid = "019eb791-cf7d-75c1-8439-9ed74c1229f6"
|
|
remoteRoot = "/home/test/.codex/sessions"
|
|
)
|
|
original := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON(
|
|
uuid, "/tmp/proj", "codex_cli_rs", tsEarly,
|
|
),
|
|
testjsonl.CodexMsgJSON("user", "alpha request", tsEarlyS1),
|
|
)
|
|
rewritten := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON(
|
|
uuid, "/tmp/proj", "codex_cli_rs", tsEarly,
|
|
),
|
|
testjsonl.CodexMsgJSON("user", "bravo request", tsEarlyS1),
|
|
)
|
|
require.Len(t, rewritten, len(original), "transcript fixtures must have equal length")
|
|
relPath := filepath.Join(
|
|
"2024", "01", "01",
|
|
"rollout-2024-01-01T10-00-00-"+uuid+".jsonl",
|
|
)
|
|
firstPath := filepath.Join(firstRoot, relPath)
|
|
secondPath := filepath.Join(secondRoot, relPath)
|
|
dbtest.WriteTestFile(t, firstPath, []byte(original))
|
|
firstInfo, err := os.Stat(firstPath)
|
|
require.NoError(t, err)
|
|
dbtest.WriteTestFile(t, secondPath, []byte(rewritten))
|
|
require.NoError(t, os.Chtimes(secondPath, firstInfo.ModTime(), firstInfo.ModTime()))
|
|
secondInfo, err := os.Stat(secondPath)
|
|
require.NoError(t, err)
|
|
require.Equal(t, firstInfo.Size(), secondInfo.Size())
|
|
require.Equal(t, firstInfo.ModTime(), secondInfo.ModTime())
|
|
|
|
rewriter := func(path string) string {
|
|
for _, root := range []string{firstRoot, secondRoot} {
|
|
rel, relErr := filepath.Rel(root, path)
|
|
if relErr == nil && !strings.HasPrefix(rel, "..") {
|
|
return "host:" + filepath.ToSlash(filepath.Join(remoteRoot, rel))
|
|
}
|
|
}
|
|
return "host:" + filepath.ToSlash(path)
|
|
}
|
|
firstEngine := sync.NewEngine(database, sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{parser.AgentCodex: {firstRoot}},
|
|
Machine: "host", IDPrefix: "host~", PathRewriter: rewriter, Ephemeral: true,
|
|
})
|
|
runSyncAndAssert(t, firstEngine, sync.SyncStats{TotalSessions: 1, Synced: 1})
|
|
before, err := database.GetSessionFull(context.Background(), "host~codex:"+uuid)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, before)
|
|
require.NotNil(t, before.FileHash)
|
|
beforeHash := *before.FileHash
|
|
|
|
secondEngine := sync.NewEngine(database, sync.EngineConfig{
|
|
AgentDirs: map[parser.AgentType][]string{parser.AgentCodex: {secondRoot}},
|
|
Machine: "host", IDPrefix: "host~", PathRewriter: rewriter, Ephemeral: true,
|
|
})
|
|
runSyncAndAssert(t, secondEngine, sync.SyncStats{TotalSessions: 1, Synced: 1})
|
|
|
|
msgs := fetchMessages(t, database, "host~codex:"+uuid)
|
|
require.Len(t, msgs, 1)
|
|
assert.Equal(t, "bravo request", msgs[0].Content)
|
|
after, err := database.GetSessionFull(context.Background(), "host~codex:"+uuid)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, after)
|
|
require.NotNil(t, after.FileHash)
|
|
assert.False(t, after.LastWriteIncremental)
|
|
assert.NotEqual(t, beforeHash, *after.FileHash)
|
|
wantHash, err := sync.ComputeFileHash(secondPath)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, wantHash, *after.FileHash)
|
|
}
|
|
|
|
func TestIncrementalSync_CodexLifecycleTailUpdatesTermination(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
const uuid = "019eb791-cf7d-75c1-8439-9ed74c1229e7"
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON(
|
|
uuid, "/tmp/proj", "codex_cli_rs", tsEarly,
|
|
),
|
|
testjsonl.CodexMsgJSON("user", "hello", tsEarlyS1),
|
|
`{"type":"event_msg","timestamp":"2024-01-01T10:00:02Z","payload":{"type":"task_complete"}}`,
|
|
)
|
|
path := env.writeCodexSession(
|
|
t, filepath.Join("2024", "01", "01"),
|
|
"rollout-2024-01-01T10-00-00-"+uuid+".jsonl", initial,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
before := fetchMessages(t, env.db, "codex:"+uuid)
|
|
require.Len(t, before, 1)
|
|
firstMessageID := before[0].ID
|
|
sess, err := env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err, "GetSessionFull after initial parse")
|
|
require.NotNil(t, sess)
|
|
require.NotNil(t, sess.TerminationStatus)
|
|
assert.Equal(t, "awaiting_user", *sess.TerminationStatus)
|
|
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err, "open for task_started append")
|
|
_, err = f.WriteString(
|
|
`{"type":"event_msg","timestamp":"2024-01-01T10:00:03Z","payload":{"type":"task_started"}}` + "\n",
|
|
)
|
|
require.NoError(t, err, "append task_started")
|
|
require.NoError(t, f.Close())
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
sess, err = env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err, "GetSessionFull after task_started")
|
|
require.NotNil(t, sess)
|
|
require.NotNil(t, sess.TerminationStatus)
|
|
assert.Equal(t, "tool_call_pending", *sess.TerminationStatus)
|
|
assert.True(t, sess.LastWriteIncremental)
|
|
afterStarted := fetchMessages(t, env.db, "codex:"+uuid)
|
|
require.Len(t, afterStarted, 1)
|
|
assert.Equal(t, firstMessageID, afterStarted[0].ID,
|
|
"message-less lifecycle tail must not replace messages")
|
|
|
|
f, err = os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err, "open for task_complete append")
|
|
_, err = f.WriteString(
|
|
`{"type":"event_msg","timestamp":"2024-01-01T10:00:04Z","payload":{"type":"task_complete"}}` + "\n",
|
|
)
|
|
require.NoError(t, err, "append task_complete")
|
|
require.NoError(t, f.Close())
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
sess, err = env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err, "GetSessionFull after task_complete")
|
|
require.NotNil(t, sess)
|
|
require.NotNil(t, sess.TerminationStatus)
|
|
assert.Equal(t, "awaiting_user", *sess.TerminationStatus)
|
|
assert.True(t, sess.LastWriteIncremental)
|
|
afterComplete := fetchMessages(t, env.db, "codex:"+uuid)
|
|
require.Len(t, afterComplete, 1)
|
|
assert.Equal(t, firstMessageID, afterComplete[0].ID)
|
|
|
|
f, err = os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err, "open for message-bearing lifecycle append")
|
|
_, err = f.WriteString(testjsonl.JoinJSONL(
|
|
`{"type":"event_msg","timestamp":"2024-01-01T10:00:05Z","payload":{"type":"task_started"}}`,
|
|
testjsonl.CodexMsgJSON(
|
|
"assistant", "working", "2024-01-01T10:00:06Z",
|
|
),
|
|
))
|
|
require.NoError(t, err, "append message-bearing lifecycle tail")
|
|
require.NoError(t, f.Close())
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
sess, err = env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err, "GetSessionFull after message-bearing tail")
|
|
require.NotNil(t, sess)
|
|
require.NotNil(t, sess.TerminationStatus)
|
|
assert.Equal(t, "tool_call_pending", *sess.TerminationStatus)
|
|
assert.True(t, sess.LastWriteIncremental)
|
|
afterMessage := fetchMessages(t, env.db, "codex:"+uuid)
|
|
require.Len(t, afterMessage, 2)
|
|
assert.Equal(t, firstMessageID, afterMessage[0].ID)
|
|
assert.Equal(t, "working", afterMessage[1].Content)
|
|
}
|
|
|
|
func TestIncrementalSync_CodexStaleProjectForcesFullReparse(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
const uuid = "019eb791-cf7d-75c1-8439-9ed74c1229e8"
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON(
|
|
uuid, "/home/user/code/agentsview", "codex_cli_rs", tsEarly,
|
|
),
|
|
testjsonl.CodexMsgJSON("user", "hello", tsEarlyS1),
|
|
)
|
|
path := env.writeCodexSession(
|
|
t, filepath.Join("2024", "01", "01"),
|
|
"rollout-2024-01-01T10-00-00-"+uuid+".jsonl", initial,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
before, err := env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, before)
|
|
require.Equal(t, "agentsview", before.Project)
|
|
require.NoError(t, env.db.Update(func(tx *sql.Tx) error {
|
|
_, err := tx.Exec(
|
|
"UPDATE sessions SET project = ? WHERE id = ?",
|
|
"roborev_ci_28293_3831737461", "codex:"+uuid,
|
|
)
|
|
return err
|
|
}))
|
|
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err, "open for append")
|
|
_, err = f.WriteString(
|
|
testjsonl.CodexMsgJSON("assistant", "world", tsEarlyS5) + "\n",
|
|
)
|
|
require.NoError(t, err, "append assistant message")
|
|
require.NoError(t, f.Close())
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
after, err := env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, after)
|
|
assert.Equal(t, "agentsview", after.Project)
|
|
assert.False(t, after.LastWriteIncremental,
|
|
"project repair must use the authoritative full parse")
|
|
assertSessionMessageCount(t, env.db, "codex:"+uuid, 2)
|
|
}
|
|
|
|
func TestSyncSingleSessionCodexAppendForcesFullReplacement(t *testing.T) {
|
|
root := t.TempDir()
|
|
codexDir := filepath.Join(root, "sessions")
|
|
require.NoError(t, os.MkdirAll(codexDir, 0o755))
|
|
env := setupTestEnv(t, WithCodexDirs([]string{codexDir}))
|
|
|
|
const uuid = "019eb791-cf7d-75c1-8439-9ed74c1229e9"
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON(
|
|
uuid, "/tmp/proj", "codex_cli_rs", tsEarly,
|
|
),
|
|
testjsonl.CodexMsgJSON("user", "hello", tsEarlyS1),
|
|
)
|
|
path := env.writeCodexSession(
|
|
t, filepath.Join("2024", "01", "01"),
|
|
"rollout-2024-01-01T10-00-00-"+uuid+".jsonl", initial,
|
|
)
|
|
indexPath := filepath.Join(root, "session_index.jsonl")
|
|
require.NoError(t, os.WriteFile(indexPath, []byte(
|
|
`{"id":"`+uuid+`","thread_name":"Original title"}`+"\n",
|
|
), 0o644))
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err, "open for append")
|
|
_, err = f.WriteString(
|
|
testjsonl.CodexMsgJSON("assistant", "world", tsEarlyS5) + "\n",
|
|
)
|
|
require.NoError(t, err, "append assistant message")
|
|
require.NoError(t, f.Close())
|
|
|
|
require.NoError(t, env.engine.SyncSingleSession("codex:"+uuid))
|
|
|
|
sess, err := env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
require.NotNil(t, sess.SessionName)
|
|
assert.Equal(t, "Original title", *sess.SessionName)
|
|
assert.False(t, sess.LastWriteIncremental,
|
|
"per-file ForceParse must take the full replacement path")
|
|
msgs := fetchMessages(t, env.db, "codex:"+uuid)
|
|
require.Len(t, msgs, 2)
|
|
assert.Equal(t, []string{"hello", "world"},
|
|
[]string{msgs[0].Content, msgs[1].Content})
|
|
}
|
|
|
|
func TestSyncPathsCodexEqualFingerprintTitleOnlyRefreshesName(t *testing.T) {
|
|
root := t.TempDir()
|
|
codexDir := filepath.Join(root, "sessions")
|
|
require.NoError(t, os.MkdirAll(codexDir, 0o755))
|
|
env := setupTestEnv(t, WithCodexDirs([]string{codexDir}))
|
|
|
|
const uuid = "019eb791-cf7d-75c1-8439-9ed74c1229eb"
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON(
|
|
uuid, "/tmp/proj", "codex_cli_rs", tsEarly,
|
|
),
|
|
testjsonl.CodexMsgJSON("user", "keep this message", tsEarlyS1),
|
|
)
|
|
path := env.writeCodexSession(
|
|
t, filepath.Join("2024", "01", "01"),
|
|
"rollout-2024-01-01T10-00-00-"+uuid+".jsonl", initial,
|
|
)
|
|
indexPath := filepath.Join(root, "session_index.jsonl")
|
|
require.NoError(t, os.WriteFile(indexPath, []byte(
|
|
`{"id":"`+uuid+`","thread_name":"Original title"}`+"\n",
|
|
), 0o644))
|
|
transcriptTime := time.Now().Add(-2 * time.Hour)
|
|
indexTime := time.Now().Add(-time.Hour)
|
|
require.NoError(t, os.Chtimes(path, transcriptTime, transcriptTime))
|
|
require.NoError(t, os.Chtimes(indexPath, indexTime, indexTime))
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
before, err := env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, before)
|
|
require.NotNil(t, before.SessionName)
|
|
require.NotNil(t, before.FileSize)
|
|
require.NotNil(t, before.FileMtime)
|
|
assert.Equal(t, "Original title", *before.SessionName)
|
|
rolloutInfo, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
require.Equal(t, rolloutInfo.Size(), *before.FileSize,
|
|
"stored transcript size precondition")
|
|
require.Less(t, rolloutInfo.ModTime().UnixNano(), *before.FileMtime,
|
|
"index mtime must determine the composite fingerprint")
|
|
|
|
require.NoError(t, os.WriteFile(indexPath, []byte(
|
|
`{"id":"`+uuid+`","thread_name":"Renamed title"}`+"\n",
|
|
), 0o644))
|
|
storedMtime := time.Unix(0, *before.FileMtime)
|
|
require.NoError(t, os.Chtimes(indexPath, storedMtime, storedMtime),
|
|
"restore index mtime after title-only rewrite")
|
|
indexInfo, err := os.Stat(indexPath)
|
|
require.NoError(t, err)
|
|
require.Equal(t, *before.FileMtime, indexInfo.ModTime().UnixNano(),
|
|
"title-only rewrite keeps the stored composite mtime")
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
after, err := env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, after)
|
|
require.NotNil(t, after.SessionName)
|
|
assert.Equal(t, "Renamed title", *after.SessionName)
|
|
assert.False(t, after.LastWriteIncremental,
|
|
"title refresh must use an authoritative full replacement")
|
|
msgs := fetchMessages(t, env.db, "codex:"+uuid)
|
|
require.Len(t, msgs, 1)
|
|
assert.Equal(t, "keep this message", msgs[0].Content)
|
|
}
|
|
|
|
func TestSyncPathsCodexIndexEventReloadsSameStatTitle(t *testing.T) {
|
|
root := t.TempDir()
|
|
codexDir := filepath.Join(root, "sessions")
|
|
require.NoError(t, os.MkdirAll(codexDir, 0o755))
|
|
env := setupTestEnv(t, WithCodexDirs([]string{codexDir}))
|
|
|
|
const uuid = "019eb791-cf7d-75c1-8439-9ed74c1229f7"
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON(
|
|
uuid, "/tmp/proj", "codex_cli_rs", tsEarly,
|
|
),
|
|
testjsonl.CodexMsgJSON("user", "keep this message", tsEarlyS1),
|
|
)
|
|
env.writeCodexSession(
|
|
t, filepath.Join("2024", "01", "01"),
|
|
"rollout-2024-01-01T10-00-00-"+uuid+".jsonl", initial,
|
|
)
|
|
indexPath := filepath.Join(root, parser.CodexSessionIndexFilename)
|
|
original := `{"id":"` + uuid + `","thread_name":"Alpha title"}` + "\n"
|
|
rewritten := `{"id":"` + uuid + `","thread_name":"Bravo title"}` + "\n"
|
|
require.Len(t, rewritten, len(original), "index fixtures must have equal length")
|
|
require.NoError(t, os.WriteFile(indexPath, []byte(original), 0o644))
|
|
stableTime := time.Unix(1_800_000_000, 456_000_000)
|
|
require.NoError(t, os.Chtimes(indexPath, stableTime, stableTime))
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
before, err := env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, before)
|
|
require.NotNil(t, before.SessionName)
|
|
assert.Equal(t, "Alpha title", *before.SessionName)
|
|
indexInfo, err := os.Stat(indexPath)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, os.WriteFile(indexPath, []byte(rewritten), 0o644))
|
|
require.NoError(t, os.Chtimes(indexPath, indexInfo.ModTime(), indexInfo.ModTime()))
|
|
rewrittenInfo, err := os.Stat(indexPath)
|
|
require.NoError(t, err)
|
|
require.Equal(t, indexInfo.Size(), rewrittenInfo.Size())
|
|
require.Equal(t, indexInfo.ModTime(), rewrittenInfo.ModTime())
|
|
|
|
env.engine.SyncPaths([]string{indexPath})
|
|
|
|
after, err := env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, after)
|
|
require.NotNil(t, after.SessionName)
|
|
assert.Equal(t, "Bravo title", *after.SessionName)
|
|
assert.False(t, after.LastWriteIncremental,
|
|
"index event refresh must use a full replacement")
|
|
}
|
|
|
|
func TestWatcherOverflowReloadsSameStatCodexIndexTitle(t *testing.T) {
|
|
root := t.TempDir()
|
|
codexDir := filepath.Join(root, "sessions")
|
|
require.NoError(t, os.MkdirAll(codexDir, 0o755))
|
|
env := setupTestEnv(t, WithCodexDirs([]string{codexDir}))
|
|
|
|
const uuid = "019eb791-cf7d-75c1-8439-9ed74c1229f8"
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON(
|
|
uuid, "/tmp/proj", "codex_cli_rs", tsEarly,
|
|
),
|
|
testjsonl.CodexMsgJSON("user", "keep overflow message", tsEarlyS1),
|
|
)
|
|
env.writeCodexSession(
|
|
t, filepath.Join("2024", "01", "01"),
|
|
"rollout-2024-01-01T10-00-00-"+uuid+".jsonl", initial,
|
|
)
|
|
indexPath := filepath.Join(root, parser.CodexSessionIndexFilename)
|
|
original := `{"id":"` + uuid + `","thread_name":"Alpha title"}` + "\n"
|
|
rewritten := `{"id":"` + uuid + `","thread_name":"Bravo title"}` + "\n"
|
|
require.Len(t, rewritten, len(original), "index fixtures must have equal length")
|
|
require.NoError(t, os.WriteFile(indexPath, []byte(original), 0o644))
|
|
stableTime := time.Unix(1_800_000_001, 456_000_000)
|
|
require.NoError(t, os.Chtimes(indexPath, stableTime, stableTime))
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
before, err := env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, before)
|
|
require.NotNil(t, before.SessionName)
|
|
assert.Equal(t, "Alpha title", *before.SessionName)
|
|
indexInfo, err := os.Stat(indexPath)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, os.WriteFile(indexPath, []byte(rewritten), 0o644))
|
|
require.NoError(t, os.Chtimes(indexPath, indexInfo.ModTime(), indexInfo.ModTime()))
|
|
rewrittenInfo, err := os.Stat(indexPath)
|
|
require.NoError(t, err)
|
|
require.Equal(t, indexInfo.Size(), rewrittenInfo.Size())
|
|
require.Equal(t, indexInfo.ModTime(), rewrittenInfo.ModTime())
|
|
|
|
stats := env.engine.SyncAllAfterWatcherOverflow(context.Background(), nil)
|
|
require.False(t, stats.Aborted)
|
|
after, err := env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, after)
|
|
require.NotNil(t, after.SessionName)
|
|
assert.Equal(t, "Bravo title", *after.SessionName)
|
|
assert.False(t, after.LastWriteIncremental)
|
|
msgs := fetchMessages(t, env.db, "codex:"+uuid)
|
|
require.Len(t, msgs, 1)
|
|
assert.Equal(t, "keep overflow message", msgs[0].Content)
|
|
}
|
|
|
|
// TestIncrementalSync_CodexStoresEffectiveMtime pins that the incremental
|
|
// append path persists the same session_index.jsonl-folded effective mtime a
|
|
// full Codex parse stores (parser.CodexEffectiveMtime). Without it an
|
|
// incrementally-synced Codex session would carry the plain rollout mtime,
|
|
// leaving the stored file_mtime on a different basis than the effective
|
|
// File.Mtime parse-diff's raced guard reads -- which would let an index newer
|
|
// than the rollout mask genuine transcript drift as raced.
|
|
func TestIncrementalSync_CodexStoresEffectiveMtime(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test")
|
|
}
|
|
root := t.TempDir()
|
|
codexDir := filepath.Join(root, "sessions")
|
|
require.NoError(t, os.MkdirAll(codexDir, 0o755))
|
|
env := setupTestEnv(t, WithCodexDirs([]string{codexDir}))
|
|
|
|
const uuid = "019eb791-cf7d-75c1-8439-9ed74c1229e1"
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON(
|
|
uuid, "/tmp/proj", "codex_cli_rs", tsEarly,
|
|
),
|
|
testjsonl.CodexMsgJSON("user", "hello", tsEarlyS1),
|
|
)
|
|
path := env.writeCodexSession(
|
|
t, filepath.Join("2024", "01", "01"),
|
|
"rollout-2024-01-01T10-00-00-"+uuid+".jsonl", initial,
|
|
)
|
|
indexPath := filepath.Join(root, "session_index.jsonl")
|
|
require.NoError(t, os.WriteFile(indexPath, []byte(
|
|
`{"id":"`+uuid+`","thread_name":"Title",`+
|
|
`"updated_at":"2024-01-01T10:00:00Z"}`+"\n",
|
|
), 0o644))
|
|
base := time.Now().Add(-2 * time.Hour)
|
|
require.NoError(t, os.Chtimes(path, base, base), "chtimes initial session")
|
|
require.NoError(t, os.Chtimes(indexPath, base, base), "chtimes initial index")
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
assertSessionMessageCount(t, env.db, "codex:"+uuid, 1)
|
|
|
|
// Append an assistant message so the next sync takes the incremental
|
|
// append path (the title is unchanged, so no index-rename full parse).
|
|
appended := testjsonl.JoinJSONL(
|
|
testjsonl.CodexMsgJSON("assistant", "world", tsEarlyS5),
|
|
)
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err, "open for append")
|
|
_, err = f.WriteString(appended)
|
|
require.NoError(t, f.Close())
|
|
require.NoError(t, err, "append")
|
|
|
|
// Push the index strictly past the rollout so the effective mtime differs
|
|
// from the plain rollout mtime.
|
|
rollTime := time.Now().Add(-30 * time.Minute)
|
|
require.NoError(t, os.Chtimes(path, rollTime, rollTime), "chtimes rollout")
|
|
require.NoError(t, os.Chtimes(
|
|
indexPath, rollTime.Add(time.Hour), rollTime.Add(time.Hour),
|
|
), "chtimes index")
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
assertSessionMessageCount(t, env.db, "codex:"+uuid, 2)
|
|
sess, err := env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err, "GetSessionFull")
|
|
require.NotNil(t, sess, "session present")
|
|
require.NotNil(t, sess.FileMtime, "file_mtime stored")
|
|
assert.True(t, sess.LastWriteIncremental,
|
|
"an unchanged-title append should stay on the incremental path")
|
|
|
|
// Compare against re-stats (not the requested Chtimes values) so the
|
|
// assertion is robust to filesystem mtime granularity.
|
|
idxInfo, err := os.Stat(indexPath)
|
|
require.NoError(t, err, "stat index")
|
|
rollInfo, err := os.Stat(path)
|
|
require.NoError(t, err, "stat rollout")
|
|
assert.Equal(t, idxInfo.ModTime().UnixNano(), *sess.FileMtime,
|
|
"incremental Codex write stores the index-folded effective mtime")
|
|
assert.Greater(t, *sess.FileMtime, rollInfo.ModTime().UnixNano(),
|
|
"effective mtime exceeds the plain rollout mtime")
|
|
}
|
|
|
|
func TestIncrementalSync_CodexPartialTailCommitsOnlySafeRecords(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test")
|
|
}
|
|
env := setupTestEnv(t)
|
|
|
|
const uuid = "019eb791-cf7d-75c1-8439-9ed74c1229e4"
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON(
|
|
uuid, "/tmp/proj", "codex_cli_rs", tsEarly,
|
|
),
|
|
testjsonl.CodexMsgJSON("user", "hello", tsEarlyS1),
|
|
)
|
|
path := env.writeCodexSession(
|
|
t, filepath.Join("2024", "01", "01"),
|
|
"rollout-2024-01-01T10-00-00-"+uuid+".jsonl", initial,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
assertSessionMessageCount(t, env.db, "codex:"+uuid, 1)
|
|
|
|
completeTail := testjsonl.JoinJSONL(
|
|
testjsonl.CodexMsgJSON("assistant", "world", tsEarlyS5),
|
|
)
|
|
partialLine := testjsonl.CodexMsgJSON(
|
|
"assistant", "completed later", "2024-01-01T10:00:10Z",
|
|
)
|
|
partialAt := len(partialLine) / 2
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err, "open for append")
|
|
_, err = f.WriteString(completeTail)
|
|
require.NoError(t, err, "append complete message")
|
|
_, err = f.WriteString(partialLine[:partialAt])
|
|
require.NoError(t, err, "append partial trailing JSON")
|
|
require.NoError(t, f.Close(), "close after append")
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
assertSessionMessageCount(t, env.db, "codex:"+uuid, 2)
|
|
|
|
sess, err := env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err, "GetSessionFull")
|
|
require.NotNil(t, sess, "session present")
|
|
require.NotNil(t, sess.FileSize, "file_size stored")
|
|
require.NotNil(t, sess.FileHash, "file_hash stored")
|
|
require.True(t, sess.LastWriteIncremental)
|
|
|
|
safeSize := int64(len(initial) + len(completeTail))
|
|
require.Equal(t, safeSize, *sess.FileSize,
|
|
"incremental cursor stops before the partial record")
|
|
safePrefix, err := os.ReadFile(path)
|
|
require.NoError(t, err, "read live transcript")
|
|
sum := sha256.Sum256(safePrefix[:safeSize])
|
|
wantHash := fmt.Sprintf("%x", sum[:])
|
|
assert.Equal(t, wantHash, *sess.FileHash,
|
|
"stored Codex hash covers only the committed safe prefix")
|
|
|
|
f, err = os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err, "reopen for append")
|
|
_, err = f.WriteString(partialLine[partialAt:] + "\n")
|
|
require.NoError(t, err, "complete partial record")
|
|
_, err = f.WriteString(testjsonl.CodexMsgJSON(
|
|
"assistant", "after partial", "2024-01-01T10:00:11Z",
|
|
) + "\n")
|
|
require.NoError(t, err, "append later record")
|
|
require.NoError(t, f.Close(), "close completed transcript")
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
msgs := fetchMessages(t, env.db, "codex:"+uuid)
|
|
require.Len(t, msgs, 4)
|
|
assert.Equal(t, []string{
|
|
"hello", "world", "completed later", "after partial",
|
|
}, []string{msgs[0].Content, msgs[1].Content, msgs[2].Content, msgs[3].Content})
|
|
sess, err = env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err, "GetSessionFull after partial completion")
|
|
require.NotNil(t, sess)
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err, "stat completed transcript")
|
|
require.NotNil(t, sess.FileSize)
|
|
assert.Equal(t, info.Size(), *sess.FileSize)
|
|
assert.True(t, sess.LastWriteIncremental)
|
|
}
|
|
|
|
func TestIncrementalSync_CodexZeroConsumedPartialTailRetriesAtSameMtime(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
const uuid = "019eb791-cf7d-75c1-8439-9ed74c1229ec"
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON(
|
|
uuid, "/tmp/proj", "codex_cli_rs", tsEarly,
|
|
),
|
|
testjsonl.CodexMsgJSON("user", "hello", tsEarlyS1),
|
|
)
|
|
path := env.writeCodexSession(
|
|
t, filepath.Join("2024", "01", "01"),
|
|
"rollout-2024-01-01T10-00-00-"+uuid+".jsonl", initial,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
completeLine := testjsonl.CodexMsgJSON(
|
|
"assistant", "arrived after partial write", tsEarlyS5,
|
|
)
|
|
partialAt := len(completeLine) / 2
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err, "open transcript for partial append")
|
|
_, err = f.WriteString(completeLine[:partialAt])
|
|
require.NoError(t, err, "append incomplete record")
|
|
require.NoError(t, f.Close())
|
|
fixedMtime := time.Now().Add(-time.Hour).Truncate(time.Second)
|
|
require.NoError(t, os.Chtimes(path, fixedMtime, fixedMtime))
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
intermediate, err := env.db.GetSessionFull(
|
|
context.Background(), "codex:"+uuid,
|
|
)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, intermediate)
|
|
require.NotNil(t, intermediate.FileSize)
|
|
assert.Equal(t, int64(len(initial)), *intermediate.FileSize,
|
|
"an incomplete record must not advance the committed offset")
|
|
assert.Equal(t, 1, intermediate.MessageCount)
|
|
assert.False(t, intermediate.LastWriteIncremental,
|
|
"zero consumed bytes must not write an incremental update")
|
|
|
|
f, err = os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err, "reopen transcript to complete record")
|
|
_, err = f.WriteString(completeLine[partialAt:] + "\n")
|
|
require.NoError(t, err, "complete record")
|
|
require.NoError(t, f.Close())
|
|
require.NoError(t, os.Chtimes(path, fixedMtime, fixedMtime),
|
|
"restore the exact mtime cached by the partial pass")
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
msgs := fetchMessages(t, env.db, "codex:"+uuid)
|
|
require.Len(t, msgs, 2)
|
|
assert.Equal(t, []string{"hello", "arrived after partial write"},
|
|
[]string{msgs[0].Content, msgs[1].Content})
|
|
final, err := env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, final)
|
|
require.NotNil(t, final.FileSize)
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, info.Size(), *final.FileSize)
|
|
assert.True(t, final.LastWriteIncremental)
|
|
}
|
|
|
|
func TestIncrementalSync_CodexUnsafeStoredOffsetForcesFullReparse(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
const uuid = "019eb791-cf7d-75c1-8439-9ed74c1229ea"
|
|
safePrefix := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON(
|
|
uuid, "/tmp/proj", "codex_cli_rs", tsEarly,
|
|
),
|
|
testjsonl.CodexMsgJSON("user", "hello", tsEarlyS1),
|
|
)
|
|
completedLine := testjsonl.CodexMsgJSON(
|
|
"assistant", "completed record", tsEarlyS5,
|
|
)
|
|
partialAt := len(completedLine) / 2
|
|
unsafeSnapshot := safePrefix + completedLine[:partialAt]
|
|
path := env.writeCodexSession(
|
|
t, filepath.Join("2024", "01", "01"),
|
|
"rollout-2024-01-01T10-00-00-"+uuid+".jsonl",
|
|
unsafeSnapshot,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
initial, err := env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, initial)
|
|
require.NotNil(t, initial.FileSize)
|
|
assert.Equal(t, int64(len(unsafeSnapshot)), *initial.FileSize,
|
|
"full parse records the raw snapshot size even when it ends mid-record")
|
|
assertSessionMessageCount(t, env.db, "codex:"+uuid, 1)
|
|
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err, "open unsafe snapshot for completion")
|
|
_, err = f.WriteString(completedLine[partialAt:] + "\n")
|
|
require.NoError(t, err, "complete stored mid-record line")
|
|
_, err = f.WriteString(testjsonl.CodexMsgJSON(
|
|
"assistant", "later record", "2024-01-01T10:00:06Z",
|
|
) + "\n")
|
|
require.NoError(t, err, "append later record")
|
|
require.NoError(t, f.Close())
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
msgs := fetchMessages(t, env.db, "codex:"+uuid)
|
|
require.Len(t, msgs, 3)
|
|
assert.Equal(t, []string{"hello", "completed record", "later record"},
|
|
[]string{msgs[0].Content, msgs[1].Content, msgs[2].Content})
|
|
after, err := env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, after)
|
|
assert.False(t, after.LastWriteIncremental,
|
|
"unsafe stored offset must force an authoritative message replacement")
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, after.FileSize)
|
|
assert.Equal(t, info.Size(), *after.FileSize)
|
|
}
|
|
|
|
func TestIncrementalSync_CodexExecAppendRetainsEvents(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON(
|
|
"inc-cx-exec", "/tmp/proj",
|
|
"codex_exec", tsEarly,
|
|
),
|
|
testjsonl.CodexMsgJSON("user", "run command", tsEarlyS1),
|
|
testjsonl.CodexFunctionCallWithCallIDJSON(
|
|
"exec_command", "call_cmd",
|
|
map[string]any{"cmd": "sleep 1"}, tsEarlyS5,
|
|
),
|
|
)
|
|
path := env.writeCodexSession(
|
|
t, filepath.Join("2024", "01", "01"),
|
|
"rollout-20240101-inc-cx-exec.jsonl", initial,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
appended := testjsonl.JoinJSONL(
|
|
testjsonl.CodexFunctionCallOutputJSON(
|
|
"call_cmd", "done", "2024-01-01T10:01:00Z",
|
|
),
|
|
)
|
|
f, err := os.OpenFile(
|
|
path, os.O_APPEND|os.O_WRONLY, 0o644,
|
|
)
|
|
require.NoError(t, err, "open for append")
|
|
_, err = f.WriteString(appended)
|
|
require.NoError(t, err, "append")
|
|
require.NoError(t, f.Close())
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
msgs := fetchMessages(t, env.db, "codex:inc-cx-exec")
|
|
require.Len(t, msgs, 2)
|
|
require.Len(t, msgs[1].ToolCalls, 1)
|
|
assert.Equal(t, "exec_command", msgs[1].ToolCalls[0].ToolName, "tool name")
|
|
assert.Equal(t, "done", msgs[1].ToolCalls[0].ResultContent, "result_content")
|
|
}
|
|
|
|
func TestIncrementalSync_CodexLateTokenCountRewritesStoredMessage(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON(
|
|
"inc-cx-late-usage", "/tmp/proj",
|
|
"codex_cli_rs", tsEarly,
|
|
),
|
|
testjsonl.CodexTurnContextJSON("gpt-5.5", tsEarlyS1),
|
|
testjsonl.CodexMsgJSON("user", "run command", tsEarlyS1),
|
|
testjsonl.CodexFunctionCallWithCallIDJSON(
|
|
"exec_command", "call_cmd",
|
|
map[string]any{"cmd": "sleep 1"}, tsEarlyS5,
|
|
),
|
|
)
|
|
path := env.writeCodexSession(
|
|
t, filepath.Join("2024", "01", "01"),
|
|
"rollout-20240101-inc-cx-late-usage.jsonl",
|
|
initial,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
msgs := fetchMessages(t, env.db, "codex:inc-cx-late-usage")
|
|
require.Len(t, msgs, 2)
|
|
assert.Empty(t, msgs[1].TokenUsage)
|
|
|
|
appended := testjsonl.JoinJSONL(
|
|
testjsonl.CodexFunctionCallOutputJSON(
|
|
"call_cmd", "done", "2024-01-01T10:01:00Z",
|
|
),
|
|
testjsonl.CodexTokenCountJSON(
|
|
"2024-01-01T10:01:00Z", 100_000, 250, 64_000,
|
|
),
|
|
)
|
|
f, err := os.OpenFile(
|
|
path, os.O_APPEND|os.O_WRONLY, 0o644,
|
|
)
|
|
require.NoError(t, err, "open for append")
|
|
_, err = f.WriteString(appended)
|
|
require.NoError(t, err, "append")
|
|
require.NoError(t, f.Close())
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
msgs = fetchMessages(t, env.db, "codex:inc-cx-late-usage")
|
|
require.Len(t, msgs, 2)
|
|
assert.NotEmpty(t, msgs[1].TokenUsage)
|
|
assert.Equal(t, 250, msgs[1].OutputTokens)
|
|
assert.Equal(t, 100_000, msgs[1].ContextTokens)
|
|
}
|
|
|
|
func TestIncrementalSync_CodexLateTokenCountWithIndexRenameRewritesStoredMessage(t *testing.T) {
|
|
root := t.TempDir()
|
|
codexDir := filepath.Join(root, "sessions")
|
|
require.NoError(t, os.MkdirAll(codexDir, 0o755))
|
|
env := setupTestEnv(t, WithCodexDirs([]string{codexDir}))
|
|
|
|
uuid := "019eb791-cf7d-75c1-8439-9ed74c1229e1"
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON(
|
|
uuid, "/tmp/proj",
|
|
"codex_cli_rs", tsEarly,
|
|
),
|
|
testjsonl.CodexTurnContextJSON("gpt-5.5", tsEarlyS1),
|
|
testjsonl.CodexMsgJSON("user", "run command", tsEarlyS1),
|
|
testjsonl.CodexFunctionCallWithCallIDJSON(
|
|
"exec_command", "call_cmd",
|
|
map[string]any{"cmd": "sleep 1"}, tsEarlyS5,
|
|
),
|
|
)
|
|
path := env.writeCodexSession(
|
|
t, filepath.Join("2024", "01", "01"),
|
|
"rollout-2024-01-01T10-00-00-"+uuid+".jsonl",
|
|
initial,
|
|
)
|
|
|
|
indexPath := filepath.Join(root, "session_index.jsonl")
|
|
require.NoError(t, os.WriteFile(indexPath, fmt.Appendf(nil,
|
|
`{"id":"%s","thread_name":"Original title","updated_at":"2024-01-01T10:00:00Z"}`+"\n",
|
|
uuid,
|
|
), 0o644))
|
|
|
|
initialTime := time.Now().Add(-2 * time.Hour)
|
|
require.NoError(t, os.Chtimes(path, initialTime, initialTime), "chtimes initial session")
|
|
require.NoError(t, os.Chtimes(indexPath, initialTime, initialTime), "chtimes initial index")
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
msgs := fetchMessages(t, env.db, "codex:"+uuid)
|
|
require.Len(t, msgs, 2)
|
|
assert.Empty(t, msgs[1].TokenUsage)
|
|
|
|
appended := testjsonl.JoinJSONL(
|
|
testjsonl.CodexFunctionCallOutputJSON(
|
|
"call_cmd", "done", "2024-01-01T10:01:00Z",
|
|
),
|
|
testjsonl.CodexTokenCountJSON(
|
|
"2024-01-01T10:01:00Z", 100_000, 250, 64_000,
|
|
),
|
|
)
|
|
f, err := os.OpenFile(
|
|
path, os.O_APPEND|os.O_WRONLY, 0o644,
|
|
)
|
|
require.NoError(t, err, "open for append")
|
|
_, err = f.WriteString(appended)
|
|
require.NoError(t, err, "append")
|
|
require.NoError(t, f.Close())
|
|
|
|
newTime := time.Now().Add(-30 * time.Minute)
|
|
require.NoError(t, os.Chtimes(path, newTime, newTime), "chtimes appended session")
|
|
require.NoError(t, os.WriteFile(indexPath, fmt.Appendf(nil,
|
|
`{"id":"%s","thread_name":"Renamed title","updated_at":"2024-01-01T10:01:00Z"}`+"\n",
|
|
uuid,
|
|
), 0o644))
|
|
require.NoError(t, os.Chtimes(indexPath, newTime.Add(time.Second), newTime.Add(time.Second)), "chtimes renamed index")
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
sess, err := env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err, "GetSessionFull after rename")
|
|
require.NotNil(t, sess, "expected Codex session to remain")
|
|
if assert.NotNil(t, sess.SessionName, "expected renamed session_name") {
|
|
assert.Equal(t, "Renamed title", *sess.SessionName)
|
|
}
|
|
|
|
msgs = fetchMessages(t, env.db, "codex:"+uuid)
|
|
require.Len(t, msgs, 2)
|
|
assert.NotEmpty(t, msgs[1].TokenUsage)
|
|
assert.Equal(t, 250, msgs[1].OutputTokens)
|
|
assert.Equal(t, 100_000, msgs[1].ContextTokens)
|
|
}
|
|
|
|
// TestIncrementalSync_CodexIndexRenameBelowStoredMtimeRefreshesName covers a
|
|
// stale-title race introduced by folding the session_index.jsonl mtime into the
|
|
// incremental write's stored file_mtime. The initial parse stores a high
|
|
// effective mtime (the index mtime is newer than the transcript). A later index
|
|
// rename whose mtime is <= that stored value cannot be detected by a
|
|
// indexMtime > storedMtime gate, so the incremental path must compare the
|
|
// session name directly and fall back to a full parse, otherwise
|
|
// shouldSkipCodexFingerprint's storedMtime==effectiveMtime fast path would
|
|
// strand the stale title on every subsequent sync.
|
|
func TestIncrementalSync_CodexIndexRenameBelowStoredMtimeRefreshesName(t *testing.T) {
|
|
root := t.TempDir()
|
|
codexDir := filepath.Join(root, "sessions")
|
|
require.NoError(t, os.MkdirAll(codexDir, 0o755))
|
|
env := setupTestEnv(t, WithCodexDirs([]string{codexDir}))
|
|
|
|
uuid := "019eb791-cf7d-75c1-8439-9ed74c1229e1"
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON(
|
|
uuid, "/tmp/proj", "codex_cli_rs", tsEarly,
|
|
),
|
|
testjsonl.CodexTurnContextJSON("gpt-5.5", tsEarlyS1),
|
|
testjsonl.CodexMsgJSON("user", "first", tsEarlyS1),
|
|
)
|
|
path := env.writeCodexSession(
|
|
t, filepath.Join("2024", "01", "01"),
|
|
"rollout-2024-01-01T10-00-00-"+uuid+".jsonl",
|
|
initial,
|
|
)
|
|
|
|
indexPath := filepath.Join(root, "session_index.jsonl")
|
|
require.NoError(t, os.WriteFile(indexPath, fmt.Appendf(nil,
|
|
`{"id":"%s","thread_name":"Original title","updated_at":"2024-01-01T10:00:00Z"}`+"\n",
|
|
uuid,
|
|
), 0o644))
|
|
|
|
// The transcript is older than the index, so the initial parse stores the
|
|
// index mtime as the effective file_mtime.
|
|
transcriptTime := time.Now().Add(-2 * time.Hour)
|
|
highIndexTime := time.Now().Add(-30 * time.Minute)
|
|
require.NoError(t, os.Chtimes(path, transcriptTime, transcriptTime), "chtimes initial session")
|
|
require.NoError(t, os.Chtimes(indexPath, highIndexTime, highIndexTime), "chtimes initial index")
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
sess, err := env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err, "GetSessionFull")
|
|
require.NotNil(t, sess, "expected Codex session to sync")
|
|
require.NotNil(t, sess.SessionName, "expected session_name to be imported")
|
|
require.Equal(t, "Original title", *sess.SessionName)
|
|
require.NotNil(t, sess.FileMtime, "expected stored file_mtime")
|
|
require.Equal(t, highIndexTime.UnixNano(), *sess.FileMtime,
|
|
"expected stored file_mtime to fold in the higher index mtime")
|
|
|
|
// Append to the transcript (an incremental update) and rename the index with
|
|
// an mtime BELOW the stored file_mtime. effectiveMtime never exceeds the
|
|
// stored value, so only a direct session-name comparison can catch the
|
|
// rename.
|
|
appended := testjsonl.JoinJSONL(
|
|
testjsonl.CodexMsgJSON("assistant", "second", tsEarlyS5),
|
|
)
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err, "open for append")
|
|
_, err = f.WriteString(appended)
|
|
require.NoError(t, err, "append")
|
|
require.NoError(t, f.Close())
|
|
|
|
appendTime := time.Now().Add(-90 * time.Minute)
|
|
lowIndexTime := time.Now().Add(-45 * time.Minute)
|
|
require.NoError(t, os.Chtimes(path, appendTime, appendTime), "chtimes appended session")
|
|
require.NoError(t, os.WriteFile(indexPath, fmt.Appendf(nil,
|
|
`{"id":"%s","thread_name":"Renamed title","updated_at":"2024-01-01T10:01:00Z"}`+"\n",
|
|
uuid,
|
|
), 0o644))
|
|
require.NoError(t, os.Chtimes(indexPath, lowIndexTime, lowIndexTime), "chtimes renamed index")
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
sess, err = env.db.GetSessionFull(context.Background(), "codex:"+uuid)
|
|
require.NoError(t, err, "GetSessionFull after rename")
|
|
require.NotNil(t, sess, "expected Codex session to remain")
|
|
if assert.NotNil(t, sess.SessionName, "expected renamed session_name") {
|
|
assert.Equal(t, "Renamed title", *sess.SessionName)
|
|
}
|
|
assert.False(t, sess.LastWriteIncremental,
|
|
"a changed title must force an authoritative full replacement")
|
|
// The incremental append must still have landed.
|
|
msgs := fetchMessages(t, env.db, "codex:"+uuid)
|
|
require.Len(t, msgs, 2)
|
|
}
|
|
|
|
func TestIncrementalSync_CodexSubagentAppendFallsBackToFullParse(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
childID := "019c9c96-6ee7-77c0-ba4c-380f844289d5"
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON(
|
|
"inc-cx-sub", "/tmp/proj",
|
|
"codex_cli_rs", tsEarly,
|
|
),
|
|
testjsonl.CodexMsgJSON("user", "run child", tsEarlyS1),
|
|
testjsonl.CodexFunctionCallWithCallIDJSON("spawn_agent", "call_spawn", map[string]any{
|
|
"agent_type": "awaiter",
|
|
"message": "run it",
|
|
}, tsEarlyS5),
|
|
testjsonl.CodexFunctionCallOutputJSON("call_spawn", `{"agent_id":"`+childID+`","nickname":"Fennel"}`, "2024-01-01T10:01:00Z"),
|
|
)
|
|
path := env.writeCodexSession(
|
|
t, filepath.Join("2024", "01", "01"),
|
|
"rollout-20240101-inc-cx-sub.jsonl", initial,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
assertSessionMessageCount(
|
|
t, env.db, "codex:inc-cx-sub", 2,
|
|
)
|
|
|
|
appended := testjsonl.JoinJSONL(
|
|
testjsonl.CodexFunctionCallWithCallIDJSON("wait", "call_wait", map[string]any{
|
|
"ids": []string{childID},
|
|
}, "2024-01-01T10:01:06Z"),
|
|
testjsonl.CodexFunctionCallOutputJSON("call_wait",
|
|
"{\"status\":{\""+childID+"\":{\"completed\":\"Finished successfully\"}}}",
|
|
"2024-01-01T10:01:07Z",
|
|
),
|
|
)
|
|
f, err := os.OpenFile(
|
|
path, os.O_APPEND|os.O_WRONLY, 0o644,
|
|
)
|
|
require.NoError(t, err, "open for append")
|
|
_, err = f.WriteString(appended)
|
|
f.Close()
|
|
require.NoError(t, err, "append")
|
|
|
|
// SyncPaths hits the incremental Codex path first. The appended
|
|
// wait call is an explicit full-parse fallback case and should
|
|
// still produce the final parsed state successfully.
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
assertSessionMessageCount(
|
|
t, env.db, "codex:inc-cx-sub", 3,
|
|
)
|
|
msgs := fetchMessages(t, env.db, "codex:inc-cx-sub")
|
|
require.Equal(t, 3, len(msgs), "messages len = %d, want 3", len(msgs))
|
|
require.Equal(t, 1, len(msgs[2].ToolCalls), "tool calls len = %d, want 1", len(msgs[2].ToolCalls))
|
|
waitCall := msgs[2].ToolCalls[0]
|
|
require.Equal(t, "wait", waitCall.ToolName, "tool name = %q, want %q", waitCall.ToolName, "wait")
|
|
require.Equal(t, 1, len(waitCall.ResultEvents), "result events len = %d, want 1", len(waitCall.ResultEvents))
|
|
require.Equal(t, childID, waitCall.ResultEvents[0].AgentID, "event agent_id = %q, want %q", waitCall.ResultEvents[0].AgentID, childID)
|
|
require.Equal(t, "Finished successfully", waitCall.ResultEvents[0].Content, "event content = %q, want %q", waitCall.ResultEvents[0].Content, "Finished successfully")
|
|
require.Equal(t, "Finished successfully", waitCall.ResultContent, "result_content = %q, want %q", waitCall.ResultContent, "Finished successfully")
|
|
}
|
|
|
|
func TestResyncAllCancelledPreservesOriginalDB(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
// Seed the DB with a session via Claude JSONL.
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "hello").
|
|
AddClaudeAssistant(tsEarlyS5, "world").
|
|
String()
|
|
env.writeClaudeSession(
|
|
t, "cancel-project", "cancel-sess.jsonl", content,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
// Verify session exists with messages.
|
|
sess, err := env.db.GetSession(
|
|
context.Background(), "cancel-sess",
|
|
)
|
|
require.NoError(t, err, "session not found")
|
|
require.NotNil(t, sess, "session not found")
|
|
origCount := sess.MessageCount
|
|
require.NotZero(t, origCount, "expected messages after initial sync")
|
|
|
|
// Cancel the context before starting ResyncAll so
|
|
// collectAndBatch aborts immediately.
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
stats := env.engine.ResyncAll(ctx, nil)
|
|
|
|
require.True(t, stats.Aborted, "expected ResyncAll to report Aborted")
|
|
assert.Equal(t, []string{"resync aborted: 0 synced, 0 failed"}, stats.Warnings)
|
|
assert.Equal(t, stats, env.engine.LastSyncStats(),
|
|
"legacy LastSyncStats must match the returned cancellation result")
|
|
|
|
// Original DB should be preserved — session still
|
|
// has the original data.
|
|
sess, err = env.db.GetSession(
|
|
context.Background(), "cancel-sess",
|
|
)
|
|
require.NoError(t, err, "session lost after cancelled resync")
|
|
require.NotNil(t, sess, "session lost after cancelled resync")
|
|
assert.Equal(t, origCount, sess.MessageCount, "message count = %d, want %d", sess.MessageCount, origCount)
|
|
}
|
|
|
|
func TestSyncAllCancelledDoesNotUpdateLastSync(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
// Seed the DB with a session.
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "hello").
|
|
String()
|
|
env.writeClaudeSession(
|
|
t, "ls-project", "ls-sess.jsonl", content,
|
|
)
|
|
|
|
// Run a successful sync to set lastSync.
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
lastSync := env.engine.LastSync()
|
|
require.False(t, lastSync.IsZero(), "expected lastSync to be set")
|
|
lastStats := env.engine.LastSyncStats()
|
|
require.NotZero(t, lastStats.Synced, "expected synced > 0")
|
|
|
|
// Run a cancelled sync.
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
stats := env.engine.SyncAll(ctx, nil)
|
|
require.True(t, stats.Aborted, "expected SyncAll to report Aborted")
|
|
|
|
// lastSync and lastSyncStats should be unchanged.
|
|
assert.Equal(t, lastSync, env.engine.LastSync(), "lastSync was updated by cancelled sync")
|
|
assert.Equal(t, lastStats.Synced, env.engine.LastSyncStats().Synced, "lastSyncStats was updated by cancelled sync")
|
|
}
|
|
|
|
func TestSyncAllSince_FiltersByMtime(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
// Seed the DB with two sessions.
|
|
oldContent := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "old session").
|
|
String()
|
|
oldPath := env.writeClaudeSession(
|
|
t, "proj-old", "old-sess.jsonl", oldContent,
|
|
)
|
|
|
|
newContent := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "new session").
|
|
String()
|
|
newPath := env.writeClaudeSession(
|
|
t, "proj-new", "new-sess.jsonl", newContent,
|
|
)
|
|
|
|
// Backdate the old file to simulate an unchanged prior
|
|
// session; keep the new file at its natural mtime.
|
|
longAgo := time.Now().Add(-48 * time.Hour)
|
|
require.NoError(t, os.Chtimes(oldPath, longAgo, longAgo), "chtimes old")
|
|
|
|
// SyncAllSince with a cutoff 1 hour ago should only
|
|
// process the new file.
|
|
cutoff := time.Now().Add(-1 * time.Hour)
|
|
stats := env.engine.SyncAllSince(
|
|
context.Background(), cutoff, nil,
|
|
)
|
|
assert.Equal(t, 1, stats.Synced, "synced = %d, want 1", stats.Synced)
|
|
|
|
// Verify only the new session is in the DB.
|
|
page, err := env.db.ListSessions(
|
|
context.Background(), db.SessionFilter{Limit: 10},
|
|
)
|
|
require.NoError(t, err, "list sessions")
|
|
require.Equal(t, 1, len(page.Sessions), "sessions = %d, want 1", len(page.Sessions))
|
|
|
|
// Second call with zero cutoff syncs everything.
|
|
stats = env.engine.SyncAllSince(
|
|
context.Background(), time.Time{}, nil,
|
|
)
|
|
// The new file is already in the DB (skip cache);
|
|
// the old file should now be synced too.
|
|
assert.NotZero(t, stats.Synced, "expected second sync to pick up backdated file")
|
|
|
|
page, err = env.db.ListSessions(
|
|
context.Background(), db.SessionFilter{Limit: 10},
|
|
)
|
|
require.NoError(t, err, "list sessions")
|
|
assert.Equal(t, 2, len(page.Sessions), "sessions = %d, want 2", len(page.Sessions))
|
|
|
|
_ = newPath
|
|
}
|
|
|
|
func TestSyncRootsSinceScopesDiscoveredFiles(t *testing.T) {
|
|
rootA := t.TempDir()
|
|
rootB := t.TempDir()
|
|
env := setupTestEnv(t, WithClaudeDirs([]string{rootA, rootB}))
|
|
|
|
contentA := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "root a").
|
|
String()
|
|
pathA := env.writeSession(
|
|
t, rootA, filepath.Join("proj-a", "root-a.jsonl"), contentA,
|
|
)
|
|
|
|
contentB := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "root b").
|
|
String()
|
|
pathB := env.writeSession(
|
|
t, rootB, filepath.Join("proj-b", "root-b.jsonl"), contentB,
|
|
)
|
|
|
|
longAgo := time.Now().Add(-48 * time.Hour)
|
|
require.NoError(t, os.Chtimes(pathA, longAgo, longAgo), "chtimes root a")
|
|
require.NoError(t, os.Chtimes(pathB, longAgo, longAgo), "chtimes root b")
|
|
|
|
cutoff := time.Now().Add(-1 * time.Hour)
|
|
require.NoError(t, os.Chtimes(pathB, time.Now(), time.Now()), "touch root b")
|
|
|
|
stats := env.engine.SyncRootsSince(
|
|
context.Background(), []string{rootB}, cutoff, nil,
|
|
)
|
|
assert.Equal(t, 1, stats.TotalSessions, "total sessions")
|
|
assert.Equal(t, 1, stats.Synced, "synced sessions")
|
|
|
|
page, err := env.db.ListSessions(
|
|
context.Background(), db.SessionFilter{Limit: 10},
|
|
)
|
|
require.NoError(t, err, "list sessions")
|
|
require.Len(t, page.Sessions, 1, "sessions")
|
|
require.NotNil(t, page.Sessions[0].FirstMessage, "first message")
|
|
assert.Equal(t, "root b", *page.Sessions[0].FirstMessage)
|
|
}
|
|
|
|
func TestSyncRootsSinceScopesOpenCodeSQLiteRoots(t *testing.T) {
|
|
rootA := t.TempDir()
|
|
rootB := t.TempDir()
|
|
env := setupTestEnv(t, WithOpenCodeDirs([]string{rootA, rootB}))
|
|
|
|
ocA := createOpenCodeDB(t, rootA)
|
|
ocA.addProject(t, "proj-a", "/home/user/code/root-a")
|
|
ocA.addSession(t, "oc-root-a", "proj-a", 1704067200000, 1704067205000)
|
|
ocA.addMessage(t, "msg-a-user", "oc-root-a", "user", 1704067200000)
|
|
ocA.addTextPart(
|
|
t, "part-a-user", "oc-root-a", "msg-a-user", "root a",
|
|
1704067200000,
|
|
)
|
|
|
|
ocB := createOpenCodeDB(t, rootB)
|
|
ocB.addProject(t, "proj-b", "/home/user/code/root-b")
|
|
ocB.addSession(t, "oc-root-b", "proj-b", 1704067200000, 1704067205000)
|
|
ocB.addMessage(t, "msg-b-user", "oc-root-b", "user", 1704067200000)
|
|
ocB.addTextPart(
|
|
t, "part-b-user", "oc-root-b", "msg-b-user", "root b",
|
|
1704067200000,
|
|
)
|
|
|
|
stats := env.engine.SyncRootsSince(
|
|
context.Background(), []string{rootB}, time.Time{}, nil,
|
|
)
|
|
assert.Equal(t, 1, stats.TotalSessions, "total sessions")
|
|
assert.Equal(t, 1, stats.Synced, "synced sessions")
|
|
|
|
page, err := env.db.ListSessions(
|
|
context.Background(), db.SessionFilter{Limit: 10},
|
|
)
|
|
require.NoError(t, err, "list sessions")
|
|
require.Len(t, page.Sessions, 1, "sessions")
|
|
require.NotNil(t, page.Sessions[0].FirstMessage, "first message")
|
|
assert.Equal(t, "root b", *page.Sessions[0].FirstMessage)
|
|
}
|
|
|
|
func TestSyncRootsSinceDoesNotAdvanceGlobalWatermark(t *testing.T) {
|
|
rootA := t.TempDir()
|
|
rootB := t.TempDir()
|
|
env := setupTestEnv(t, WithClaudeDirs([]string{rootA, rootB}))
|
|
|
|
watermark := time.Now().Add(-24 * time.Hour).UTC()
|
|
require.NoError(t, env.db.SetSyncState(
|
|
"last_sync_started_at",
|
|
watermark.Format(time.RFC3339Nano),
|
|
), "seed last_sync_started_at")
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "root a").
|
|
String()
|
|
pathA := env.writeSession(
|
|
t, rootA, filepath.Join("proj-a", "root-a.jsonl"), content,
|
|
)
|
|
fileTime := watermark.Add(time.Hour)
|
|
require.NoError(t, os.Chtimes(pathA, fileTime, fileTime), "chtimes root a")
|
|
|
|
env.engine.SyncRootsSince(
|
|
context.Background(), []string{rootB}, watermark, nil,
|
|
)
|
|
|
|
stats := env.engine.SyncAllSince(
|
|
context.Background(), env.engine.LastSyncStartedAt(), nil,
|
|
)
|
|
assert.Equal(t, 1, stats.TotalSessions, "total sessions")
|
|
assert.Equal(t, 1, stats.Synced, "synced sessions")
|
|
|
|
page, err := env.db.ListSessions(
|
|
context.Background(), db.SessionFilter{Limit: 10},
|
|
)
|
|
require.NoError(t, err, "list sessions")
|
|
require.Len(t, page.Sessions, 1, "sessions")
|
|
require.NotNil(t, page.Sessions[0].FirstMessage, "first message")
|
|
assert.Equal(t, "root a", *page.Sessions[0].FirstMessage)
|
|
}
|
|
|
|
func TestSyncAll_PersistsStartedAndFinishedAt(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "hello").
|
|
String()
|
|
env.writeClaudeSession(
|
|
t, "proj", "sess.jsonl", content,
|
|
)
|
|
|
|
before := time.Now().UTC().Add(-1 * time.Second)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
after := time.Now().UTC().Add(1 * time.Second)
|
|
|
|
startedAt := env.engine.LastSyncStartedAt()
|
|
require.False(t, startedAt.IsZero(), "LastSyncStartedAt is zero after sync")
|
|
assert.False(t, startedAt.Before(before) || startedAt.After(after),
|
|
"LastSyncStartedAt %v outside [%v, %v]", startedAt, before, after)
|
|
|
|
finishedRaw, err := env.db.GetSyncState(
|
|
"last_sync_finished_at",
|
|
)
|
|
require.NoError(t, err, "get finish state")
|
|
require.NotEmpty(t, finishedRaw, "last_sync_finished_at not persisted")
|
|
}
|
|
|
|
func TestResyncAllPreservesPGPushMarkerID(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "hello").
|
|
AddClaudeAssistant(tsZeroS5, "hi").
|
|
String()
|
|
env.writeClaudeSession(t, "proj", "sess.jsonl", content)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
require.NoError(t, env.db.SetSyncState("pg_push_marker_id", "marker-123"),
|
|
"SetSyncState pg_push_marker_id")
|
|
|
|
stats := env.engine.ResyncAll(context.Background(), nil)
|
|
require.False(t, stats.Aborted, "ResyncAll aborted: %+v", stats)
|
|
|
|
got, err := env.db.GetSyncState("pg_push_marker_id")
|
|
require.NoError(t, err, "GetSyncState pg_push_marker_id after resync")
|
|
assert.Equal(t, "marker-123", got)
|
|
}
|
|
|
|
func TestOpenCodeExcludedSessionsAreSkipped(t *testing.T) {
|
|
|
|
env := setupSingleAgentTestEnv(t, parser.AgentOpenCode)
|
|
|
|
oc := createOpenCodeDB(t, env.opencodeDir)
|
|
oc.addProject(t, "proj1", "/tmp/proj1")
|
|
oc.addSession(t, "oc-excl-sync", "proj1", 1000, 1000)
|
|
oc.addMessage(t, "msg-sync", "oc-excl-sync", "user", 1000)
|
|
oc.addTextPart(
|
|
t, "part-sync", "oc-excl-sync", "msg-sync", "hi", 1000,
|
|
)
|
|
oc.addSession(t, "oc-excl-single", "proj1", 1000, 1000)
|
|
oc.addMessage(
|
|
t, "msg-single", "oc-excl-single", "user", 1000,
|
|
)
|
|
oc.addTextPart(
|
|
t, "part-single", "oc-excl-single", "msg-single",
|
|
"hello", 1000,
|
|
)
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
syncSessionID := "opencode:oc-excl-sync"
|
|
singleSessionID := "opencode:oc-excl-single"
|
|
assertSessionMessageCount(t, env.db, syncSessionID, 1)
|
|
assertSessionMessageCount(t, env.db, singleSessionID, 1)
|
|
|
|
// Permanently delete the session (marks it excluded).
|
|
require.NoError(t, env.db.DeleteSession(syncSessionID), "delete sync session")
|
|
require.NoError(t, env.db.DeleteSession(singleSessionID), "delete single session")
|
|
|
|
// Bump time_updated so the parser would normally pick them up.
|
|
oc.updateSessionTime(t, "oc-excl-sync", 2000)
|
|
oc.updateSessionTime(t, "oc-excl-single", 2000)
|
|
|
|
// Sync again: excluded sessions should not be counted as failures.
|
|
stats := env.engine.SyncAll(context.Background(), nil)
|
|
assert.Equal(t, 0, stats.Failed,
|
|
"excluded OpenCode session should not count as failure")
|
|
|
|
require.NoError(t, env.engine.SyncSingleSession(singleSessionID),
|
|
"SyncSingleSession on excluded OpenCode session returned error")
|
|
}
|
|
|
|
// TestSyncSingleSessionDeletedClaudeIsNoOp verifies that calling
|
|
// SyncSingleSession on permanently deleted or soft-deleted Claude sessions
|
|
// returns nil, not an error.
|
|
func TestSyncSingleSessionDeletedClaudeIsNoOp(t *testing.T) {
|
|
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "hello").
|
|
AddClaudeAssistant(tsZeroS5, "hi").
|
|
String()
|
|
|
|
env.writeClaudeSession(
|
|
t, "test-proj", "excl-single.jsonl", content,
|
|
)
|
|
env.writeClaudeSession(
|
|
t, "test-proj", "trashed-single.jsonl", content,
|
|
)
|
|
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
assertSessionMessageCount(t, env.db, "excl-single", 2)
|
|
assertSessionMessageCount(t, env.db, "trashed-single", 2)
|
|
|
|
// Permanently delete → marks it excluded.
|
|
err := env.db.DeleteSession(
|
|
"excl-single",
|
|
)
|
|
require.NoError(t, err, "DeleteSession")
|
|
require.NoError(t, env.db.SoftDeleteSession("trashed-single"), "SoftDeleteSession")
|
|
|
|
require.NoError(t, env.engine.SyncSingleSession("excl-single"),
|
|
"SyncSingleSession on excluded session returned error")
|
|
require.NoError(t, env.engine.SyncSingleSession("trashed-single"),
|
|
"SyncSingleSession on trashed session returned error")
|
|
}
|
|
|
|
func TestSyncAllTrashedSessionIsSkippedAndCached(t *testing.T) {
|
|
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "hello").
|
|
AddClaudeAssistant(tsZeroS5, "hi").
|
|
String()
|
|
|
|
path := env.writeClaudeSession(
|
|
t, "test-proj", "trashed-sync.jsonl", content,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
assertSessionMessageCount(t, env.db, "trashed-sync", 2)
|
|
|
|
require.NoError(t, env.db.SoftDeleteSession("trashed-sync"), "SoftDeleteSession")
|
|
require.NoError(t, env.db.ResetAllMtimes(), "ResetAllMtimes")
|
|
|
|
updated := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "hello again with a longer prompt").
|
|
AddClaudeAssistant(tsZeroS5, "still here with a longer reply").
|
|
String()
|
|
require.NoError(t, os.Remove(path), "Remove")
|
|
dbtest.WriteTestFile(t, path, []byte(updated))
|
|
future := time.Now().Add(2 * time.Second)
|
|
require.NoError(t, os.Chtimes(path, future, future), "Chtimes")
|
|
|
|
stats := env.engine.SyncAll(context.Background(), nil)
|
|
require.Equal(t, 0, stats.Failed, "Failed = %d, want 0 for trashed session", stats.Failed)
|
|
require.Equal(t, 0, stats.Synced, "Synced = %d, want 0 for trashed session", stats.Synced)
|
|
require.NotZero(t, env.engine.SnapshotSkipCache()[path], "skip cache missing trashed session path %s", path)
|
|
}
|
|
|
|
func TestSyncAllTrashedSessionAppendUsesSkipPath(t *testing.T) {
|
|
|
|
env := setupSingleAgentTestEnv(t, parser.AgentClaude)
|
|
|
|
content := testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsZero, "hello").
|
|
AddClaudeAssistant(tsZeroS5, "hi").
|
|
String()
|
|
|
|
path := env.writeClaudeSession(
|
|
t, "test-proj", "trashed-append.jsonl", content,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
assertSessionMessageCount(t, env.db, "trashed-append", 2)
|
|
|
|
require.NoError(t, env.db.SoftDeleteSession("trashed-append"), "SoftDeleteSession")
|
|
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err, "open append")
|
|
_, err = f.WriteString(
|
|
testjsonl.NewSessionBuilder().
|
|
AddClaudeUser(tsEarly, "new prompt").
|
|
AddClaudeAssistant(tsEarlyS5, "new reply").
|
|
String(),
|
|
)
|
|
require.NoError(t, f.Close(), "close append")
|
|
require.NoError(t, err, "append")
|
|
|
|
stats := env.engine.SyncAll(context.Background(), nil)
|
|
require.Equal(t, 0, stats.Failed, "Failed = %d, want 0 for trashed append", stats.Failed)
|
|
require.Equal(t, 0, stats.Synced, "Synced = %d, want 0 for trashed append", stats.Synced)
|
|
full, err := env.db.GetSessionFull(
|
|
context.Background(), "trashed-append",
|
|
)
|
|
require.NoError(t, err, "GetSessionFull")
|
|
require.NotNil(t, full, "MessageCount = nil, want preserved count 2")
|
|
require.Equal(t, 2, full.MessageCount, "MessageCount = %v, want preserved count 2", full)
|
|
}
|
|
|
|
func TestIncrementalSync_ClaudeClearOnlyRepairedOnAppend(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
// Initial sync: session opens with only a /clear command
|
|
// envelope. Under the new parser rule, first_message is
|
|
// empty even though UserMsgCount is 1.
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.ClaudeUserJSON(
|
|
"<command-name>/clear</command-name>",
|
|
tsZero,
|
|
),
|
|
)
|
|
path := env.writeClaudeSession(
|
|
t, "proj", "clear-only.jsonl", initial,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
full, err := env.db.GetSessionFull(
|
|
context.Background(), "clear-only",
|
|
)
|
|
require.NoError(t, err, "GetSessionFull after initial sync")
|
|
if full.FirstMessage != nil {
|
|
require.Equal(t, "", *full.FirstMessage, "initial FirstMessage = %q, want empty", *full.FirstMessage)
|
|
}
|
|
require.Equal(t, 1, full.UserMessageCount, "initial UserMessageCount = %d, want 1", full.UserMessageCount)
|
|
|
|
// Append a real user message — incremental sync must now
|
|
// fall back to a full parse so first_message gets populated.
|
|
appended := testjsonl.ClaudeUserJSON(
|
|
"Fix the login bug", tsZeroS1,
|
|
) + "\n"
|
|
f, err := os.OpenFile(
|
|
path, os.O_APPEND|os.O_WRONLY, 0o644,
|
|
)
|
|
require.NoError(t, err, "open for append")
|
|
_, err = f.WriteString(appended)
|
|
f.Close()
|
|
require.NoError(t, err, "append")
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
updated, err := env.db.GetSessionFull(
|
|
context.Background(), "clear-only",
|
|
)
|
|
require.NoError(t, err, "GetSessionFull after append")
|
|
require.NotNil(t, updated.FirstMessage,
|
|
"FirstMessage after append = nil, want %q", "Fix the login bug")
|
|
assert.Equal(t, "Fix the login bug", *updated.FirstMessage,
|
|
"FirstMessage after append = %q, want %q", *updated.FirstMessage, "Fix the login bug")
|
|
assert.Equal(t, 2, updated.UserMessageCount, "UserMessageCount after append = %d, want 2", updated.UserMessageCount)
|
|
}
|
|
|
|
// TestIncrementalSync_CodexReemittedPromptDedupedOnAppend covers
|
|
// the case where Codex re-emits the initial prompt verbatim on a
|
|
// continued turn. The duplicate is appended after the first sync,
|
|
// so the incremental parser starts past the original prompt and
|
|
// only sees the re-emitted copy. It must still recognise it as a
|
|
// duplicate via the stored first_message rather than counting it
|
|
// as a second user turn — otherwise an automated session (here a
|
|
// roborev code review) flips to UserMessageCount 2 and loses its
|
|
// is_automated classification, resurfacing in the viewer.
|
|
func TestIncrementalSync_CodexReemittedPromptDedupedOnAppend(t *testing.T) {
|
|
env := setupTestEnv(t)
|
|
|
|
uuid := "c3d4e5f6-3456-789a-bcde-f01234567890"
|
|
prompt := "You are a code reviewer. Review the code changes shown below."
|
|
|
|
initial := testjsonl.NewSessionBuilder().
|
|
AddCodexMeta(tsEarly, uuid, "/home/user/code/api", "user").
|
|
AddCodexMessage(tsEarlyS1, "user", prompt).
|
|
AddCodexMessage(tsEarlyS5, "assistant", "Looks good.").
|
|
String()
|
|
path := env.writeCodexSession(
|
|
t, filepath.Join("2024", "01", "15"),
|
|
"rollout-20240115-"+uuid+".jsonl", initial,
|
|
)
|
|
env.engine.SyncAll(context.Background(), nil)
|
|
|
|
sessionID := "codex:" + uuid
|
|
full, err := env.db.GetSessionFull(context.Background(), sessionID)
|
|
require.NoError(t, err, "GetSessionFull after initial sync")
|
|
require.Equal(t, 1, full.UserMessageCount,
|
|
"initial UserMessageCount = %d, want 1", full.UserMessageCount)
|
|
require.True(t, full.IsAutomated,
|
|
"initial IsAutomated = false, want true for reviewer prompt")
|
|
|
|
// Codex continues after an interrupted turn, re-emits the
|
|
// same prompt verbatim, then adds a fresh assistant reply.
|
|
appended := testjsonl.CodexMsgJSON(
|
|
"user", "<turn_aborted>\ninterrupted", "2024-01-01T10:00:59Z",
|
|
) + "\n" + testjsonl.CodexMsgJSON(
|
|
"user", prompt, "2024-01-01T10:01:00Z",
|
|
) + "\n" + testjsonl.CodexMsgJSON(
|
|
"assistant", "Still good.", "2024-01-01T10:01:05Z",
|
|
) + "\n"
|
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err, "open for append")
|
|
_, err = f.WriteString(appended)
|
|
f.Close()
|
|
require.NoError(t, err, "append")
|
|
|
|
env.engine.SyncPaths([]string{path})
|
|
|
|
updated, err := env.db.GetSessionFull(context.Background(), sessionID)
|
|
require.NoError(t, err, "GetSessionFull after append")
|
|
assert.Equal(t, 1, updated.UserMessageCount,
|
|
"UserMessageCount after re-emitted prompt = %d, want 1 (duplicate must be dropped)",
|
|
updated.UserMessageCount)
|
|
assert.Equal(t, 3, updated.MessageCount,
|
|
"MessageCount after append = %d, want 3 (only the assistant reply is new)",
|
|
updated.MessageCount)
|
|
assert.True(t, updated.IsAutomated,
|
|
"IsAutomated after append = false, want true (dedup must preserve classification)")
|
|
}
|
|
|
|
func testStringPtrValue(v *string) string {
|
|
if v == nil {
|
|
return ""
|
|
}
|
|
return *v
|
|
}
|