Files
kenn-io--agentsview/internal/sync/shelley_integration_test.go
wehub-resource-sync f99010fae1
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
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

604 lines
22 KiB
Go

package sync_test
import (
"context"
"database/sql"
"os"
"path/filepath"
stdlibsync "sync"
"testing"
"time"
_ "github.com/mattn/go-sqlite3"
"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"
)
const shelleyTestSchema = `
CREATE TABLE conversations (
conversation_id TEXT PRIMARY KEY,
slug TEXT,
user_initiated BOOLEAN NOT NULL DEFAULT TRUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
cwd TEXT,
archived BOOLEAN NOT NULL DEFAULT FALSE,
parent_conversation_id TEXT,
model TEXT,
current_generation INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE messages (
message_id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
sequence_id INTEGER NOT NULL,
type TEXT NOT NULL,
llm_data TEXT,
user_data TEXT,
usage_data TEXT,
display_data TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
generation INTEGER NOT NULL DEFAULT 1,
excluded_from_context BOOLEAN NOT NULL DEFAULT FALSE
);
`
// shelleyMsg is a minimal message fixture for the integration DB.
type shelleyMsg struct {
seq int
msgType string
llmData string
usageDat string
created string
}
var (
shelleyMainTemplateOnce stdlibsync.Once
shelleyMainTemplateBytes []byte
shelleyMainTemplateErr error
)
func createShelleyDB(t *testing.T, dir string) string {
t.Helper()
dbPath := filepath.Join(dir, "shelley.db")
copySQLiteSchemaTemplate(
t, dbPath, "shelley", &shelleySchemaOnce,
&shelleySchemaBytes, &shelleySchemaErr,
shelleyTestSchema,
)
db, err := sql.Open("sqlite3", dbPath)
require.NoError(t, err, "open shelley test db")
defer db.Close()
return dbPath
}
func createShelleyMainDB(t *testing.T, dir string) string {
t.Helper()
shelleyMainTemplateOnce.Do(func() {
templateDir, err := os.MkdirTemp("", "agentsview-shelley-main-*")
if err != nil {
shelleyMainTemplateErr = err
return
}
defer os.RemoveAll(templateDir)
dbPath := createShelleyDB(t, templateDir)
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())
shelleyMainTemplateBytes, shelleyMainTemplateErr = os.ReadFile(dbPath)
})
require.NoError(t, shelleyMainTemplateErr, "build Shelley main fixture")
dbPath := filepath.Join(dir, "shelley.db")
require.NoError(t, os.MkdirAll(dir, 0o755), "create Shelley fixture dir")
require.NoError(t, os.WriteFile(dbPath, shelleyMainTemplateBytes, 0o600),
"copy Shelley main fixture")
return dbPath
}
func seedShelleyConvo(
t *testing.T, dbPath, id, slug, cwd, model, parent string,
userInitiated bool, created, updated string, msgs []shelleyMsg,
) {
t.Helper()
db, err := sql.Open("sqlite3", dbPath)
require.NoError(t, err)
defer db.Close()
_, err = db.Exec(
`INSERT INTO conversations
(conversation_id, slug, user_initiated, created_at,
updated_at, cwd, parent_conversation_id, model)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
id, slug, userInitiated, created, updated, cwd,
nullIfEmpty(parent), nullIfEmpty(model),
)
require.NoError(t, err, "insert conversation %s", id)
for _, m := range msgs {
_, err = db.Exec(
`INSERT INTO messages
(message_id, conversation_id, sequence_id, type,
llm_data, usage_data, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
id+"-m"+string(rune('0'+m.seq)), id, m.seq, m.msgType,
nullIfEmpty(m.llmData), nullIfEmpty(m.usageDat), m.created,
)
require.NoError(t, err, "insert message %s/%d", id, m.seq)
}
}
func nullIfEmpty(s string) any {
if s == "" {
return nil
}
return s
}
func newShelleyEngine(t *testing.T, dir string) (*sync.Engine, *db.DB) {
t.Helper()
database := dbtest.OpenTestDB(t)
engine := sync.NewEngine(database, sync.EngineConfig{
AgentDirs: map[parser.AgentType][]string{
parser.AgentShelley: {dir},
},
Machine: "local",
})
return engine, database
}
func sessionIDs(sessions []db.Session) []string {
ids := make([]string, 0, len(sessions))
for _, s := range sessions {
ids = append(ids, s.ID)
}
return ids
}
func mainConvoMsgs() []shelleyMsg {
return []shelleyMsg{
{1, "user",
`{"Role":0,"Content":[{"Type":2,"Text":"hello shelley"}]}`,
"", "2026-06-15T10:00:00Z"},
{2, "agent",
`{"Role":1,"Content":[{"Type":2,"Text":"hi"},` +
`{"ID":"toolu_x","Type":5,"ToolName":"bash","ToolInput":{"cmd":"ls"}}]}`,
`{"input_tokens":500,"cache_read_input_tokens":0,` +
`"cache_creation_input_tokens":0,"output_tokens":50,` +
`"model":"claude-sonnet-4-6"}`,
"2026-06-15T10:00:05Z"},
{3, "tool",
`{"Role":0,"Content":[{"Type":6,"ToolUseID":"toolu_x",` +
`"ToolResult":[{"Type":2,"Text":"file1"}]}]}`,
"", "2026-06-15T10:00:06Z"},
}
}
func TestSyncSingleSessionShelleyUsesVirtualSourcePath(t *testing.T) {
dir := t.TempDir()
dbPath := createShelleyMainDB(t, dir)
engine, database := newShelleyEngine(t, dir)
assert.Equal(t, dbPath+"#cMAIN1",
engine.FindSourceFile("shelley:cMAIN1"), "virtual source path")
require.NoError(t, engine.SyncSingleSession("shelley:cMAIN1"))
sess, err := database.GetSession(context.Background(), "shelley:cMAIN1")
require.NoError(t, err)
require.NotNil(t, sess, "session present")
// The user prompt and the agent reply remain; the tool-result-only
// carrier message is paired into the tool call and dropped.
assert.Equal(t, 2, sess.MessageCount, "message count")
assert.Equal(t, "app", sess.Project, "project")
assert.Equal(t, dbPath+"#cMAIN1",
database.GetSessionFilePath("shelley:cMAIN1"), "stored file path")
}
func TestSyncSingleSessionShelleyForceRewritesUnchangedSession(t *testing.T) {
dir := t.TempDir()
dbPath := createShelleyMainDB(t, dir)
engine, database := newShelleyEngine(t, dir)
require.NoError(t, engine.SyncSingleSession("shelley:cMAIN1"))
sess, err := database.GetSession(context.Background(), "shelley:cMAIN1")
require.NoError(t, err)
require.NotNil(t, sess)
require.Equal(t, 2, sess.MessageCount)
sess.MessageCount = 0
require.NoError(t, database.UpsertSession(*sess))
require.NoError(t, engine.SyncSingleSession("shelley:cMAIN1"))
sess, err = database.GetSession(context.Background(), "shelley:cMAIN1")
require.NoError(t, err)
require.NotNil(t, sess)
assert.Equal(t, 2, sess.MessageCount)
assert.Equal(t, dbPath+"#cMAIN1",
database.GetSessionFilePath("shelley:cMAIN1"), "stored file path")
}
func TestSyncPathsShelleyDeletedPhysicalDBPreservesSessions(t *testing.T) {
dir := t.TempDir()
dbPath := createShelleyMainDB(t, dir)
engine, database := newShelleyEngine(t, dir)
stats := engine.SyncAll(context.Background(), nil)
require.Equal(t, 1, stats.Synced)
require.NoError(t, os.Remove(dbPath))
engine.SyncPaths([]string{dbPath})
// The SQLite store is a persistent archive: removing the backing DB file
// must not delete the already-synced session.
sess, err := database.GetSession(context.Background(), "shelley:cMAIN1")
require.NoError(t, err)
require.NotNil(t, sess)
assert.Equal(t, "shelley:cMAIN1", sess.ID)
}
// TestSourceMtimeShelleyResolvesVirtualPath guards the live per-session
// watcher: SourceMtime must resolve a shelley.db#<id> virtual path to the
// conversation's updated_at, not fall through to os.Stat (which fails on a
// virtual path and returns 0, which the watcher reads as "source gone").
func TestSourceMtimeShelleyResolvesVirtualPath(t *testing.T) {
dir := t.TempDir()
createShelleyMainDB(t, dir)
engine, _ := newShelleyEngine(t, dir)
assert.Positive(t, engine.SourceMtime("shelley:cMAIN1"),
"SourceMtime must resolve the virtual path, not return 0")
}
func TestShelleySyncAllAndResyncAllArchiveBehavior(t *testing.T) {
dir := t.TempDir()
dbPath := createShelleyMainDB(t, dir)
seedShelleyConvo(t, dbPath, "cAUX1", "aux", "/home/u/dev/app",
"claude-sonnet-4-6", "", true,
"2026-06-15T10:01:00Z", "2026-06-15T10:01:10Z", []shelleyMsg{
{1, "user", `{"Role":0,"Content":[{"Type":2,"Text":"second"}]}`,
"", "2026-06-15T10:01:00Z"},
{2, "agent", `{"Role":1,"Content":[{"Type":2,"Text":"ok"}]}`,
"", "2026-06-15T10:01:10Z"},
})
seedShelleyConvo(t, dbPath, "cGONE1", "gone", "/home/u/dev/app",
"claude-sonnet-4-6", "", true,
"2026-06-15T09:00:00Z", "2026-06-15T09:00:10Z", []shelleyMsg{
{1, "user", `{"Role":0,"Content":[{"Type":2,"Text":"old work"}]}`,
"", "2026-06-15T09:00:00Z"},
{2, "agent", `{"Role":1,"Content":[{"Type":2,"Text":"old reply"}]}`,
"", "2026-06-15T09:00:10Z"},
})
engine, database := newShelleyEngine(t, dir)
stats := engine.SyncAll(context.Background(), nil)
require.False(t, stats.Aborted, "sync aborted: %+v", stats)
assert.Equal(t, 3, stats.Synced, "synced count")
assertSessionMessageCount(t, database, "shelley:cMAIN1", 2)
assertSessionMessageCount(t, database, "shelley:cAUX1", 2)
assertSessionMessageCount(t, database, "shelley:cGONE1", 2)
assertToolCallCount(t, database, "shelley:cMAIN1", 1)
assertMessageContent(t, database, "shelley:cAUX1", "second", "ok")
// The tool result from the dropped carrier message is paired into
// the tool call's result_content.
var resultContent string
require.NoError(t, database.Reader().QueryRow(
`SELECT COALESCE(result_content, '') FROM tool_calls WHERE session_id = ?`,
"shelley:cMAIN1",
).Scan(&resultContent), "query tool result content")
assert.Contains(t, resultContent, "file1", "tool result preserved on tool call")
// Remove cGONE1 from the source DB entirely, then verify a full resync
// rebuilds present conversations and preserves the removed conversation
// from the old archive.
conn, err := sql.Open("sqlite3", dbPath)
require.NoError(t, err)
_, err = conn.Exec(`DELETE FROM messages WHERE conversation_id = 'cGONE1'`)
require.NoError(t, err)
_, err = conn.Exec(`DELETE FROM conversations WHERE conversation_id = 'cGONE1'`)
require.NoError(t, err)
require.NoError(t, conn.Close())
stats = engine.ResyncAll(context.Background(), nil)
assert.False(t, stats.Aborted, "resync aborted: %+v", stats)
assert.NotZero(t, stats.Synced, "resync should re-parse fresh")
assertSessionMessageCount(t, database, "shelley:cMAIN1", 2)
assertSessionMessageCount(t, database, "shelley:cAUX1", 2)
gone, err := database.GetSession(context.Background(), "shelley:cGONE1")
require.NoError(t, err)
require.NotNil(t, gone, "removed conversation should survive resync")
assert.Equal(t, 2, gone.MessageCount, "preserved message count")
}
func TestSyncShelleyRemotePathRewriterSkip(t *testing.T) {
dir := t.TempDir()
dbPath := createShelleyMainDB(t, dir)
database := dbtest.OpenTestDB(t)
engine := sync.NewEngine(database, sync.EngineConfig{
AgentDirs: map[parser.AgentType][]string{
parser.AgentShelley: {dir},
},
Machine: "host",
IDPrefix: "host~",
PathRewriter: func(path string) string {
return "host:" + path
},
})
stats := engine.SyncAll(context.Background(), nil)
require.False(t, stats.Aborted, "first sync aborted: %+v", stats)
assert.Equal(t, 1, stats.Synced, "first sync should write the session")
const sessionID = "host~shelley:cMAIN1"
storedPath := "host:" + dbPath + "#cMAIN1"
assert.Equal(t, storedPath,
database.GetSessionFilePath(sessionID), "stored remote path")
before, err := database.GetSessionFull(context.Background(), sessionID)
require.NoError(t, err, "GetSessionFull before second sync")
require.NotNil(t, before, "session before second sync")
require.NotNil(t, before.LocalModifiedAt,
"local_modified_at before second sync")
_, storedMtime, ok := database.GetFileInfoByPath(storedPath)
require.True(t, ok, "remote virtual path should be indexed")
assert.NotZero(t, storedMtime, "stored mtime")
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 remote Shelley conversation should be skipped")
after, err := database.GetSessionFull(context.Background(), sessionID)
require.NoError(t, err, "GetSessionFull after second sync")
require.NotNil(t, after, "session after second sync")
require.NotNil(t, after.LocalModifiedAt,
"local_modified_at after second sync")
assert.Equal(t, *before.LocalModifiedAt, *after.LocalModifiedAt,
"skip lookup must use the rewritten virtual path")
}
// TestSyncShelleyForceReplaceOnInPlaceUpdate verifies that when a
// message's content changes in place (Shelley rewrites rows and bumps
// updated_at), a re-sync fully replaces the session's messages rather
// than appending duplicates.
func TestSyncShelleyForceReplaceOnInPlaceUpdate(t *testing.T) {
dir := t.TempDir()
dbPath := createShelleyDB(t, dir)
seedShelleyConvo(t, dbPath, "cMAIN1", "main", "/home/u/dev/app",
"claude-sonnet-4-6", "", true,
"2026-06-15T10:00:00Z", "2026-06-15T10:00:10Z", []shelleyMsg{
{1, "user", `{"Role":0,"Content":[{"Type":2,"Text":"q"}]}`,
"", "2026-06-15T10:00:00Z"},
{2, "agent", `{"Role":1,"Content":[{"Type":2,"Text":"first answer"}]}`,
"", "2026-06-15T10:00:10Z"},
})
engine, database := newShelleyEngine(t, dir)
require.False(t, engine.SyncAll(context.Background(), nil).Aborted)
assertMessageContent(t, database, "shelley:cMAIN1", "q", "first answer")
// Rewrite the agent message in place and bump updated_at so the
// per-session skip detects the change.
conn, err := sql.Open("sqlite3", dbPath)
require.NoError(t, err)
_, err = conn.Exec(
`UPDATE messages SET llm_data = ? WHERE conversation_id = 'cMAIN1' AND sequence_id = 2`,
`{"Role":1,"Content":[{"Type":2,"Text":"second answer"}]}`,
)
require.NoError(t, err)
_, err = conn.Exec(
`UPDATE conversations SET updated_at = '2026-06-15T10:05:00Z' WHERE conversation_id = 'cMAIN1'`,
)
require.NoError(t, err)
require.NoError(t, conn.Close())
require.False(t, engine.SyncAll(context.Background(), nil).Aborted)
// Replaced, not appended: still two messages, new content.
assertSessionMessageCount(t, database, "shelley:cMAIN1", 2)
assertMessageContent(t, database, "shelley:cMAIN1", "q", "second answer")
}
// TestSyncShelleyStoresRealTimestamp guards against a synthetic future
// file_mtime. ListSessionsModifiedBetween filters file_mtime <= now for
// PG/DuckDB push, so a Shelley row whose stored mtime drifted past its
// updated_at second could be dropped from a same-second push. The stored
// file_mtime must equal the conversation's real updated_at instant.
func TestSyncShelleyStoresRealTimestamp(t *testing.T) {
dir := t.TempDir()
dbPath := createShelleyDB(t, dir)
const updatedAt = "2026-06-15T10:00:05Z"
seedShelleyConvo(t, dbPath, "cMAIN1", "main", "/home/u/dev/app",
"claude-sonnet-4-6", "", true,
"2026-06-15T10:00:00Z", updatedAt, mainConvoMsgs())
engine, database := newShelleyEngine(t, dir)
require.False(t, engine.SyncAll(context.Background(), nil).Aborted)
want, err := time.Parse(time.RFC3339, updatedAt)
require.NoError(t, err)
_, mtime, ok := database.GetSessionFileInfo("shelley:cMAIN1")
require.True(t, ok, "session file info present")
assert.Equal(t, want.UnixNano(), mtime,
"stored file_mtime is the real updated_at, not a synthetic offset")
}
// TestSyncShelleySameSecondInPlaceRewrite covers the gap that
// updated_at + MAX(sequence_id) alone cannot close: a message rewritten
// in place where updated_at stays in the same wall-clock second and no
// new row is appended (sequence_id unchanged). The content fingerprint
// detects it, so a re-sync replaces the stored transcript instead of
// skipping it as unchanged.
func TestSyncShelleySameSecondInPlaceRewrite(t *testing.T) {
dir := t.TempDir()
dbPath := createShelleyDB(t, dir)
seedShelleyConvo(t, dbPath, "cMAIN1", "main", "/home/u/dev/app",
"claude-sonnet-4-6", "", true,
"2026-06-15T10:00:00Z", "2026-06-15T10:00:10Z", []shelleyMsg{
{1, "user", `{"Role":0,"Content":[{"Type":2,"Text":"q"}]}`,
"", "2026-06-15T10:00:00Z"},
{2, "agent", `{"Role":1,"Content":[{"Type":2,"Text":"partial"}]}`,
"", "2026-06-15T10:00:10Z"},
})
engine, database := newShelleyEngine(t, dir)
require.False(t, engine.SyncAll(context.Background(), nil).Aborted)
assertMessageContent(t, database, "shelley:cMAIN1", "q", "partial")
before, err := database.GetSessionFull(
context.Background(), "shelley:cMAIN1",
)
require.NoError(t, err, "GetSessionFull before rewrite")
require.NotNil(t, before, "session before rewrite")
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")
// Rewrite the agent message in place. Crucially, updated_at is left
// untouched (same second) and sequence_id is unchanged, so only the
// content fingerprint differs.
time.Sleep(20 * time.Millisecond)
conn, err := sql.Open("sqlite3", dbPath)
require.NoError(t, err)
_, err = conn.Exec(
`UPDATE messages SET llm_data = ? WHERE conversation_id = 'cMAIN1' AND sequence_id = 2`,
`{"Role":1,"Content":[{"Type":2,"Text":"the full streamed answer"}]}`,
)
require.NoError(t, err)
require.NoError(t, conn.Close())
require.False(t, engine.SyncAll(context.Background(), nil).Aborted)
// Detected via the content fingerprint: replaced, not skipped.
assertSessionMessageCount(t, database, "shelley:cMAIN1", 2)
assertMessageContent(
t, database, "shelley:cMAIN1", "q", "the full streamed answer",
)
after, err := database.GetSessionFull(
context.Background(), "shelley:cMAIN1",
)
require.NoError(t, err, "GetSessionFull after rewrite")
require.NotNil(t, after, "session after rewrite")
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-second rewrite keeps the real Shelley updated_at timestamp")
assert.NotEqual(t, *before.FileHash, *after.FileHash,
"same-count same-second rewrite changes the content fingerprint")
assert.Greater(t, *after.LocalModifiedAt, *before.LocalModifiedAt,
"successful rewrite must bump local_modified_at for push windows")
candidates, err := database.ListSessionsModifiedBetween(
context.Background(),
*before.LocalModifiedAt,
time.Now().UTC().Add(time.Second).Format(time.RFC3339Nano),
nil,
nil,
)
require.NoError(t, err, "ListSessionsModifiedBetween after rewrite")
assert.Contains(t, sessionIDs(candidates), "shelley:cMAIN1",
"local_modified_at must select the rewritten session for pushes")
}
// TestSyncShelleySameSecondMetadataRewrite covers conversation fields the
// parser reads outside the message payloads. A same-second slug/cwd edit
// must change the stored fingerprint so the bulk skip re-parses the
// conversation and refreshes session metadata.
func TestSyncShelleySameSecondMetadataRewrite(t *testing.T) {
dir := t.TempDir()
dbPath := createShelleyDB(t, dir)
seedShelleyConvo(t, dbPath, "cMAIN1", "main", "/home/u/dev/app",
"claude-sonnet-4-6", "", true,
"2026-06-15T10:00:00Z", "2026-06-15T10:00:10Z", []shelleyMsg{
{1, "user", `{"Role":0,"Content":[{"Type":2,"Text":"q"}]}`,
"", "2026-06-15T10:00:00Z"},
{2, "agent", `{"Role":1,"Content":[{"Type":2,"Text":"answer"}]}`,
"", "2026-06-15T10:00:10Z"},
})
engine, database := newShelleyEngine(t, dir)
require.False(t, engine.SyncAll(context.Background(), nil).Aborted)
before, err := database.GetSessionFull(
context.Background(), "shelley:cMAIN1",
)
require.NoError(t, err, "GetSessionFull before metadata rewrite")
require.NotNil(t, before, "session before metadata rewrite")
require.NotNil(t, before.SessionName, "session_name before rewrite")
require.NotNil(t, before.FileMtime, "file_mtime before rewrite")
require.NotNil(t, before.FileHash, "file_hash before rewrite")
assert.Equal(t, "main", *before.SessionName)
assert.Equal(t, "app", before.Project)
conn, err := sql.Open("sqlite3", dbPath)
require.NoError(t, err)
_, err = conn.Exec(
`UPDATE conversations
SET slug = 'renamed',
cwd = '/home/u/dev/renamed-app'
WHERE conversation_id = 'cMAIN1'`,
)
require.NoError(t, err)
require.NoError(t, conn.Close())
require.False(t, engine.SyncAll(context.Background(), nil).Aborted)
after, err := database.GetSessionFull(
context.Background(), "shelley:cMAIN1",
)
require.NoError(t, err, "GetSessionFull after metadata rewrite")
require.NotNil(t, after, "session after metadata rewrite")
require.NotNil(t, after.SessionName, "session_name after rewrite")
require.NotNil(t, after.FileMtime, "file_mtime after rewrite")
require.NotNil(t, after.FileHash, "file_hash after rewrite")
assert.Equal(t, *before.FileMtime, *after.FileMtime,
"same-second metadata rewrite keeps the real updated_at timestamp")
assert.NotEqual(t, *before.FileHash, *after.FileHash,
"metadata rewrite must change the Shelley fingerprint")
assert.Equal(t, "renamed", *after.SessionName)
assert.Equal(t, "renamed_app", after.Project)
}
// TestSyncShelleyLengthPreservingRewrite is the case a byte-length signal
// cannot catch: a same-second in-place edit that changes content while
// keeping the exact byte length (and sequence_id and updated_at). Only a
// real content digest detects it, so the re-sync must replace the stored
// transcript rather than skip it.
func TestSyncShelleyLengthPreservingRewrite(t *testing.T) {
dir := t.TempDir()
dbPath := createShelleyDB(t, dir)
seedShelleyConvo(t, dbPath, "cMAIN1", "main", "/home/u/dev/app",
"claude-sonnet-4-6", "", true,
"2026-06-15T10:00:00Z", "2026-06-15T10:00:10Z", []shelleyMsg{
{1, "user", `{"Role":0,"Content":[{"Type":2,"Text":"q"}]}`,
"", "2026-06-15T10:00:00Z"},
{2, "agent", `{"Role":1,"Content":[{"Type":2,"Text":"answer aaaa"}]}`,
"", "2026-06-15T10:00:10Z"},
})
engine, database := newShelleyEngine(t, dir)
require.False(t, engine.SyncAll(context.Background(), nil).Aborted)
assertMessageContent(t, database, "shelley:cMAIN1", "q", "answer aaaa")
// Same byte length, different content. updated_at and sequence_id are
// untouched, so a byte-length signal would skip this as unchanged.
conn, err := sql.Open("sqlite3", dbPath)
require.NoError(t, err)
_, err = conn.Exec(
`UPDATE messages SET llm_data = ? WHERE conversation_id = 'cMAIN1' AND sequence_id = 2`,
`{"Role":1,"Content":[{"Type":2,"Text":"answer bbbb"}]}`,
)
require.NoError(t, err)
require.NoError(t, conn.Close())
require.False(t, engine.SyncAll(context.Background(), nil).Aborted)
assertSessionMessageCount(t, database, "shelley:cMAIN1", 2)
assertMessageContent(t, database, "shelley:cMAIN1", "q", "answer bbbb")
}