package sync_test // Integration tests for the report-only parse-diff mode. They are // written strictly against the exported contract in parsediff.go and // parsediff_report.go: assertions go through DiffClass buckets, the // Field* name constants, and ParseDiffTotals — never through rendered // strings, whose exact wording belongs to the renderer. import ( "context" "database/sql" "os" "path/filepath" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "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" ) // newParseDiffEngine builds a report-only diff engine over the same // database and agent directories the setupTestEnv harness used for // the initial SyncAll. func newParseDiffEngine(env *testEnv) *sync.Engine { return sync.NewDiffEngine(env.db, sync.EngineConfig{ AgentDirs: parseDiffAgentDirs(env), Machine: "local", }) } func parseDiffAgentDirs(env *testEnv) map[parser.AgentType][]string { dirs := map[parser.AgentType][]string{} add := func(agent parser.AgentType, dir string) { if dir != "" { dirs[agent] = []string{dir} } } add(parser.AgentClaude, env.claudeDir) add(parser.AgentCodex, env.codexDir) add(parser.AgentCursor, env.cursorDir) add(parser.AgentGemini, env.geminiDir) add(parser.AgentOpenCode, env.opencodeDir) add(parser.AgentForge, env.forgeDir) add(parser.AgentPiebald, env.piebaldDir) add(parser.AgentWarp, env.warpDir) add(parser.AgentIflow, env.iflowDir) add(parser.AgentAmp, env.ampDir) add(parser.AgentPi, env.piDir) add(parser.AgentOMP, env.ompDir) add(parser.AgentKiro, env.kiroDir) add(parser.AgentKilo, env.kiloDir) add(parser.AgentShelley, env.shelleyDir) add(parser.AgentWindsurf, env.windsurfDir) add(parser.AgentAntigravityCLI, env.antigravityCLIDir) return dirs } // runParseDiff runs ParseDiff with the given options and fails the // test on error or a nil report. func runParseDiff( t *testing.T, env *testEnv, opts sync.ParseDiffOptions, ) *sync.ParseDiffReport { t.Helper() report, err := newParseDiffEngine(env).ParseDiff( context.Background(), opts, ) require.NoError(t, err, "ParseDiff") require.NotNil(t, report, "ParseDiff report") return report } // findSessionDiff returns the listed SessionDiff for the given // session ID, or nil when the session is not listed. func findSessionDiff( report *sync.ParseDiffReport, sessionID string, ) *sync.SessionDiff { for i := range report.Sessions { if report.Sessions[i].SessionID == sessionID { return &report.Sessions[i] } } return nil } // sessionDiffFieldNames collects the Field names attached to one // session diff. Informational diffs are excluded unless // includeInformational is set. func sessionDiffFieldNames( sd *sync.SessionDiff, includeInformational bool, ) []string { var names []string for _, f := range sd.Fields { if f.Informational && !includeInformational { continue } names = append(names, f.Field) } return names } // mutateDB executes a single statement against the archive to // simulate stored-row drift. func mutateDB( t *testing.T, env *testEnv, query string, args ...any, ) { t.Helper() err := env.db.Update(func(tx *sql.Tx) error { _, err := tx.Exec(query, args...) return err }) require.NoError(t, err, "mutate db: %s", query) } // parseDiffClaudeContent builds a minimal two-message Claude session. func parseDiffClaudeContent(prompt, reply string) string { return testjsonl.NewSessionBuilder(). AddClaudeUser(tsEarly, prompt). AddClaudeAssistant(tsEarlyS5, reply). String() } // parseDiffClaudeContentRich builds a Claude session that exercises the // thinking, tool_use/tool_result, and system-message paths, so the // clean-archive acid test covers the message-flag and tool-call // comparisons against real parsed data rather than empty fingerprints. func parseDiffClaudeContentRich() string { return testjsonl.NewSessionBuilder(). AddClaudeUser(tsEarly, "run the build"). AddRaw(testjsonl.ClaudeAssistantJSON( []map[string]any{ {"type": "thinking", "thinking": "I should run make build"}, {"type": "text", "text": "Running the build now."}, { "type": "tool_use", "id": "tu-1", "name": "Bash", "input": map[string]any{"command": "make build"}, }, }, tsEarlyS1, )). AddRaw(testjsonl.ClaudeToolResultUserJSON( "tu-1", "build succeeded", tsEarlyS5, )). AddClaudeMetaUser(tsEarlyS5, "system notice", true, false). String() } // parseDiffCodexContent builds a minimal Codex rollout session with // the given session ID. func parseDiffCodexContent(id string) string { return testjsonl.NewSessionBuilder(). AddCodexMeta(tsEarly, id, "/home/user/code/api", "user"). AddCodexMessage(tsEarlyS1, "user", "Add tests"). AddCodexMessage(tsEarlyS5, "assistant", "Adding coverage."). String() } // parseDiffGeminiContent builds a minimal two-message Gemini session. func parseDiffGeminiContent(sessionID, hash string) string { return testjsonl.GeminiSessionJSON( sessionID, hash, tsEarly, tsEarlyS5, []map[string]any{ testjsonl.GeminiUserMsg("u1", tsEarly, "Explain this"), testjsonl.GeminiAssistantMsg( "a1", tsEarlyS5, "Here you go.", nil, ), }, ) } // TestParseDiffCleanArchiveIsIdentical is the false-diff acid test: // a freshly synced archive re-parsed by the same binary must come // back identical on every session, with no field counts and no // listed sessions. func TestParseDiffCleanArchiveIsIdentical(t *testing.T) { env := setupFocusedTestEnv(t, parser.AgentClaude, parser.AgentCodex) // pd-alpha carries thinking, a tool_use/tool_result pair, and a // system message so the run exercises the message-flag and tool-call // comparisons, not just the summary fields. env.writeClaudeSession(t, "test-proj", "pd-alpha.jsonl", parseDiffClaudeContentRich()) env.writeClaudeSession(t, "test-proj", "pd-beta.jsonl", parseDiffClaudeContent("beta prompt", "beta reply")) env.writeCodexSession( t, filepath.Join("2024", "01", "15"), "rollout-20240115-pd-codex.jsonl", parseDiffCodexContent("pd-codex"), ) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 3, Synced: 3, }) report := runParseDiff(t, env, sync.ParseDiffOptions{}) assert.Equal(t, db.CurrentDataVersion(), report.DataVersion, "report data version") assert.Equal(t, 3, report.FilesExamined, "files examined") assert.False(t, report.FilesLimited, "files limited") assert.Equal(t, sync.ParseDiffTotals{ Examined: 3, Identical: 3, }, report.Totals, "totals") assert.Empty(t, report.FieldCounts, "field counts") assert.Empty(t, report.Sessions, "identical sessions must not be listed") assert.False(t, report.HasFailures(), "HasFailures") } // TestParseDiffDetectsStoredDrift mutates stored rows directly after // a sync and verifies each drifted session is classified DiffChanged // with the expected field names while an untouched control session // stays identical. func TestParseDiffDetectsStoredDrift(t *testing.T) { env := setupSingleAgentTestEnv(t, parser.AgentClaude) ids := []string{ "pd-count", "pd-first", "pd-model", "pd-role", "pd-time", "pd-body", "pd-swap", "pd-term", "pd-usage", "pd-control", } for _, id := range ids { env.writeClaudeSession(t, "test-proj", id+".jsonl", parseDiffClaudeContent(id+" prompt", id+" reply")) } runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 10, Synced: 10, }) // Simulate drift between the stored rows and what the current // parser produces from the unchanged source files. mutateDB(t, env, "UPDATE sessions SET message_count = message_count + 5"+ " WHERE id = ?", "pd-count") mutateDB(t, env, "UPDATE sessions SET first_message = ? WHERE id = ?", "drifted first message", "pd-first") mutateDB(t, env, "UPDATE messages SET model = ? WHERE session_id = ?", "drifted-model", "pd-model") // Role-only and timestamp-only drift: neither moves the token or // content-length fingerprints, so the dedicated role/time // fingerprint is the only thing that can trigger the row-level // comparison. A regression here reports these sessions identical. mutateDB(t, env, "UPDATE messages SET role = 'assistant'"+ " WHERE session_id = ? AND ordinal = 0", "pd-role") mutateDB(t, env, "UPDATE messages SET timestamp = ?"+ " WHERE session_id = ? AND ordinal = 1", "2024-01-01T10:00:06Z", "pd-time") // Equal-length body rewrite: upper() changes the bytes without // moving content_length, so neither the length aggregates nor the // token fingerprint see it. Only the content hash can. mutateDB(t, env, "UPDATE messages SET content = upper(content)"+ " WHERE session_id = ? AND ordinal = 0", "pd-body") // Aggregate collision: swapping content and content_length between // the two ordinals permutes the lengths, so sum/max/min are // unchanged while every per-ordinal value differs. swapMsgs := fetchMessages(t, env.db, "pd-swap") require.Len(t, swapMsgs, 2, "pd-swap fixture") require.NotEqual(t, swapMsgs[0].ContentLength, swapMsgs[1].ContentLength, "swap needs distinct lengths or the collision test is vacuous") mutateDB(t, env, "UPDATE messages SET content = ?, content_length = ?"+ " WHERE session_id = ? AND ordinal = 0", swapMsgs[1].Content, swapMsgs[1].ContentLength, "pd-swap") mutateDB(t, env, "UPDATE messages SET content = ?, content_length = ?"+ " WHERE session_id = ? AND ordinal = 1", swapMsgs[0].Content, swapMsgs[0].ContentLength, "pd-swap") // The parser classifies this fixture's termination; store a // different non-null value so the diff is real drift, not the // informational cleared-to-NULL case. mutateDB(t, env, "UPDATE sessions SET termination_status = ? WHERE id = ?", "truncated", "pd-term") // The Claude parser emits no usage events for this fixture, so // a synthetic stored event is pure drift. require.NoError(t, env.db.ReplaceSessionUsageEvents( "pd-usage", []db.UsageEvent{{ SessionID: "pd-usage", Source: "synthetic", Model: "synthetic-model", }}, ), "insert synthetic usage event") report := runParseDiff(t, env, sync.ParseDiffOptions{}) assert.Equal(t, 10, report.FilesExamined, "files examined") assert.Equal(t, sync.ParseDiffTotals{ Examined: 10, Identical: 1, Changed: 9, }, report.Totals, "totals") cases := []struct { name string sessionID string field string // fieldCount is the expected FieldCounts entry; message_metadata // is shared by the role and timestamp drift sessions. fieldCount int // exact asserts the drifted field is the only // non-informational diff; otherwise presence suffices // (a synthetic usage event may also move totals). exact bool }{ {"message count drift", "pd-count", sync.FieldMessageCount, 1, true}, {"first message drift", "pd-first", sync.FieldFirstMessage, 1, true}, {"model drift", "pd-model", sync.FieldModels, 1, true}, {"role-only drift", "pd-role", sync.FieldMessageMetadata, 2, true}, {"timestamp-only drift", "pd-time", sync.FieldMessageMetadata, 2, true}, {"equal-length body drift", "pd-body", sync.FieldMessageContent, 2, true}, {"aggregate-collision length swap", "pd-swap", sync.FieldMessageContent, 2, true}, {"termination status drift", "pd-term", sync.FieldTerminationStatus, 1, true}, {"usage event drift", "pd-usage", sync.FieldUsageEventCount, 1, false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { sd := findSessionDiff(report, tc.sessionID) require.NotNil(t, sd, "session %q not listed", tc.sessionID) assert.Equal(t, sync.DiffChanged, sd.Class, "class for %q", tc.sessionID) got := sessionDiffFieldNames(sd, false) if tc.exact { assert.ElementsMatch(t, []string{tc.field}, got, "non-informational fields for %q", tc.sessionID) } else { assert.Contains(t, got, tc.field, "fields for %q", tc.sessionID) } assert.Equal(t, tc.fieldCount, report.FieldCounts[tc.field], "FieldCounts[%s]", tc.field) }) } if sd := findSessionDiff(report, "pd-control"); sd != nil { assert.Equal(t, sync.DiffIdentical, sd.Class, "control session class") assert.Empty(t, sessionDiffFieldNames(sd, false), "control session non-informational fields") } assert.True(t, report.HasFailures(), "HasFailures with drift") } // TestParseDiffToleratesNullStoredTimestamp guards the NULL-timestamp // regression. timestamp is the only nullable text column in messages, and // a single imported row with a NULL timestamp once made the tier-1 // role/time fingerprint scan fail, aborting the whole run instead of // producing a report. The run must complete and surface the now-empty // stored timestamp as ordinary message_metadata drift. func TestParseDiffToleratesNullStoredTimestamp(t *testing.T) { env := setupSingleAgentTestEnv(t, parser.AgentClaude) env.writeClaudeSession(t, "test-proj", "pd-nullts.jsonl", parseDiffClaudeContent("nullts prompt", "nullts reply")) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 1, Synced: 1, }) // Null the assistant message's stored timestamp. The source file is // unchanged, so the re-parse still yields a real timestamp there; // the stored NULL (coalesced to "") then differs from it as drift. mutateDB(t, env, "UPDATE messages SET timestamp = NULL"+ " WHERE session_id = ? AND ordinal = 1", "pd-nullts") // runParseDiff requires no error and a non-nil report: before the // COALESCE guard the NULL row aborted the run inside the fingerprint. report := runParseDiff(t, env, sync.ParseDiffOptions{}) assert.Equal(t, sync.ParseDiffTotals{ Examined: 1, Changed: 1, }, report.Totals, "totals") sd := findSessionDiff(report, "pd-nullts") require.NotNil(t, sd, "session not listed") assert.Equal(t, sync.DiffChanged, sd.Class, "class") assert.ElementsMatch(t, []string{sync.FieldMessageMetadata}, sessionDiffFieldNames(sd, false), "non-informational fields") assert.True(t, report.HasFailures(), "HasFailures") // messageMetadataDiff compares role before timestamp, so the field // family alone does not prove timestamp was the trigger. Pin the // detail to the timestamp column to match this test's intent. var metaDetail string for _, f := range sd.Fields { if f.Field == sync.FieldMessageMetadata { metaDetail = f.Detail } } assert.Contains(t, metaDetail, "timestamp", "message_metadata drift should attribute to the timestamp column") } // TestParseDiffDetectsExtendedFieldDrift covers the comparator surface // added beyond the summary fields end-to-end: tool_call drift, a // per-message flag, a full-replace-agent session-metadata field, and the // informational-for-incremental rule on a Claude session. func TestParseDiffDetectsExtendedFieldDrift(t *testing.T) { env := setupFocusedTestEnv(t, parser.AgentClaude, parser.AgentGemini) // Two rich Claude sessions (thinking + tool_use/result + system), // one minimal Claude session, and a full-replace Gemini session. env.writeClaudeSession(t, "test-proj", "pd-ext-tool.jsonl", parseDiffClaudeContentRich()) env.writeClaudeSession(t, "test-proj", "pd-ext-flag.jsonl", parseDiffClaudeContentRich()) env.writeClaudeSession(t, "test-proj", "pd-ext-cwd.jsonl", parseDiffClaudeContent("cwd prompt", "cwd reply")) env.writeGeminiSession(t, filepath.Join("tmp", "exthash", "chats", "session-001.json"), parseDiffGeminiContent("pd-ext-branch", "exthash")) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 4, Synced: 4, }) // Tool-call drift: rename a stored tool call. None of the message // token/role/content/flag fingerprints move, so this is caught only // via the tool-call fingerprint. mutateDB(t, env, "UPDATE tool_calls SET tool_name = ? WHERE session_id = ?", "DRIFTED", "pd-ext-tool") // Flag drift: flip has_thinking on the assistant message (the one // carrying the tool use). Only the flags fingerprint moves. mutateDB(t, env, "UPDATE messages SET has_thinking = NOT has_thinking"+ " WHERE session_id = ? AND has_tool_use = 1", "pd-ext-flag") // Incremental-append session field on a Claude session: a real // difference, but classified informational, so the session stays // identical rather than changed. mutateDB(t, env, "UPDATE sessions SET cwd = ? WHERE id = ?", "/drifted/path", "pd-ext-cwd") // Full-replace-agent session metadata: Gemini does not take the // incremental path, so a git_branch difference is real drift. mutateDB(t, env, "UPDATE sessions SET git_branch = ? WHERE id = ?", "feature-x", "gemini:pd-ext-branch") report := runParseDiff(t, env, sync.ParseDiffOptions{}) assert.Equal(t, sync.ParseDiffTotals{ Examined: 4, Identical: 1, Changed: 3, InformationalOnly: 1, }, report.Totals, "totals") cases := []struct { name string sessionID string field string }{ {"tool call drift", "pd-ext-tool", sync.FieldToolCalls}, {"flag drift", "pd-ext-flag", sync.FieldMessageMetadata}, {"git_branch drift", "gemini:pd-ext-branch", sync.FieldGitBranch}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { sd := findSessionDiff(report, tc.sessionID) require.NotNil(t, sd, "session %q not listed", tc.sessionID) assert.Equal(t, sync.DiffChanged, sd.Class, "class for %q", tc.sessionID) assert.ElementsMatch(t, []string{tc.field}, sessionDiffFieldNames(sd, false), "non-informational fields for %q", tc.sessionID) assert.Equal(t, 1, report.FieldCounts[tc.field], "FieldCounts[%s]", tc.field) }) } t.Run("incremental cwd drift is informational", func(t *testing.T) { sd := findSessionDiff(report, "pd-ext-cwd") require.NotNil(t, sd, "pd-ext-cwd not listed") assert.Equal(t, sync.DiffIdentical, sd.Class, "informational-only session stays identical") assert.Empty(t, sessionDiffFieldNames(sd, false), "no non-informational fields") assert.Contains(t, sessionDiffFieldNames(sd, true), sync.FieldCwd, "informational cwd diff must be attached") assert.Zero(t, report.FieldCounts[sync.FieldCwd], "informational diffs are excluded from FieldCounts") }) assert.True(t, report.HasFailures(), "HasFailures with drift") } // TestParseDiffWritesNothing verifies the report-only promise: the // stored drift is detected but not repaired, and nothing is persisted // (no skip cache entries, no row rewrites). func TestParseDiffWritesNothing(t *testing.T) { env := setupFocusedTestEnv(t, parser.AgentClaude, parser.AgentGemini) env.writeClaudeSession(t, "test-proj", "pd-keep.jsonl", parseDiffClaudeContent("keep prompt", "keep reply")) geminiPath := env.writeGeminiSession( t, filepath.Join("tmp", "pdhash", "chats", "session-001.json"), parseDiffGeminiContent("pd-err", "pdhash"), ) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 2, Synced: 2, }) // Corrupt the Gemini source so the diff run exercises the parse // error path, which a writing sync would record in skipped_files. dbtest.WriteTestFile(t, geminiPath, []byte("{corrupt")) mutateDB(t, env, "UPDATE sessions SET first_message = ? WHERE id = ?", "drifted first message", "pd-keep") report := runParseDiff(t, env, sync.ParseDiffOptions{}) assert.Equal(t, sync.ParseDiffTotals{ Examined: 1, Changed: 1, ParseErrors: 1, }, report.Totals, "totals") // The drift must still be there: ParseDiff reports, never fixes. sess, err := env.db.GetSessionFull( context.Background(), "pd-keep", ) require.NoError(t, err, "GetSessionFull") require.NotNil(t, sess, "session pd-keep not found") require.NotNil(t, sess.FirstMessage, "first_message is NULL") assert.Equal(t, "drifted first message", *sess.FirstMessage, "ParseDiff must not repair stored drift") // The corrupt session's archived rows are untouched. assertSessionMessageCount(t, env.db, "gemini:pd-err", 2) // Nothing was persisted: the parse error did not land in the // skip cache table. skipped, err := env.db.LoadSkippedFiles() require.NoError(t, err, "LoadSkippedFiles") assert.Empty(t, skipped, "skipped_files must stay empty") } // TestParseDiffBypassesSkipLayers proves ParseDiff re-parses every // file even when the sync engine's size/mtime/skip-cache layers // would skip it, by appending to a source file without re-syncing. func TestParseDiffBypassesSkipLayers(t *testing.T) { env := setupSingleAgentTestEnv(t, parser.AgentClaude) path := env.writeClaudeSession(t, "test-proj", "pd-skip.jsonl", parseDiffClaudeContent("skip prompt", "skip reply")) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 1, Synced: 1, }) // Second pass proves the skip layers are armed. runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 1, Synced: 0, Skipped: 1, }) report := runParseDiff(t, env, sync.ParseDiffOptions{}) assert.Equal(t, 1, report.FilesExamined, "skip layers must not hide files from ParseDiff") assert.Positive(t, report.Totals.Examined, "examined sessions") assert.Equal(t, sync.ParseDiffTotals{ Examined: 1, Identical: 1, }, report.Totals, "totals before append") // Capture the original mtime so the append can be replayed without // tripping the live-write skew guard: appending advances the file // mtime past the stored snapshot, which would (correctly) classify // the diff as raced. Restoring the mtime keeps this test focused on // the skip-layer bypass and the message_count CHANGE detection; the // raced path has dedicated coverage in TestParseDiffRacedSourceSkew. origInfo, err := os.Stat(path) require.NoError(t, err, "stat source before append") // Append one more message without syncing. The incremental // append path would normally absorb this; a full re-parse must // surface it as a message count change instead. f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) require.NoError(t, err, "open session file for append") _, err = f.WriteString(testjsonl.ClaudeAssistantJSON( []map[string]any{{"type": "text", "text": "appended reply"}}, "2024-01-01T10:00:10Z", ) + "\n") require.NoError(t, err, "append message line") require.NoError(t, f.Close(), "close session file") require.NoError(t, os.Chtimes(path, origInfo.ModTime(), origInfo.ModTime()), "restore source mtime so the change is not classified raced") report = runParseDiff(t, env, sync.ParseDiffOptions{}) assert.Equal(t, 1, report.FilesExamined, "files examined") assert.Equal(t, 1, report.Totals.Changed, "changed sessions") sd := findSessionDiff(report, "pd-skip") require.NotNil(t, sd, "session pd-skip not listed") assert.Equal(t, sync.DiffChanged, sd.Class, "class") assert.Contains(t, sessionDiffFieldNames(sd, false), sync.FieldMessageCount, "appended message must surface as a message_count diff") assert.Equal(t, 1, report.FieldCounts[sync.FieldMessageCount], "FieldCounts[message_count]") } // TestParseDiffBuckets covers the non-compared classification // buckets: skipped (source missing), new on disk, pending resync, // and parse error. func TestParseDiffBuckets(t *testing.T) { t.Run("source missing", func(t *testing.T) { env := setupSingleAgentTestEnv(t, parser.AgentClaude) path := env.writeClaudeSession( t, "test-proj", "pd-gone.jsonl", parseDiffClaudeContent("gone prompt", "gone reply"), ) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 1, Synced: 1, }) require.NoError(t, os.Remove(path), "remove source file") report := runParseDiff(t, env, sync.ParseDiffOptions{}) assert.Equal(t, 0, report.FilesExamined, "files examined") assert.Equal(t, sync.ParseDiffTotals{ Skipped: 1, }, report.Totals, "totals") sd := findSessionDiff(report, "pd-gone") require.NotNil(t, sd, "session pd-gone not listed") assert.Equal(t, sync.DiffSkipped, sd.Class, "class") assert.NotEmpty(t, sd.Reason, "skip reason") }) t.Run("new on disk", func(t *testing.T) { env := setupSingleAgentTestEnv(t, parser.AgentClaude) env.writeClaudeSession(t, "test-proj", "pd-base.jsonl", parseDiffClaudeContent("base prompt", "base reply")) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 1, Synced: 1, }) // Written after the sync: the archive is behind the disk. env.writeClaudeSession(t, "test-proj", "pd-new.jsonl", parseDiffClaudeContent("new prompt", "new reply")) report := runParseDiff(t, env, sync.ParseDiffOptions{}) assert.Equal(t, 2, report.FilesExamined, "files examined") assert.Equal(t, sync.ParseDiffTotals{ Examined: 1, Identical: 1, NewOnDisk: 1, }, report.Totals, "totals") sd := findSessionDiff(report, "pd-new") require.NotNil(t, sd, "session pd-new not listed") assert.Equal(t, sync.DiffNewOnDisk, sd.Class, "class") }) t.Run("pending resync", func(t *testing.T) { env := setupSingleAgentTestEnv(t, parser.AgentClaude) env.writeClaudeSession(t, "test-proj", "pd-stale.jsonl", parseDiffClaudeContent("stale prompt", "stale reply")) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 1, Synced: 1, }) staleVersion := db.CurrentDataVersion() - 1 require.NoError(t, env.db.SetSessionDataVersion("pd-stale", staleVersion), "downgrade data_version") // A real field diff that must NOT count as parser drift. mutateDB(t, env, "UPDATE sessions SET first_message = ? WHERE id = ?", "drifted first message", "pd-stale") report := runParseDiff(t, env, sync.ParseDiffOptions{}) assert.Equal(t, sync.ParseDiffTotals{ Examined: 1, PendingResync: 1, }, report.Totals, "totals") assert.Empty(t, report.FieldCounts, "pending_resync diffs must not be counted") sd := findSessionDiff(report, "pd-stale") require.NotNil(t, sd, "session pd-stale not listed") assert.Equal(t, sync.DiffPendingResync, sd.Class, "class") assert.Equal(t, staleVersion, sd.StoredDataVersion, "stored data version") // Field diffs are still attached for drill-down. assert.Contains(t, sessionDiffFieldNames(sd, true), sync.FieldFirstMessage, "pending_resync field diffs attached for drill-down") assert.False(t, report.HasFailures(), "pending_resync must not trip HasFailures") }) t.Run("parse error", func(t *testing.T) { env := setupFocusedTestEnv(t, parser.AgentClaude, parser.AgentGemini) env.writeClaudeSession(t, "test-proj", "pd-ok.jsonl", parseDiffClaudeContent("ok prompt", "ok reply")) geminiPath := env.writeGeminiSession( t, filepath.Join( "tmp", "badhash", "chats", "session-001.json", ), parseDiffGeminiContent("pd-bad", "badhash"), ) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 2, Synced: 2, }) dbtest.WriteTestFile(t, geminiPath, []byte("{corrupt")) // The corrupt file must not abort the run. report := runParseDiff(t, env, sync.ParseDiffOptions{}) assert.Equal(t, sync.ParseDiffTotals{ Examined: 1, Identical: 1, ParseErrors: 1, }, report.Totals, "totals") sd := findSessionDiff(report, "gemini:pd-bad") require.NotNil(t, sd, "session gemini:pd-bad not listed") assert.Equal(t, sync.DiffParseError, sd.Class, "class") assert.True(t, report.HasFailures(), "parse errors must trip HasFailures") }) } // TestParseDiffLimitNewestFirst verifies Limit samples files newest // mtime first and reports the unexamined sessions as skipped. func TestParseDiffLimitNewestFirst(t *testing.T) { env := setupSingleAgentTestEnv(t, parser.AgentClaude) base := time.Now() files := []struct { id string mtime time.Time }{ {"pd-oldest", base.Add(-2 * time.Hour)}, {"pd-middle", base.Add(-1 * time.Hour)}, {"pd-newest", base}, } for _, f := range files { path := env.writeClaudeSession( t, "test-proj", f.id+".jsonl", parseDiffClaudeContent(f.id+" prompt", f.id+" reply"), ) require.NoError(t, os.Chtimes(path, f.mtime, f.mtime), "chtimes %s", path) } runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 3, Synced: 3, }) report := runParseDiff(t, env, sync.ParseDiffOptions{Limit: 1}) assert.Equal(t, 1, report.FilesExamined, "files examined") assert.True(t, report.FilesLimited, "files limited") assert.Equal(t, sync.ParseDiffTotals{ Examined: 1, Identical: 1, Skipped: 2, }, report.Totals, "totals") for _, id := range []string{"pd-oldest", "pd-middle"} { sd := findSessionDiff(report, id) require.NotNil(t, sd, "session %q not listed", id) assert.Equal(t, sync.DiffSkipped, sd.Class, "class for %q", id) assert.NotEmpty(t, sd.Reason, "skip reason for %q", id) } // The newest file was the one examined; it round-trips clean so // it is either unlisted or listed as identical. if sd := findSessionDiff(report, "pd-newest"); sd != nil { assert.Equal(t, sync.DiffIdentical, sd.Class, "newest session class") } } // TestParseDiffAgentScope verifies Agents restricts the run to the // requested agents and that agents without an on-disk source to // re-parse are rejected. func TestParseDiffAgentScope(t *testing.T) { env := setupFocusedTestEnv(t, parser.AgentClaude, parser.AgentCodex) env.writeClaudeSession(t, "test-proj", "pd-claude.jsonl", parseDiffClaudeContent("claude prompt", "claude reply")) env.writeCodexSession( t, filepath.Join("2024", "01", "15"), "rollout-20240115-pd-codex.jsonl", parseDiffCodexContent("pd-codex"), ) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 2, Synced: 2, }) engine := newParseDiffEngine(env) report, err := engine.ParseDiff( context.Background(), sync.ParseDiffOptions{ Agents: []parser.AgentType{parser.AgentCodex}, }, ) require.NoError(t, err, "ParseDiff scoped to codex") require.NotNil(t, report, "ParseDiff report") assert.Equal(t, 1, report.FilesExamined, "claude files must not be counted in a codex-scoped run") assert.Equal(t, sync.ParseDiffTotals{ Examined: 1, Identical: 1, }, report.Totals, "totals") for _, s := range report.Sessions { assert.NotEqual(t, "claude", s.Agent, "claude session listed in codex-scoped run: %+v", s) assert.NotEqual(t, "pd-claude", s.SessionID, "claude session listed in codex-scoped run: %+v", s) } assert.Equal(t, []string{"codex"}, report.Agents, "report.Agents must reflect the scoped run") // Import-only agents are outside parse-diff support. _, err = engine.ParseDiff( context.Background(), sync.ParseDiffOptions{ Agents: []parser.AgentType{parser.AgentClaudeAI}, }, ) require.Error(t, err, "ParseDiff must reject import-only agents") assert.ErrorContains(t, err, "is not supported by parse-diff") } func TestParseDiffCoversProviderAuthoritativePiFamily(t *testing.T) { env := setupFocusedTestEnv(t, parser.AgentPi, parser.AgentOMP) env.writeSession( t, env.piDir, filepath.Join("encoded-cwd", "pd-pi.jsonl"), piLikeProviderFixture("pd-pi", "/Users/alice/code/pi-app"), ) env.writeSession( t, env.ompDir, filepath.Join("encoded-cwd", "pd-omp.jsonl"), piLikeProviderFixture("pd-omp", "/Users/alice/code/omp-app"), ) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 2, Synced: 2, }) report := runParseDiff(t, env, sync.ParseDiffOptions{ Agents: []parser.AgentType{parser.AgentPi, parser.AgentOMP}, }) assert.Equal(t, []string{"pi", "omp"}, report.Agents) assert.Equal(t, 2, report.FilesExamined) assert.Equal(t, sync.ParseDiffTotals{ Examined: 2, Identical: 2, }, report.Totals) } // TestParseDiffCoversKiroSQLite proves that Kiro's shared data.sqlite3 // store, which the provider discovers and fans out to one session per // row, is actually re-parsed by parse-diff. A // regressed force-parse guard or missing synthesized discovery would // surface here as the session being skipped/"not discovered" with // Examined 0 rather than compared. func TestParseDiffCoversKiroSQLite(t *testing.T) { env := setupSingleAgentTestEnv(t, parser.AgentKiro) ks := createKiroSQLiteDB(t, env.kiroDir) ks.addSession( t, "/home/user/code/kiro-app", "sqlite-session", readKiroSQLiteFixture(t, "standard_payload.json"), 1779012000000, 1779012030000, ) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 1, Synced: 1, }) report := runParseDiff(t, env, sync.ParseDiffOptions{ Agents: []parser.AgentType{parser.AgentKiro}, }) // Examined:1/Identical:1 proves the data.sqlite3 session was // re-parsed and compared (not bucketed skipped/"not discovered"). // Identical sessions are intentionally not listed, so a Skipped or // NewOnDisk count here would mean the synthesized discovery or the // force-parse guard regressed. assert.Equal(t, sync.ParseDiffTotals{ Examined: 1, Identical: 1, }, report.Totals, "kiro sqlite session must be examined, not skipped") assert.Equal(t, 1, report.FilesExamined, "data.sqlite3 examined") assert.False(t, report.HasFailures(), "clean kiro sqlite run") } // TestParseDiffCoversMixedOpenCodeRoot proves a storage-mode OpenCode // root that still carries DB-only legacy sessions in opencode.db gets // BOTH sources re-parsed. Normal sync reads opencode.db regardless of // source mode, so parse-diff must too; a mode-gated synthesized // discovery would leave the legacy session "not discovered" and let // --fail-on-change pass without vetting it. func TestParseDiffCoversMixedOpenCodeRoot(t *testing.T) { env := setupSingleAgentTestEnv(t, parser.AgentOpenCode) // File-backed storage session: this makes ResolveOpenCodeSource // pick storage mode for the root. storage := createOpenCodeStorageFixture(t, env.opencodeDir) const storageID = "oc-mixed-storage" storage.addSession( t, "global", storageID, "/home/user/code/storage-app", "Mixed Storage", 1704067200000, 1704067205000, ) storage.addMessage( t, storageID, "msg-a1", "assistant", 1704067201000, nil, ) storage.addTextPart( t, storageID, "msg-a1", "part-a1", "storage reply", 1704067201000, ) // DB-only legacy session in the same root, plus a SQLite duplicate // of the storage session that the storage-ID filter must drop. sqlite := createOpenCodeDB(t, env.opencodeDir) sqlite.addProject(t, "proj-1", "/home/user/code/legacy-app") const legacyID = "oc-mixed-legacy" timeCreated := int64(1704067200000) sqlite.addSession(t, legacyID, "proj-1", timeCreated, timeCreated+5000) sqlite.addMessage(t, "lg-msg-u1", legacyID, "user", timeCreated) sqlite.addMessage(t, "lg-msg-a1", legacyID, "assistant", timeCreated+1) sqlite.addTextPart( t, "lg-part-u1", legacyID, "lg-msg-u1", "legacy question", timeCreated, ) sqlite.addTextPart( t, "lg-part-a1", legacyID, "lg-msg-a1", "legacy answer", timeCreated+1, ) sqlite.addSession(t, storageID, "proj-1", timeCreated, timeCreated+5000) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 2, Synced: 2, }) report := runParseDiff(t, env, sync.ParseDiffOptions{ Agents: []parser.AgentType{parser.AgentOpenCode}, }) // Examined:2/Identical:2 proves the DB-only legacy session was // re-parsed and compared alongside the storage session. A Skipped // count here means opencode.db was not synthesized for the // storage-mode root; a Changed count means the storage-ID filter // let the SQLite duplicate shadow the storage transcript. assert.Equal(t, sync.ParseDiffTotals{ Examined: 2, Identical: 2, }, report.Totals, "both opencode sources must be examined") assert.Equal(t, 2, report.FilesExamined, "storage session file and opencode.db examined") assert.False(t, report.HasFailures(), "clean mixed opencode run") } // TestParseDiffCoversMixedKiloRoot is the Kilo mirror of the OpenCode // case above. Kilo reuses OpenCode's hybrid storage, so a storage-mode // root that still carries DB-only sessions in kilo.db must have BOTH // sources re-parsed. func TestParseDiffCoversMixedKiloRoot(t *testing.T) { env := setupSingleAgentTestEnv(t, parser.AgentKilo) // File-backed storage session: this makes ResolveKiloSource pick storage // mode for the root. storage := createOpenCodeStorageFixture(t, env.kiloDir) const storageID = "kilo-mixed-storage" storage.addSession( t, "global", storageID, "/home/user/code/storage-app", "Mixed Storage", 1704067200000, 1704067205000, ) storage.addMessage( t, storageID, "msg-a1", "assistant", 1704067201000, nil, ) storage.addTextPart( t, storageID, "msg-a1", "part-a1", "storage reply", 1704067201000, ) // DB-only legacy session in the same root, plus a SQLite duplicate // of the storage session that the storage-ID filter must drop. sqlite := createKiloDB(t, env.kiloDir) sqlite.addProject(t, "proj-1", "/home/user/code/legacy-app") const legacyID = "kilo-mixed-legacy" timeCreated := int64(1704067200000) sqlite.addSession(t, legacyID, "proj-1", timeCreated, timeCreated+5000) sqlite.addMessage(t, "lg-msg-u1", legacyID, "user", timeCreated) sqlite.addMessage(t, "lg-msg-a1", legacyID, "assistant", timeCreated+1) sqlite.addTextPart( t, "lg-part-u1", legacyID, "lg-msg-u1", "legacy question", timeCreated, ) sqlite.addTextPart( t, "lg-part-a1", legacyID, "lg-msg-a1", "legacy answer", timeCreated+1, ) sqlite.addSession(t, storageID, "proj-1", timeCreated, timeCreated+5000) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 2, Synced: 2, }) report := runParseDiff(t, env, sync.ParseDiffOptions{ Agents: []parser.AgentType{parser.AgentKilo}, }) // Examined:2/Identical:2 proves the DB-only legacy session was // re-parsed and compared alongside the storage session. assert.Equal(t, sync.ParseDiffTotals{ Examined: 2, Identical: 2, }, report.Totals, "both kilo sources must be examined") assert.Equal(t, 2, report.FilesExamined, "storage session file and kilo.db examined") assert.False(t, report.HasFailures(), "clean mixed kilo run") } // TestParseDiffCoversShelley proves Shelley's shared shelley.db — which // the provider discovers as a single source and which normal sync fans // out to one session per conversation — is re-parsed and compared by parse-diff. // Examined:1/Identical:1 means the stored conversation was matched and // vetted, not bucketed as skipped/"not discovered". func TestParseDiffCoversShelley(t *testing.T) { env := setupSingleAgentTestEnv(t, parser.AgentShelley) dbPath := createShelleyDB(t, env.shelleyDir) seedShelleyConvo(t, dbPath, "cMAIN1", "main", "/home/u/dev/app", "claude-sonnet-4-6", "", true, "2026-06-15T10:00:00Z", "2026-06-15T10:00:06Z", mainConvoMsgs()) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 1, Synced: 1, }) report := runParseDiff(t, env, sync.ParseDiffOptions{ Agents: []parser.AgentType{parser.AgentShelley}, }) assert.Equal(t, sync.ParseDiffTotals{ Examined: 1, Identical: 1, }, report.Totals, "shelley conversation must be examined, not skipped") assert.Equal(t, 1, report.FilesExamined, "shelley.db examined") assert.False(t, report.HasFailures(), "clean shelley run") } // TestParseDiffShelleyDBErrorAttributed proves that when the whole // shelley.db can no longer be read, the stored conversations under it // are reported as DiffParseError attributed to their session IDs, not // misclassified as source-missing in the final sweep. This exercises // stripVirtualSourceSuffix mapping shelley.db#id back to shelley.db so // the parse error keyed by the real DB path matches the stored rows. func TestParseDiffShelleyDBErrorAttributed(t *testing.T) { env := setupSingleAgentTestEnv(t, parser.AgentShelley) dbPath := createShelleyDB(t, env.shelleyDir) seedShelleyConvo(t, dbPath, "cMAIN1", "main", "/home/u/dev/app", "claude-sonnet-4-6", "", true, "2026-06-15T10:00:00Z", "2026-06-15T10:00:06Z", mainConvoMsgs()) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 1, Synced: 1, }) // Break the DB so the conversation meta query fails: the file is // still a regular shelley.db (so it is discovered), but reading it // errors, forcing the whole-file job error path. conn, err := sql.Open("sqlite3", dbPath) require.NoError(t, err) _, err = conn.Exec(`DROP TABLE messages`) require.NoError(t, err) require.NoError(t, conn.Close()) report := runParseDiff(t, env, sync.ParseDiffOptions{ Agents: []parser.AgentType{parser.AgentShelley}, }) assert.Equal(t, sync.ParseDiffTotals{ParseErrors: 1}, report.Totals, "unreadable shelley.db is a parse error for the stored conversation") sd := findSessionDiff(report, "shelley:cMAIN1") require.NotNil(t, sd, "stored conversation attributed by session ID") assert.Equal(t, sync.DiffParseError, sd.Class, "class") assert.True(t, report.HasFailures(), "a DB read failure must trip --fail-on-change") } // TestParseDiffKiroSQLitePerSessionError proves a malformed session // inside the shared Kiro store surfaces as DiffParseError instead of // being silently dropped (unstored) or misclassified as presence // drift (stored), so --fail-on-change stays trustworthy. func TestParseDiffKiroSQLitePerSessionError(t *testing.T) { env := setupSingleAgentTestEnv(t, parser.AgentKiro) ks := createKiroSQLiteDB(t, env.kiroDir) standard := readKiroSQLiteFixture(t, "standard_payload.json") ks.addSession( t, "/home/user/code/kiro-app", "sqlite-session", standard, 1779012000000, 1779012030000, ) ks.addSession( t, "/home/user/code/kiro-app", "good-session", standard, 1779012100000, 1779012130000, ) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 2, Synced: 2, }) ks.updateSession(t, "sqlite-session", "{corrupt", 1779012060000) // Never synced: written to the store after the sync. ks.addSession( t, "/home/user/code/kiro-app", "bad-session", "{corrupt", 1779012040000, 1779012050000, ) report := runParseDiff(t, env, sync.ParseDiffOptions{ Agents: []parser.AgentType{parser.AgentKiro}, }) assert.Equal(t, sync.ParseDiffTotals{ Examined: 1, Identical: 1, ParseErrors: 2, }, report.Totals, "good session compared; both malformed sessions are parse errors") assert.Empty(t, report.FieldCounts, "no presence diff for sessions that failed to parse") stored := findSessionDiff(report, "kiro:sqlite-session") require.NotNil(t, stored, "stored session must be attributed by ID") assert.Equal(t, sync.DiffParseError, stored.Class, "stored class") assert.Contains(t, stored.Reason, "malformed payload", "stored reason") var unstored *sync.SessionDiff for i := range report.Sessions { if report.Sessions[i].Class == sync.DiffParseError && strings.Contains(report.Sessions[i].FilePath, "bad-session") { unstored = &report.Sessions[i] } } require.NotNil(t, unstored, "unstored parse error entry listed") assert.Contains(t, unstored.FilePath, "data.sqlite3#bad-session", "error attributed to the per-session virtual path") assert.True(t, report.HasFailures(), "per-session parse errors must trip --fail-on-change") } // TestParseDiffKiloSQLitePerSessionError proves a malformed session // inside the shared Kilo store surfaces as DiffParseError instead of // being silently dropped, so --fail-on-change stays trustworthy even // for a session that was never stored. func TestParseDiffKiloSQLitePerSessionError(t *testing.T) { env := setupSingleAgentTestEnv(t, parser.AgentKilo) ks := createKiloDB(t, env.kiloDir) ks.addProject(t, "proj-1", "/home/user/code/kilo-app") const goodID = "good-session" good := int64(1779012000000) ks.addSession(t, goodID, "proj-1", good, good+30000) ks.addMessage(t, "g-msg-u1", goodID, "user", good) ks.addMessage(t, "g-msg-a1", goodID, "assistant", good+1) ks.addTextPart(t, "g-part-u1", goodID, "g-msg-u1", "good question", good) ks.addTextPart(t, "g-part-a1", goodID, "g-msg-a1", "good answer", good+1) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 1, Synced: 1, }) // Never synced: a session row whose non-integer time_created is // listed for fan-out but fails the per-session parse afterward. const badID = "bad-session" ks.mustExec(t, "insert malformed session", `INSERT INTO session (id, project_id, time_created, time_updated) VALUES (?, ?, ?, ?)`, badID, "proj-1", "not-a-number", good+40000, ) report := runParseDiff(t, env, sync.ParseDiffOptions{ Agents: []parser.AgentType{parser.AgentKilo}, }) assert.Equal(t, sync.ParseDiffTotals{ Examined: 1, Identical: 1, ParseErrors: 1, }, report.Totals, "good session compared; malformed session is a parse error") var errEntry *sync.SessionDiff for i := range report.Sessions { if report.Sessions[i].Class == sync.DiffParseError { errEntry = &report.Sessions[i] } } require.NotNil(t, errEntry, "parse error entry listed") assert.Contains(t, errEntry.FilePath, "kilo.db#bad-session", "error attributed to the per-session virtual path") assert.True(t, report.HasFailures(), "HasFailures") } // TestParseDiffRacedSourceSkew is the end-to-end live-write skew guard: // a stored row that drifted from its source is normally DiffChanged, but // when the on-disk source advances past the snapshot file_mtime mid-run // (simulating a daemon or active session rewriting the file after the // snapshot) the change is inconclusive and must be reclassified DiffRaced // without tripping --fail-on-change. A control session whose source was // not touched stays a real DiffChanged so a genuine regression is never // masked. func TestParseDiffRacedSourceSkew(t *testing.T) { env := setupSingleAgentTestEnv(t, parser.AgentClaude) racedPath := env.writeClaudeSession(t, "test-proj", "pd-raced.jsonl", parseDiffClaudeContent("raced prompt", "raced reply")) env.writeClaudeSession(t, "test-proj", "pd-control.jsonl", parseDiffClaudeContent("control prompt", "control reply")) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 2, Synced: 2, }) // Drift the stored rows so a re-parse of the unchanged source content // would report a real first_message change for both. mutateDB(t, env, "UPDATE sessions SET first_message = ? WHERE id = ?", "drifted first message", "pd-raced") mutateDB(t, env, "UPDATE sessions SET first_message = ? WHERE id = ?", "drifted first message", "pd-control") // Simulate a mid-run write to pd-raced's source only: push its mtime // well past the stored snapshot file_mtime. The content is unchanged, // so the comparison still detects the seeded drift, but the advanced // mtime marks the comparison as a torn read. pd-control is left // untouched, so its drift stays a genuine change. future := time.Now().Add(48 * time.Hour) require.NoError(t, os.Chtimes(racedPath, future, future), "advance raced source mtime") report := runParseDiff(t, env, sync.ParseDiffOptions{}) assert.Equal(t, sync.ParseDiffTotals{ Examined: 2, Changed: 1, Raced: 1, }, report.Totals, "totals") // Only the untouched-source change is counted as drift; the raced // session's masked field diff is excluded from FieldCounts. assert.Equal(t, map[string]int{sync.FieldFirstMessage: 1}, report.FieldCounts, "only the genuine change contributes to FieldCounts") raced := findSessionDiff(report, "pd-raced") require.NotNil(t, raced, "raced session not listed") assert.Equal(t, sync.DiffRaced, raced.Class, "raced class") assert.NotEmpty(t, raced.Reason, "raced reason") // The would-be change is attached for drill-down even though it is // not counted, so an operator can see what the skew masked. assert.Contains(t, sessionDiffFieldNames(raced, true), sync.FieldFirstMessage, "raced field diff attached for drill-down") changed := findSessionDiff(report, "pd-control") require.NotNil(t, changed, "control session not listed") assert.Equal(t, sync.DiffChanged, changed.Class, "untouched-source drift stays changed") assert.Contains(t, sessionDiffFieldNames(changed, false), sync.FieldFirstMessage, "control change field") // The run fails because of the genuine change, not the raced one. assert.True(t, report.HasFailures(), "the untouched-source change must still trip --fail-on-change") } // TestParseDiffRacedAloneDoesNotFail isolates the gate contract: a run // whose only drift is masked by a live-write skew classifies raced and // must NOT trip --fail-on-change, so a concurrent daemon write can never // turn a vet run red on its own. func TestParseDiffRacedAloneDoesNotFail(t *testing.T) { env := setupSingleAgentTestEnv(t, parser.AgentClaude) racedPath := env.writeClaudeSession(t, "test-proj", "pd-solo.jsonl", parseDiffClaudeContent("solo prompt", "solo reply")) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 1, Synced: 1, }) mutateDB(t, env, "UPDATE sessions SET first_message = ? WHERE id = ?", "drifted first message", "pd-solo") future := time.Now().Add(48 * time.Hour) require.NoError(t, os.Chtimes(racedPath, future, future), "advance raced source mtime") report := runParseDiff(t, env, sync.ParseDiffOptions{}) assert.Equal(t, sync.ParseDiffTotals{ Examined: 1, Raced: 1, }, report.Totals, "totals") assert.Empty(t, report.FieldCounts, "raced field diffs must be excluded from FieldCounts") assert.False(t, report.HasFailures(), "a raced session alone must not trip --fail-on-change") } // TestParseDiffRacedDoesNotMaskCleanRun proves the skew guard never // invents a raced session out of an identical comparison: advancing the // source mtime of a session whose stored rows still match the parse // leaves it identical, not raced (the raced reclass only applies when // there is a real change to mask). func TestParseDiffRacedDoesNotMaskCleanRun(t *testing.T) { env := setupSingleAgentTestEnv(t, parser.AgentClaude) path := env.writeClaudeSession(t, "test-proj", "pd-clean.jsonl", parseDiffClaudeContent("clean prompt", "clean reply")) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 1, Synced: 1, }) // Advance the mtime without changing content: the comparison is still // identical, so the session must remain identical despite the skew. future := time.Now().Add(72 * time.Hour) require.NoError(t, os.Chtimes(path, future, future), "advance mtime") report := runParseDiff(t, env, sync.ParseDiffOptions{}) assert.Equal(t, sync.ParseDiffTotals{ Examined: 1, Identical: 1, }, report.Totals, "an unchanged session must stay identical, not raced") assert.False(t, report.HasFailures(), "clean run") } // claudeAppendedAssistantLine renders one raw assistant JSONL line that // can be appended to a Claude session source and picked up by an // incremental sync. func claudeAppendedAssistantLine(text, ts string) string { return testjsonl.ClaudeAssistantJSON( []map[string]any{{"type": "text", "text": text}}, ts, ) + "\n" } // appendClaudeLineAndSyncIncremental appends one raw JSONL line to a // Claude session source and drives a real incremental sync of it, // exercising the production incremental-append path (writeIncremental -> // WriteSessionIncremental) that sets last_write_incremental. Using the // real write path rather than a mutateDB-simulated flag is the point of // these tests: it proves the detection signal is wired end to end. func appendClaudeLineAndSyncIncremental( t *testing.T, env *testEnv, path, line string, ) { t.Helper() f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) require.NoError(t, err, "open for append") _, err = f.WriteString(line) require.NoError(t, err, "append line") require.NoError(t, f.Close(), "close after append") env.engine.SyncPaths([]string{path}) } // TestParseDiffIncrementalAppendSkewNotCountedAsChange proves the core // contract: a session last written through the real incremental-append // path is classified DiffIncrementalSkew (not DiffChanged), excluded from // FieldCounts and HasFailures, but still counted in Examined and listed // for drill-down. A control session written only through the full path // still classifies as a genuine change, so the marker never masks drift // on a session that was not actually written incrementally. func TestParseDiffIncrementalAppendSkewNotCountedAsChange(t *testing.T) { env := setupSingleAgentTestEnv(t, parser.AgentClaude) skewPath := env.writeClaudeSession(t, "test-proj", "pd-skew.jsonl", parseDiffClaudeContent("skew prompt", "skew reply")) env.writeClaudeSession(t, "test-proj", "pd-full.jsonl", parseDiffClaudeContent("full prompt", "full reply")) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 2, Synced: 2, }) appendClaudeLineAndSyncIncremental(t, env, skewPath, claudeAppendedAssistantLine("appended reply", "2024-01-01T10:00:10Z")) // Assert the marker was set by real production code, not simulated. afterAppend, err := env.db.GetSessionFull( context.Background(), "pd-skew", ) require.NoError(t, err, "GetSessionFull after append") require.NotNil(t, afterAppend, "session after append") require.True(t, afterAppend.LastWriteIncremental, "the incremental append must set the marker via real code") // Seed drift on both sessions. The incrementally written pd-skew gets a // per-message metadata (timestamp) diff -- the ordinal-shape surface a // full re-parse can legitimately reshape, so the marker may suppress it. // The full-write control pd-full gets a first_message diff so a re-parse // detects a change there too. mutateDB(t, env, "UPDATE messages SET timestamp = ? WHERE session_id = ? AND ordinal = 0", "2020-01-01T00:00:00Z", "pd-skew") mutateDB(t, env, "UPDATE sessions SET first_message = ? WHERE id = ?", "drifted first message", "pd-full") report := runParseDiff(t, env, sync.ParseDiffOptions{}) assert.Equal(t, sync.ParseDiffTotals{ Examined: 2, Changed: 1, IncrementalSkew: 1, }, report.Totals, "totals") // Only the full-write session's drift contributes to FieldCounts; the // incrementally written session's metadata drift is suppressed. assert.Equal(t, map[string]int{sync.FieldFirstMessage: 1}, report.FieldCounts, "only the full-write change contributes to FieldCounts") skew := findSessionDiff(report, "pd-skew") require.NotNil(t, skew, "skew session not listed") assert.Equal(t, sync.DiffIncrementalSkew, skew.Class, "skew class") assert.NotEmpty(t, skew.Reason, "skew reason") // The would-be change is attached for drill-down even though it is // not counted, so an operator can see what the skew suppressed. assert.Contains(t, sessionDiffFieldNames(skew, true), sync.FieldMessageMetadata, "skew field diff attached for drill-down") changed := findSessionDiff(report, "pd-full") require.NotNil(t, changed, "full session not listed") assert.Equal(t, sync.DiffChanged, changed.Class, "full-write drift stays changed, never masked as skew") assert.Contains(t, sessionDiffFieldNames(changed, false), sync.FieldFirstMessage, "control change field") // The run fails because of the full-write change, not the skew one. assert.True(t, report.HasFailures(), "the full-write change must still trip --fail-on-change") } // TestParseDiffIncrementalAppendSkewAloneDoesNotFail isolates the gate // contract: a run whose only drift is on an incrementally written session // classifies incremental_skew and must NOT trip --fail-on-change. func TestParseDiffIncrementalAppendSkewAloneDoesNotFail(t *testing.T) { env := setupSingleAgentTestEnv(t, parser.AgentClaude) path := env.writeClaudeSession(t, "test-proj", "pd-solo-skew.jsonl", parseDiffClaudeContent("solo prompt", "solo reply")) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 1, Synced: 1, }) appendClaudeLineAndSyncIncremental(t, env, path, claudeAppendedAssistantLine("appended reply", "2024-01-01T10:00:10Z")) // A per-message metadata (timestamp) diff is the incremental-artifact // surface the marker may suppress. mutateDB(t, env, "UPDATE messages SET timestamp = ? WHERE session_id = ? AND ordinal = 0", "2020-01-01T00:00:00Z", "pd-solo-skew") report := runParseDiff(t, env, sync.ParseDiffOptions{}) assert.Equal(t, sync.ParseDiffTotals{ Examined: 1, IncrementalSkew: 1, }, report.Totals, "totals") assert.Empty(t, report.FieldCounts, "skew field diffs must be excluded from FieldCounts") assert.False(t, report.HasFailures(), "a skew session alone must not trip --fail-on-change") } // TestParseDiffIncrementalMarkerDoesNotMaskNonArtifactDrift is the direct // regression guard for the narrowing: an incrementally written session // (marker set) whose drift lands on a field OUTSIDE the incremental-artifact // allow-list -- here first_message, which is head-derived and byte-stable // across appends -- must stay a genuine DiffChanged and trip // --fail-on-change. The marker is session-level; a regression is // field-level, so the marker alone must not suppress it. func TestParseDiffIncrementalMarkerDoesNotMaskNonArtifactDrift(t *testing.T) { env := setupSingleAgentTestEnv(t, parser.AgentClaude) path := env.writeClaudeSession(t, "test-proj", "pd-nomask.jsonl", parseDiffClaudeContent("nomask prompt", "nomask reply")) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 1, Synced: 1, }) appendClaudeLineAndSyncIncremental(t, env, path, claudeAppendedAssistantLine("appended reply", "2024-01-01T10:00:10Z")) marked, err := env.db.GetSessionFull(context.Background(), "pd-nomask") require.NoError(t, err, "GetSessionFull after append") require.NotNil(t, marked, "session after append") require.True(t, marked.LastWriteIncremental, "the incremental append must set the marker via real code") // first_message is not an incremental artifact: a diff there is genuine // parser drift the marker must not mask. mutateDB(t, env, "UPDATE sessions SET first_message = ? WHERE id = ?", "drifted first message", "pd-nomask") report := runParseDiff(t, env, sync.ParseDiffOptions{}) assert.Equal(t, sync.ParseDiffTotals{ Examined: 1, Changed: 1, }, report.Totals, "a non-artifact diff on a marked row must stay changed") changed := findSessionDiff(report, "pd-nomask") require.NotNil(t, changed, "session not listed") assert.Equal(t, sync.DiffChanged, changed.Class, "marker must not reclassify a first_message regression as skew") assert.Equal(t, map[string]int{sync.FieldFirstMessage: 1}, report.FieldCounts, "the regression must contribute to FieldCounts") assert.True(t, report.HasFailures(), "a non-artifact regression on a marked row must trip --fail-on-change") } // TestParseDiffFullResyncClearsIncrementalSkew proves the resync-baseline // self-heal: an incrementally written session classifies incremental_skew, // but after a full resync rewrites it through normalization (clearing the // marker) the same seeded drift classifies as a genuine DiffChanged again. func TestParseDiffFullResyncClearsIncrementalSkew(t *testing.T) { env := setupSingleAgentTestEnv(t, parser.AgentClaude) path := env.writeClaudeSession(t, "test-proj", "pd-heal.jsonl", parseDiffClaudeContent("heal prompt", "heal reply")) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 1, Synced: 1, }) appendClaudeLineAndSyncIncremental(t, env, path, claudeAppendedAssistantLine("appended reply", "2024-01-01T10:00:10Z")) // Precondition: the incrementally written session, drifting on the // per-message metadata (timestamp) artifact surface, is classified skew. mutateDB(t, env, "UPDATE messages SET timestamp = ? WHERE session_id = ? AND ordinal = 0", "2020-01-01T00:00:00Z", "pd-heal") report := runParseDiff(t, env, sync.ParseDiffOptions{}) skew := findSessionDiff(report, "pd-heal") require.NotNil(t, skew, "skew session not listed pre-resync") require.Equal(t, sync.DiffIncrementalSkew, skew.Class, "skew class pre-resync") // A full resync rewrites every row through normalization, clearing the // marker. Re-seed the same drift afterward (the resync overwrote it), // then confirm the session is now a genuine DiffChanged: the skew // suppression self-heals once the comparison basis is rebuilt. stats := env.engine.ResyncAll(context.Background(), nil) require.False(t, stats.Aborted, "resync aborted") healed, err := env.db.GetSessionFull(context.Background(), "pd-heal") require.NoError(t, err, "GetSessionFull after resync") require.NotNil(t, healed, "session after resync") require.False(t, healed.LastWriteIncremental, "a full resync must clear the incremental marker") mutateDB(t, env, "UPDATE messages SET timestamp = ? WHERE session_id = ? AND ordinal = 0", "2020-01-01T00:00:00Z", "pd-heal") report = runParseDiff(t, env, sync.ParseDiffOptions{}) changed := findSessionDiff(report, "pd-heal") require.NotNil(t, changed, "session not listed post-resync") assert.Equal(t, sync.DiffChanged, changed.Class, "a full resync clears the marker, restoring drift detection") assert.True(t, report.HasFailures(), "post-resync drift must trip --fail-on-change again") } // TestParseDiffIncrementalSkewNeverSetForDBBackedProviders proves the // marker is driven by the write path, not the agent: a DB-backed provider // (Kiro) always writes through the full path, so its rows never carry the // incremental marker and seeded drift stays a genuine DiffChanged. func TestParseDiffIncrementalSkewNeverSetForDBBackedProviders(t *testing.T) { env := setupSingleAgentTestEnv(t, parser.AgentKiro) ks := createKiroSQLiteDB(t, env.kiroDir) payload := readKiroSQLiteFixture(t, "standard_payload.json") ks.addSession( t, "/home/user/code/kiro-app", "kiro-db", payload, 1779012000000, 1779012030000, ) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 1, Synced: 1, }) stored, err := env.db.GetSessionFull( context.Background(), "kiro:kiro-db", ) require.NoError(t, err, "GetSessionFull kiro") require.NotNil(t, stored, "kiro session") require.False(t, stored.LastWriteIncremental, "DB-backed providers must never set the incremental marker") mutateDB(t, env, "UPDATE sessions SET first_message = ? WHERE id = ?", "drifted first message", "kiro:kiro-db") report := runParseDiff(t, env, sync.ParseDiffOptions{ Agents: []parser.AgentType{parser.AgentKiro}, }) assert.Zero(t, report.Totals.IncrementalSkew, "DB-backed drift must never classify as incremental skew") changed := findSessionDiff(report, "kiro:kiro-db") require.NotNil(t, changed, "kiro session not listed") assert.Equal(t, sync.DiffChanged, changed.Class, "DB-backed drift stays changed, never skew") assert.True(t, report.HasFailures(), "DB-backed drift must trip --fail-on-change") } // TestParseDiffRacedWinsOverIncrementalSkew pins the precedence: when a // source both advanced past its snapshot mtime (raced) and was last // written incrementally (skew), raced wins because the advanced mtime is // the stronger, directly provable diagnosis. func TestParseDiffRacedWinsOverIncrementalSkew(t *testing.T) { env := setupSingleAgentTestEnv(t, parser.AgentClaude) path := env.writeClaudeSession(t, "test-proj", "pd-both.jsonl", parseDiffClaudeContent("both prompt", "both reply")) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 1, Synced: 1, }) appendClaudeLineAndSyncIncremental(t, env, path, claudeAppendedAssistantLine("appended reply", "2024-01-01T10:00:10Z")) // Seed a per-message metadata (timestamp) diff -- an incremental-artifact // field, so this session would otherwise classify skew; raced must win. mutateDB(t, env, "UPDATE messages SET timestamp = ? WHERE session_id = ? AND ordinal = 0", "2020-01-01T00:00:00Z", "pd-both") // Advance the source mtime past the snapshot so the session is BOTH // raced and incrementally written. future := time.Now().Add(48 * time.Hour) require.NoError(t, os.Chtimes(path, future, future), "advance source mtime") report := runParseDiff(t, env, sync.ParseDiffOptions{}) assert.Equal(t, sync.ParseDiffTotals{ Examined: 1, Raced: 1, }, report.Totals, "raced wins over incremental skew") both := findSessionDiff(report, "pd-both") require.NotNil(t, both, "session not listed") assert.Equal(t, sync.DiffRaced, both.Class, "raced takes precedence over incremental skew") assert.False(t, report.HasFailures(), "a raced/skew session must not trip --fail-on-change") } // TestParseDiffDBBackedSourceNotMaskedAsRaced pins the reliability gate for // composite, DB-backed sources. Many Kiro sessions share ONE data.sqlite3, and // the live mtime the skew guard could observe (a composite db stat or per-row // updated_at) is NOT a basis-matching stat of a literal file whose mtime // populated file_mtime. Because that comparison is unreliable, the raced // reclassification must NOT apply to these sources at all -- even when a // session's row updated_at advanced past its snapshot, a detected drift stays a // genuine DiffChanged rather than being masked as DiffRaced. This fails CLOSED: // real parser regressions on DB-backed agents are never hidden from // --fail-on-change. func TestParseDiffDBBackedSourceNotMaskedAsRaced(t *testing.T) { env := setupSingleAgentTestEnv(t, parser.AgentKiro) ks := createKiroSQLiteDB(t, env.kiroDir) const ( advancedID = "kiro-advanced" quiescentID = "kiro-quiescent" ) // Both sessions share the same payload content and the same initial // updated_at, so each is stored with the same per-session snapshot // file_mtime (updated_at * 1e6). payload := readKiroSQLiteFixture(t, "standard_payload.json") const initialUpdatedAt = int64(1779012030000) ks.addSession( t, "/home/user/code/kiro-app", advancedID, payload, 1779012000000, initialUpdatedAt, ) ks.addSession( t, "/home/user/code/kiro-app", quiescentID, payload, 1779012000000, initialUpdatedAt, ) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 2, Synced: 2, }) // Seed real parser drift in the archive for BOTH sessions so a // re-parse of the unchanged payload detects a first_message change. mutateDB(t, env, "UPDATE sessions SET first_message = ? WHERE id = ?", "drifted first message", "kiro:"+advancedID) mutateDB(t, env, "UPDATE sessions SET first_message = ? WHERE id = ?", "drifted first message", "kiro:"+quiescentID) // Advance ONLY one session's row updated_at past its snapshot. Under the // old per-session raced check this would have masked the advanced session // as DiffRaced; the reliability gate now skips the raced check for this // DB-backed (virtual-path) source entirely, so the drift stays genuine. ks.updateSession(t, advancedID, payload, initialUpdatedAt+60000) report := runParseDiff(t, env, sync.ParseDiffOptions{ Agents: []parser.AgentType{parser.AgentKiro}, }) // Neither session is masked as raced: a DB-backed source has no // basis-matching live mtime, so both genuine drifts are reported as // changes and --fail-on-change fires. assert.Equal(t, sync.ParseDiffTotals{ Examined: 2, Changed: 2, }, report.Totals, "DB-backed drift must not be masked as raced") // Both seeded first_message drifts are counted; the advanced session's // updated_at bump additionally surfaces its ended_at change. Under the old // raced reclassification the advanced session (and all its field diffs) // would have been masked, hiding genuine drift from --fail-on-change. assert.Equal(t, map[string]int{ sync.FieldFirstMessage: 2, sync.FieldEndedAt: 1, }, report.FieldCounts, "genuine drift on DB-backed sources is no longer masked") advanced := findSessionDiff(report, "kiro:"+advancedID) require.NotNil(t, advanced, "advanced session not listed") assert.Equal(t, sync.DiffChanged, advanced.Class, "advanced DB-backed drift stays changed, not raced") assert.Contains(t, sessionDiffFieldNames(advanced, false), sync.FieldFirstMessage, "advanced field") quiescent := findSessionDiff(report, "kiro:"+quiescentID) require.NotNil(t, quiescent, "quiescent session not listed") assert.Equal(t, sync.DiffChanged, quiescent.Class, "untouched-source drift stays changed") assert.Contains(t, sessionDiffFieldNames(quiescent, false), sync.FieldFirstMessage, "quiescent field") assert.True(t, report.HasFailures(), "genuine DB-backed drift must trip --fail-on-change") } // TestParseDiffCodexIndexSkewDoesNotMaskTranscriptDrift pins that Codex's // shared session_index.jsonl mtime is not a per-session raced signal for // transcript-derived diffs. Advancing ONLY the index past the snapshot leaves // the transcript untouched; seeded first_message drift must stay DiffChanged // so --fail-on-change cannot pass because some unrelated title/index write // advanced the global index. func TestParseDiffCodexIndexSkewDoesNotMaskTranscriptDrift(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 := setupSingleAgentTestEnvWithDirs( t, parser.AgentCodex, []string{codexDir}, ) const uuid = "019eb791-cf7d-75c1-8439-9ed74c1229e1" content := testjsonl.NewSessionBuilder(). AddCodexMeta(tsEarly, uuid, "/home/user/code/api", "user"). AddCodexMessage(tsEarlyS1, "user", "Add tests"). AddCodexMessage(tsEarlyS5, "assistant", "Adding coverage."). String() sessionPath := 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, []byte( `{"id":"`+uuid+`","thread_name":"Codex title",`+ `"updated_at":"2026-06-11T17:34:20Z"}`+"\n", ), 0o644)) // Pin both files to one past instant so the stored snapshot file_mtime // (max of transcript and index) is that instant for both. base := time.Now().Add(-2 * time.Hour) require.NoError(t, os.Chtimes(sessionPath, base, base), "chtimes session") require.NoError(t, os.Chtimes(indexPath, base, base), "chtimes index") runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 1, Synced: 1, }) // Seed real parser drift in the archive so a re-parse of the unchanged // transcript reports a first_message change to reclassify. mutateDB(t, env, "UPDATE sessions SET first_message = ? WHERE id = ?", "drifted first message", "codex:"+uuid) // Advance ONLY the index past the snapshot; the transcript is untouched. // CodexEffectiveMtime folds the index in, but that global index mtime is // not evidence that this session's transcript-derived first_message diff // raced with a live write. future := time.Now().Add(48 * time.Hour) require.NoError(t, os.Chtimes(indexPath, future, future), "advance index mtime") report := runParseDiff(t, env, sync.ParseDiffOptions{ Agents: []parser.AgentType{parser.AgentCodex}, }) assert.Equal(t, sync.ParseDiffTotals{Examined: 1, Changed: 1}, report.Totals, "index-only skew must not mask transcript drift") assert.Equal(t, 1, report.FieldCounts[sync.FieldFirstMessage], "first_message drift must remain counted") changed := findSessionDiff(report, "codex:"+uuid) require.NotNil(t, changed, "codex session not listed") assert.Equal(t, sync.DiffChanged, changed.Class, "changed class") assert.True(t, report.HasFailures(), "transcript drift must trip --fail-on-change") } // TestParseDiffCodexTranscriptSkewUsesTranscriptStoredMtime pins the other // Codex mtime basis: the raced guard must compare the transcript-only live // mtime against the transcript-only stored mtime. Stored file_mtime still folds // in session_index.jsonl for normal sync invalidation, and that index mtime can // be newer than both transcript mtimes; it must not prevent a later transcript // write from being classified DiffRaced. func TestParseDiffCodexTranscriptSkewUsesTranscriptStoredMtime(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 := setupSingleAgentTestEnvWithDirs( t, parser.AgentCodex, []string{codexDir}, ) const uuid = "019eb791-cf7d-75c1-8439-9ed74c1229e2" original := testjsonl.NewSessionBuilder(). AddCodexMeta(tsEarly, uuid, "/home/user/code/api", "user"). AddCodexMessage(tsEarlyS1, "user", "Add tests"). AddCodexMessage(tsEarlyS5, "assistant", "Adding coverage."). String() sessionPath := env.writeCodexSession( t, filepath.Join("2026", "06", "11"), "rollout-2026-06-11T12-44-06-"+uuid+".jsonl", original, ) indexPath := filepath.Join(root, "session_index.jsonl") require.NoError(t, os.WriteFile(indexPath, []byte( `{"id":"`+uuid+`","thread_name":"Codex title",`+ `"updated_at":"2026-06-11T17:34:20Z"}`+"\n", ), 0o644)) transcriptSnapshot := time.Now().Add(-4 * time.Hour) transcriptWrite := time.Now().Add(-3 * time.Hour) indexSnapshot := time.Now().Add(-2 * time.Hour) require.NoError(t, os.Chtimes( sessionPath, transcriptSnapshot, transcriptSnapshot, ), "chtimes session snapshot") require.NoError(t, os.Chtimes(indexPath, indexSnapshot, indexSnapshot), "chtimes index snapshot") runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 1, Synced: 1, }) stored, err := env.db.GetSessionFull(context.Background(), "codex:"+uuid) require.NoError(t, err, "GetSessionFull") require.NotNil(t, stored, "stored Codex session") require.NotNil(t, stored.FileMtime, "stored file_mtime") assert.Equal(t, indexSnapshot.UnixNano(), *stored.FileMtime, "stored file_mtime should be index-folded") changed := testjsonl.NewSessionBuilder(). AddCodexMeta(tsEarly, uuid, "/home/user/code/api", "user"). AddCodexMessage(tsEarlyS1, "user", "Changed prompt"). AddCodexMessage(tsEarlyS5, "assistant", "Adding coverage."). String() require.NoError(t, os.WriteFile(sessionPath, []byte(changed), 0o644), "rewrite transcript") require.NoError(t, os.Chtimes(sessionPath, transcriptWrite, transcriptWrite), "advance transcript below index-folded stored mtime") report := runParseDiff(t, env, sync.ParseDiffOptions{ Agents: []parser.AgentType{parser.AgentCodex}, }) assert.Equal(t, sync.ParseDiffTotals{Examined: 1, Raced: 1}, report.Totals, "transcript write should be raced") assert.Empty(t, report.FieldCounts, "raced field diffs must not count as parser drift") raced := findSessionDiff(report, "codex:"+uuid) require.NotNil(t, raced, "codex session not listed") assert.Equal(t, sync.DiffRaced, raced.Class, "raced class") assert.False(t, report.HasFailures(), "transcript write skew must not trip --fail-on-change") } // TestParseDiffCodexIncrementalAppendDoesNotLookRaced covers the stable-source // side of the Codex transcript fingerprint fallback. A Codex incremental append // advances the stored source snapshot, so later parser drift against that // unchanged transcript must remain DiffChanged rather than being hidden as a // stale-fingerprint race. func TestParseDiffCodexIncrementalAppendDoesNotLookRaced(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } env := setupSingleAgentTestEnv(t, parser.AgentCodex) const uuid = "019eb791-cf7d-75c1-8439-9ed74c1229e3" initial := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON( uuid, "/tmp/proj", "codex_cli_rs", tsEarly, ), testjsonl.CodexMsgJSON("user", "hello", tsEarlyS1), ) path := env.writeCodexSession( t, filepath.Join("2026", "06", "11"), "rollout-2026-06-11T12-44-06-"+uuid+".jsonl", initial, ) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 1, Synced: 1, }) 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(), "close after append") require.NoError(t, err, "append") env.engine.SyncPaths([]string{path}) assertSessionMessageCount(t, env.db, "codex:"+uuid, 2) mutateDB(t, env, "UPDATE sessions SET first_message = ? WHERE id = ?", "drifted first message", "codex:"+uuid) report := runParseDiff(t, env, sync.ParseDiffOptions{ Agents: []parser.AgentType{parser.AgentCodex}, }) assert.Equal(t, sync.ParseDiffTotals{Examined: 1, Changed: 1}, report.Totals, "unchanged-source drift must stay changed") assert.Equal(t, 1, report.FieldCounts[sync.FieldFirstMessage], "first_message drift must be counted") changed := findSessionDiff(report, "codex:"+uuid) require.NotNil(t, changed, "codex session not listed") assert.Equal(t, sync.DiffChanged, changed.Class, "changed class") assert.True(t, report.HasFailures(), "stable-source parser drift must trip --fail-on-change") } func TestParseDiffCodexLegacyStaleIncrementalHashDoesNotLookRaced(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } env := setupSingleAgentTestEnv(t, parser.AgentCodex) const uuid = "019eb791-cf7d-75c1-8439-9ed74c1229e5" initial := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON( uuid, "/tmp/proj", "codex_cli_rs", tsEarly, ), testjsonl.CodexMsgJSON("user", "hello", tsEarlyS1), ) path := env.writeCodexSession( t, filepath.Join("2026", "06", "11"), "rollout-2026-06-11T12-44-06-"+uuid+".jsonl", initial, ) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 1, Synced: 1, }) beforeAppend, err := env.db.GetSessionFull( context.Background(), "codex:"+uuid, ) require.NoError(t, err, "GetSessionFull before append") require.NotNil(t, beforeAppend, "session before append") require.NotNil(t, beforeAppend.FileHash, "file_hash before append") staleHash := *beforeAppend.FileHash 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(), "close after append") require.NoError(t, err, "append") env.engine.SyncPaths([]string{path}) assertSessionMessageCount(t, env.db, "codex:"+uuid, 2) // Simulate a current data-version archive written by the legacy // incremental path: size/mtime advanced, but file_hash stayed on the // previous full-parse snapshot. mutateDB(t, env, "UPDATE sessions SET file_hash = ? WHERE id = ?", staleHash, "codex:"+uuid) mutateDB(t, env, "UPDATE sessions SET first_message = ? WHERE id = ?", "drifted first message", "codex:"+uuid) report := runParseDiff(t, env, sync.ParseDiffOptions{ Agents: []parser.AgentType{parser.AgentCodex}, }) assert.Equal(t, sync.ParseDiffTotals{Examined: 1, Changed: 1}, report.Totals, "legacy stale hash drift must stay changed") assert.Equal(t, 1, report.FieldCounts[sync.FieldFirstMessage], "first_message drift must be counted") changed := findSessionDiff(report, "codex:"+uuid) require.NotNil(t, changed, "codex session not listed") assert.Equal(t, sync.DiffChanged, changed.Class, "changed class") assert.True(t, report.HasFailures(), "stable-source parser drift must trip --fail-on-change") } func TestParseDiffCodexFullParsePartialTailDoesNotLookRaced(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } env := setupSingleAgentTestEnv(t, parser.AgentCodex) const uuid = "019eb791-cf7d-75c1-8439-9ed74c1229e6" content := testjsonl.JoinJSONL( testjsonl.CodexSessionMetaJSON( uuid, "/tmp/proj", "codex_cli_rs", tsEarly, ), testjsonl.CodexMsgJSON("user", "hello", tsEarlyS1), ) + `{"timestamp":"2024-01-01T10:00:10Z"` path := env.writeCodexSession( t, filepath.Join("2026", "06", "11"), "rollout-2026-06-11T12-44-06-"+uuid+".jsonl", content, ) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 1, Synced: 1, }) assertSessionMessageCount(t, env.db, "codex:"+uuid, 1) stored, err := env.db.GetSessionFull( context.Background(), "codex:"+uuid, ) require.NoError(t, err, "GetSessionFull after full parse") require.NotNil(t, stored, "stored session after full parse") require.NotNil(t, stored.FileSize, "stored file_size") info, err := os.Stat(path) require.NoError(t, err, "stat transcript") assert.Equal(t, info.Size(), *stored.FileSize, "full Codex parse stores raw file size including ignored tail") mutateDB(t, env, "UPDATE sessions SET first_message = ? WHERE id = ?", "drifted first message", "codex:"+uuid) report := runParseDiff(t, env, sync.ParseDiffOptions{ Agents: []parser.AgentType{parser.AgentCodex}, }) assert.Equal(t, sync.ParseDiffTotals{Examined: 1, Changed: 1}, report.Totals, "unchanged full-parse partial tail drift must stay changed") assert.Equal(t, 1, report.FieldCounts[sync.FieldFirstMessage], "first_message drift must be counted") changed := findSessionDiff(report, "codex:"+uuid) require.NotNil(t, changed, "codex session not listed") assert.Equal(t, sync.DiffChanged, changed.Class, "changed class") assert.True(t, report.HasFailures(), "stable-source parser drift must trip --fail-on-change") } func TestParseDiffCodexIncrementalPartialTailDoesNotLookRaced(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } env := setupSingleAgentTestEnv(t, parser.AgentCodex) 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("2026", "06", "11"), "rollout-2026-06-11T12-44-06-"+uuid+".jsonl", initial, ) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 1, Synced: 1, }) 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, err, "append complete message") _, err = f.WriteString(`{"timestamp":"2024-01-01T10:00:10Z"`) 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) mutateDB(t, env, "UPDATE sessions SET first_message = ? WHERE id = ?", "drifted first message", "codex:"+uuid) report := runParseDiff(t, env, sync.ParseDiffOptions{ Agents: []parser.AgentType{parser.AgentCodex}, }) assert.Equal(t, sync.ParseDiffTotals{Examined: 1, Changed: 1}, report.Totals, "unchanged consumed prefix drift must stay changed") assert.Equal(t, 1, report.FieldCounts[sync.FieldFirstMessage], "first_message drift must be counted") changed := findSessionDiff(report, "codex:"+uuid) require.NotNil(t, changed, "codex session not listed") assert.Equal(t, sync.DiffChanged, changed.Class, "changed class") assert.True(t, report.HasFailures(), "stable-source parser drift must trip --fail-on-change") } // writeHermesFanoutStateDB writes a Hermes state.db at /state.db holding // two sessions (each one user message) and no sibling transcripts, so both // sessions resolve to the SAME shared state.db File.Path -- the literal-path // fan-out shape. func writeHermesFanoutStateDB(t *testing.T, root string) { t.Helper() conn, err := sql.Open("sqlite3", filepath.Join(root, "state.db")) require.NoError(t, err, "open hermes state.db") defer conn.Close() _, err = conn.Exec(` CREATE TABLE sessions ( id TEXT PRIMARY KEY, source TEXT NOT NULL, user_id TEXT, model TEXT, model_config TEXT, system_prompt TEXT, parent_session_id TEXT, started_at REAL NOT NULL, ended_at REAL, end_reason TEXT, message_count INTEGER DEFAULT 0, tool_call_count INTEGER DEFAULT 0, input_tokens INTEGER DEFAULT 0, output_tokens INTEGER DEFAULT 0, cache_read_tokens INTEGER DEFAULT 0, cache_write_tokens INTEGER DEFAULT 0, reasoning_tokens INTEGER DEFAULT 0, billing_provider TEXT, billing_base_url TEXT, billing_mode TEXT, estimated_cost_usd REAL, actual_cost_usd REAL, cost_status TEXT, cost_source TEXT, pricing_version TEXT, title TEXT, api_call_count INTEGER DEFAULT 0 ); CREATE TABLE messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT, tool_call_id TEXT, tool_calls TEXT, tool_name TEXT, timestamp REAL NOT NULL, token_count INTEGER, finish_reason TEXT, reasoning TEXT, reasoning_content TEXT, reasoning_details TEXT, codex_reasoning_items TEXT, codex_message_items TEXT ); INSERT INTO sessions (id, source, model, started_at, ended_at, message_count, title) VALUES ('alpha', 'discord', 'gpt-5.5', 1778767200.0, 1778767800.0, 1, 'Alpha'); INSERT INTO sessions (id, source, model, started_at, ended_at, message_count, title) VALUES ('beta', 'discord', 'gpt-5.5', 1778768200.0, 1778768800.0, 1, 'Beta'); INSERT INTO messages (session_id, role, content, timestamp) VALUES ('alpha', 'user', 'alpha prompt', 1778767210.0); INSERT INTO messages (session_id, role, content, timestamp) VALUES ('beta', 'user', 'beta prompt', 1778768210.0); `) require.NoError(t, err, "seed hermes state.db") } // TestParseDiffHermesSharedStateDBNotMaskedAsRaced pins the literal-path // fan-out gate. Many Hermes sessions resolve to one shared state.db File.Path, // so its mtime is not a per-session signal: a write that advances state.db for // any session would, without the fan-out guard, race-mask genuine drift on // every sibling. Both sessions must therefore stay DiffChanged (fail closed), // not DiffRaced, even though the shared source mtime advanced past the snapshot. func TestParseDiffHermesSharedStateDBNotMaskedAsRaced(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } root := t.TempDir() sessionsDir := filepath.Join(root, "sessions") require.NoError(t, os.MkdirAll(sessionsDir, 0o755)) writeHermesFanoutStateDB(t, root) database := dbtest.OpenTestDB(t) cfg := sync.EngineConfig{ AgentDirs: map[parser.AgentType][]string{ parser.AgentHermes: {sessionsDir}, }, Machine: "local", } engine := sync.NewEngine(database, cfg) stats := engine.SyncAll(context.Background(), nil) require.Equal(t, 2, stats.Synced, "expected both Hermes sessions synced") // Seed real parser drift in the archive for BOTH sessions so a re-parse // of the unchanged state.db reports a first_message change. for _, id := range []string{"hermes:alpha", "hermes:beta"} { require.NoError(t, database.Update(func(tx *sql.Tx) error { _, err := tx.Exec( "UPDATE sessions SET first_message = ? WHERE id = ?", "drifted first message", id, ) return err }), "drift %s", id) } // Advance the shared state.db mtime well past the stored snapshot, as a // concurrent write to any session would. future := time.Now().Add(48 * time.Hour) require.NoError(t, os.Chtimes( filepath.Join(root, "state.db"), future, future, ), "advance state.db mtime") report, err := sync.NewDiffEngine(database, cfg).ParseDiff( context.Background(), sync.ParseDiffOptions{Agents: []parser.AgentType{parser.AgentHermes}}, ) require.NoError(t, err, "ParseDiff") require.NotNil(t, report) assert.Equal(t, sync.ParseDiffTotals{Examined: 2, Changed: 2}, report.Totals, "shared-source drift must not be masked as raced") for _, id := range []string{"hermes:alpha", "hermes:beta"} { sd := findSessionDiff(report, id) require.NotNil(t, sd, "%s not listed", id) assert.Equal(t, sync.DiffChanged, sd.Class, "%s stays changed, not raced", id) } assert.True(t, report.HasFailures(), "genuine shared-source drift must trip --fail-on-change") } // TestParseDiffEngineRefusesWrites proves sub-item (4): the report-only // engine NewDiffEngine returns must refuse the write entrypoints // (SyncAll/ResyncAll and friends) so a forceParse engine can never be // driven into rewriting the archive. The refusal is a no-op (zero stats, // nil error) and persists nothing. func TestParseDiffEngineRefusesWrites(t *testing.T) { env := setupSingleAgentTestEnv(t, parser.AgentClaude) path := env.writeClaudeSession(t, "test-proj", "pd-guard.jsonl", parseDiffClaudeContent( "guard prompt with AKIA7QHWN2DKR4FYPLJM", "guard reply", )) diffEngine := newParseDiffEngine(env) ctx := context.Background() assert.Equal(t, sync.SyncStats{}, diffEngine.SyncAll(ctx, nil), "SyncAll on a report-only engine must be a no-op") assert.Equal(t, sync.SyncStats{}, diffEngine.ResyncAll(ctx, nil), "ResyncAll on a report-only engine must be a no-op") assert.Equal(t, sync.SyncStats{}, diffEngine.SyncAllSince(ctx, time.Time{}, nil), "SyncAllSince on a report-only engine must be a no-op") assert.Equal(t, sync.SyncStats{}, diffEngine.SyncRootsSince(ctx, nil, time.Time{}, nil), "SyncRootsSince on a report-only engine must be a no-op") stats, err := diffEngine.SyncThenRun( ctx, false, nil, func(forceFull bool) error { return env.db.UpsertSession(db.Session{ ID: "sync-then-run-wrote", }) }, ) require.NoError(t, err, "SyncThenRun on a report-only engine should refuse cleanly") assert.Equal(t, sync.SyncStats{}, stats, "SyncThenRun on a report-only engine must be a no-op") require.Error(t, diffEngine.RunExclusive(func() error { return env.db.UpsertSession(db.Session{ ID: "run-exclusive-wrote", }) }), "RunExclusive on a report-only engine must error") require.Error(t, diffEngine.SyncSingleSession("claude:pd-guard"), "SyncSingleSession on a report-only engine must error") // Nothing was written despite a discoverable source on disk. require.FileExists(t, path) all, err := env.db.ListSessionsModifiedBetween(ctx, "", "", nil, nil) require.NoError(t, err, "list sessions") assert.Empty(t, all, "refused writes must not persist any session rows") // The real sync engine (forceParse off) still syncs the same source, // proving the guard is scoped to report-only engines only. runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 1, Synced: 1, }) const sessionID = "pd-guard" mutateDB(t, env, "UPDATE sessions SET secret_leak_count = 0, "+ "secrets_rules_version = '', quality_signal_version = 0"+ " WHERE id = ?", sessionID) mutateDB(t, env, "DELETE FROM secret_findings WHERE session_id = ?", sessionID) require.Error(t, diffEngine.RecomputeSignals(ctx, sessionID), "RecomputeSignals on a report-only engine must error") mutateDB(t, env, "UPDATE sessions SET secret_leak_count = 0, "+ "secrets_rules_version = '', quality_signal_version = 0"+ " WHERE id = ?", sessionID) mutateDB(t, env, "DELETE FROM secret_findings WHERE session_id = ?", sessionID) require.Error(t, diffEngine.BackfillSignalComputer()(ctx, sessionID), "BackfillSignalComputer on a report-only engine must error") mutateDB(t, env, "UPDATE sessions SET secret_leak_count = 0, "+ "secrets_rules_version = '', quality_signal_version = 0"+ " WHERE id = ?", sessionID) mutateDB(t, env, "DELETE FROM secret_findings WHERE session_id = ?", sessionID) _, err = diffEngine.ScanSecrets( ctx, sync.SecretScanInput{Backfill: true}, nil, ) require.Error(t, err, "ScanSecrets on a report-only engine must error") stored, err := env.db.GetSessionFull(ctx, sessionID) require.NoError(t, err, "GetSessionFull after refused scans") require.NotNil(t, stored, "stored session after refused scans") assert.Zero(t, stored.SecretLeakCount, "refused scans must not update secret_leak_count") assert.Empty(t, stored.SecretsRulesVersion, "refused scans must not update secrets_rules_version") assert.Nil(t, stored.StoredQualitySignals(), "refused recompute must not update quality signals") findings, err := env.db.SessionSecretFindings(ctx, sessionID) require.NoError(t, err, "SessionSecretFindings after refused scans") assert.Empty(t, findings, "refused scans must not persist secret findings") } func TestParseDiffPresenceSweep(t *testing.T) { t.Run("current-version row no longer emitted", func(t *testing.T) { env := setupSingleAgentTestEnv(t, parser.AgentClaude) path := env.writeClaudeSession(t, "test-proj", "pd-real.jsonl", parseDiffClaudeContent("real prompt", "real reply")) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 1, Synced: 1, }) // A current-version row under the same source file with an ID // today's parser never derives: the loudest drift signal. require.NoError(t, env.db.UpsertSession(db.Session{ ID: "pd-phantom", Project: "test-proj", Machine: "local", Agent: "claude", FilePath: &path, }), "insert phantom session") require.NoError(t, env.db.SetSessionDataVersion( "pd-phantom", db.CurrentDataVersion(), ), "stamp current data version") report := runParseDiff(t, env, sync.ParseDiffOptions{}) assert.Equal(t, sync.ParseDiffTotals{ Examined: 2, Identical: 1, Changed: 1, }, report.Totals, "totals") assert.Equal(t, map[string]int{sync.FieldPresence: 1}, report.FieldCounts, "field counts") sd := findSessionDiff(report, "pd-phantom") require.NotNil(t, sd, "phantom session not listed") assert.Equal(t, sync.DiffChanged, sd.Class, "class") assert.Contains(t, sessionDiffFieldNames(sd, false), sync.FieldPresence, "presence diff") assert.True(t, report.HasFailures(), "a current-version presence drop is parser drift") }) t.Run("stale row no longer emitted is pending resync", func(t *testing.T) { env := setupSingleAgentTestEnv(t, parser.AgentClaude) path := env.writeClaudeSession(t, "test-proj", "pd-real.jsonl", parseDiffClaudeContent("real prompt", "real reply")) runSyncAndAssert(t, env.engine, sync.SyncStats{ TotalSessions: 1, Synced: 1, }) // Data version 0: an incomplete write preserved by the // archive (e.g. a transient fork row left by a live sync). require.NoError(t, env.db.UpsertSession(db.Session{ ID: "pd-zombie", Project: "test-proj", Machine: "local", Agent: "claude", FilePath: &path, }), "insert zombie session") report := runParseDiff(t, env, sync.ParseDiffOptions{}) assert.Equal(t, sync.ParseDiffTotals{ Examined: 2, Identical: 1, PendingResync: 1, }, report.Totals, "totals") assert.Empty(t, report.FieldCounts, "stale presence is pipeline history, not drift") sd := findSessionDiff(report, "pd-zombie") require.NotNil(t, sd, "zombie session not listed") assert.Equal(t, sync.DiffPendingResync, sd.Class, "class") assert.Contains(t, sessionDiffFieldNames(sd, true), sync.FieldPresence, "presence field attached for drill-down") assert.False(t, report.HasFailures(), "stale rows must not trip --fail-on-change") }) }