Files
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

853 lines
26 KiB
Go

package db
import (
"context"
"database/sql"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const largeSessionPerfCeiling = 10 * time.Second
const crossSessionNeighborCount = 40
const crossSessionToolCallsPerNeighbor = 250
const crossSessionToolCallTotal = crossSessionNeighborCount * crossSessionToolCallsPerNeighbor
const largeSessionFixtureID = "large-session-fixture"
const largeSessionFixtureToken = "ftslargefixture"
const largeSessionNeighborPrefix = "large-session-neighbor"
var (
largeSessionTemplateBuildMu sync.Mutex
largeSessionOnlyOnce sync.Once
largeSessionOnlyDir string
largeSessionOnlyPath string
largeSessionPoisonOnce sync.Once
largeSessionPoisonDir string
largeSessionPoisonPath string
)
func largeSessionMessages(sessionID, blobToken string) []Message {
const n = 1000
msgs := make([]Message, 0, n)
for i := range n {
msgs = append(msgs, userMsg(sessionID, i, "small"))
}
big := strings.Repeat(blobToken+" ", 5*1024*1024/len(blobToken+" "))
msgs[n/2] = Message{
SessionID: sessionID,
Ordinal: n / 2,
Role: "assistant",
Content: big,
ContentLength: len(big),
Timestamp: tsZero,
}
return msgs
}
func openLargeSessionFixtureDB(t *testing.T, withFKPoison bool) *DB {
t.Helper()
var src string
if withFKPoison {
largeSessionPoisonOnce.Do(func() {
largeSessionPoisonDir, largeSessionPoisonPath =
buildLargeSessionFixtureTemplate(t, true)
})
src = largeSessionPoisonPath
} else {
largeSessionOnlyOnce.Do(func() {
largeSessionOnlyDir, largeSessionOnlyPath =
buildLargeSessionFixtureTemplate(t, false)
})
src = largeSessionOnlyPath
}
dst := filepath.Join(t.TempDir(), "test.db")
for _, suffix := range []string{"", "-wal", "-shm"} {
require.NoError(t,
copyTemplateDBFile(src+suffix, dst+suffix, suffix == ""),
"copy large-session fixture %q", suffix)
}
d, err := OpenPreparedTestDB(dst)
require.NoError(t, err, "open large-session fixture")
t.Cleanup(func() { require.NoError(t, d.Close()) })
return d
}
func buildLargeSessionFixtureTemplate(
t *testing.T, withFKPoison bool,
) (string, string) {
t.Helper()
largeSessionTemplateBuildMu.Lock()
defer largeSessionTemplateBuildMu.Unlock()
dir, err := os.MkdirTemp("", "agentsview-large-session-*")
require.NoError(t, err, "create large-session fixture dir")
path := filepath.Join(dir, "test.db")
require.NoError(t, copyTestDBTemplate(path),
"copy base db template for large-session fixture")
d, err := OpenPreparedTestDB(path)
require.NoError(t, err, "open large-session template")
insertSession(t, d, largeSessionFixtureID, "proj")
insertMessages(t, d,
largeSessionMessages(largeSessionFixtureID, largeSessionFixtureToken)...)
if withFKPoison {
seedCrossSessionFKGrowth(t, d, largeSessionNeighborPrefix)
poisonMessagesDeleteTrigger(t, d)
}
ctx, cancel := context.WithTimeout(context.Background(), largeSessionPerfCeiling)
defer cancel()
require.NoError(t, d.CheckpointWALTruncate(ctx),
"checkpoint large-session template")
require.NoError(t, d.Close(), "close large-session template")
return dir, path
}
func seedCrossSessionFKGrowth(t *testing.T, d *DB, sessionID string) {
t.Helper()
type neighborSeed struct {
sessionID string
messageID int64
}
seeds := make([]neighborSeed, 0, crossSessionNeighborCount)
for i := range crossSessionNeighborCount {
neighborID := sessionID + "-" + strconv.Itoa(i)
insertSession(t, d, neighborID, "proj")
insertMessages(t, d, userMsg(neighborID, 0, "neighbor"))
msgs, err := d.GetAllMessages(context.Background(), neighborID)
require.NoError(t, err, "GetAllMessages neighbor %s", neighborID)
require.Len(t, msgs, 1)
_, err = d.PinMessage(neighborID, msgs[0].ID, nil)
require.NoError(t, err, "PinMessage neighbor %s", neighborID)
seeds = append(seeds, neighborSeed{
sessionID: neighborID,
messageID: msgs[0].ID,
})
}
require.NoError(t, d.Update(func(tx *sql.Tx) error {
for _, seed := range seeds {
for i := range crossSessionToolCallsPerNeighbor {
if _, err := tx.Exec(
`INSERT INTO tool_calls
(message_id, session_id, tool_name, category, tool_use_id)
VALUES (?, ?, ?, ?, ?)`,
seed.messageID, seed.sessionID, "Read", "Read",
seed.sessionID+"-tool-"+strconv.Itoa(i),
); err != nil {
return err
}
}
}
return nil
}), "seed neighbor tool_calls")
}
func assertNoFTSLeak(t *testing.T, d *DB, token string) {
t.Helper()
var leaked int
err := d.getReader().QueryRow(
`SELECT count(*) FROM messages_fts
WHERE messages_fts MATCH ?`,
token,
).Scan(&leaked)
require.NoError(t, err, "fts leak check")
assert.Zero(t, leaked, "FTS still contains rows matching %q", token)
}
func poisonMessagesDeleteTrigger(t *testing.T, d *DB) {
t.Helper()
require.NoError(t, d.Update(func(tx *sql.Tx) error {
if _, err := tx.Exec("DROP TRIGGER IF EXISTS messages_ad"); err != nil {
return err
}
_, err := tx.Exec(`
CREATE TRIGGER messages_ad AFTER DELETE ON messages BEGIN
SELECT RAISE(FAIL, 'poison messages_ad fired');
END`)
return err
}), "poison messages_ad trigger")
}
func requireMessagesDeleteTriggerRestored(t *testing.T, d *DB) {
t.Helper()
var triggerSQL string
err := d.getReader().QueryRow(
`SELECT sql FROM sqlite_master
WHERE type = 'trigger' AND name = 'messages_ad'`,
).Scan(&triggerSQL)
require.NoError(t, err, "read messages_ad trigger")
assert.NotContains(t, triggerSQL, "poison messages_ad fired",
"messages_ad trigger was not restored")
assert.Contains(t, triggerSQL, "INSERT INTO messages_fts",
"messages_ad trigger no longer matches the canonical FTS delete path")
}
func requireMessagesDeleteTriggerPoisoned(t *testing.T, d *DB) {
t.Helper()
var triggerSQL string
err := d.getReader().QueryRow(
`SELECT sql FROM sqlite_master
WHERE type = 'trigger' AND name = 'messages_ad'`,
).Scan(&triggerSQL)
require.NoError(t, err, "read messages_ad trigger")
assert.Contains(t, triggerSQL, "poison messages_ad fired",
"fresh batch write should not touch the delete trigger")
}
func TestInsertAndGetMessage_ThinkingText(t *testing.T) {
t.Parallel()
d := testDB(t)
sessionID := "thinking-test"
insertSession(t, d, sessionID, "proj1")
insertMessages(t, d, Message{
SessionID: sessionID,
Ordinal: 0,
Role: "assistant",
Content: "the answer",
ThinkingText: "I am pondering",
})
got, err := d.GetAllMessages(context.Background(), sessionID)
require.NoError(t, err, "GetAllMessages")
require.Len(t, got, 1)
assert.Equal(t, "I am pondering", got[0].ThinkingText, "ThinkingText")
}
func TestWriteSessionBatchCommitsGoodRowsAndSkipsBadRows(t *testing.T) {
d := testDB(t)
require.NoError(t, d.Update(func(tx *sql.Tx) error {
_, err := tx.Exec(
"INSERT INTO excluded_sessions (id) VALUES (?)",
"excluded",
)
return err
}), "seed excluded session")
require.NoError(t, d.UpsertSession(Session{
ID: "trashed",
Project: "proj",
Machine: defaultMachine,
Agent: defaultAgent,
}), "seed trashed session")
require.NoError(t, d.SoftDeleteSession("trashed"), "soft delete session")
health := 95
grade := "A"
result, err := d.WriteSessionBatch([]SessionBatchWrite{
{
Session: Session{
ID: "good",
Project: "proj",
Machine: defaultMachine,
Agent: defaultAgent,
FirstMessage: new(string("hello")),
MessageCount: 2,
UserMessageCount: 1,
},
Messages: []Message{
userMsg("good", 0, "hello"),
{
SessionID: "good",
Ordinal: 1,
Role: "assistant",
Content: "answer",
ContentLength: 6,
ToolCalls: []ToolCall{{
ToolName: "Read",
Category: "Read",
ToolUseID: "toolu_1",
}},
},
},
Signals: SessionSignalUpdate{
Outcome: "success",
OutcomeConfidence: "high",
EndedWithRole: "assistant",
HealthScore: &health,
HealthGrade: &grade,
HasToolCalls: true,
},
DataVersion: CurrentDataVersion(),
},
{
Session: Session{
ID: "bad",
Project: "proj",
Machine: defaultMachine,
Agent: defaultAgent,
MessageCount: 1,
UserMessageCount: 1,
},
Messages: []Message{
userMsg("missing-session", 0, "broken"),
},
DataVersion: CurrentDataVersion(),
},
{
Session: Session{
ID: "excluded",
Project: "proj",
Machine: defaultMachine,
Agent: defaultAgent,
MessageCount: 1,
UserMessageCount: 1,
},
Messages: []Message{
userMsg("excluded", 0, "deleted"),
},
DataVersion: CurrentDataVersion(),
},
{
Session: Session{
ID: "trashed",
Project: "proj",
Machine: defaultMachine,
Agent: defaultAgent,
MessageCount: 1,
UserMessageCount: 1,
},
Messages: []Message{
userMsg("trashed", 0, "trashed"),
},
DataVersion: CurrentDataVersion(),
},
})
require.NoError(t, err, "WriteSessionBatch")
require.Equal(t, 1, result.WrittenSessions, "WrittenSessions")
require.Equal(t, 2, result.WrittenMessages, "WrittenMessages")
require.Equal(t, 1, result.FailedSessions, "FailedSessions")
require.Equal(t, 2, result.ExcludedSessions, "ExcludedSessions")
sess, err := d.GetSessionFull(context.Background(), "good")
require.NoError(t, err, "GetSessionFull good")
require.NotNil(t, sess, "good session not found")
assert.Equal(t, CurrentDataVersion(), sess.DataVersion, "DataVersion")
assert.Equal(t, "success", sess.Outcome, "Outcome")
assert.Equal(t, "high", sess.OutcomeConfidence, "OutcomeConfidence")
assert.True(t, sess.HasToolCalls, "HasToolCalls")
trashed, err := d.GetSessionFull(context.Background(), "trashed")
require.NoError(t, err, "GetSessionFull trashed")
require.NotNil(t, trashed, "trashed session was not preserved in trash")
assert.NotNil(t, trashed.DeletedAt, "trashed session was not preserved in trash")
msgs, err := d.GetAllMessages(context.Background(), "good")
require.NoError(t, err, "GetAllMessages good")
require.Len(t, msgs, 2)
require.Len(t, msgs[1].ToolCalls, 1, "assistant tool calls")
bad, err := d.GetSessionFull(context.Background(), "bad")
require.NoError(t, err, "GetSessionFull bad")
assert.Nil(t, bad, "bad session should have rolled back")
excluded, err := d.GetSessionFull(context.Background(), "excluded")
require.NoError(t, err, "GetSessionFull excluded")
assert.Nil(t, excluded, "excluded session should not be written")
}
func TestMigration_ThinkingTextColumn(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.db")
// Create a DB with the current schema then drop the
// thinking_text column to simulate a pre-migration DB.
d, err := Open(path)
require.NoError(t, err, "initial open")
insertSession(t, d, "s1", "proj")
insertMessages(t, d,
userMsg("s1", 0, "hello"),
Message{
SessionID: "s1",
Ordinal: 1,
Role: "assistant",
Content: "answer",
ThinkingText: "pre-migration thought",
},
)
d.Close()
// Remove thinking_text via ALTER TABLE DROP COLUMN
// (SQLite 3.35+) to simulate a legacy schema.
conn, err := sql.Open("sqlite3", path)
require.NoError(t, err, "raw open")
_, err = conn.Exec(
`ALTER TABLE messages DROP COLUMN thinking_text`,
)
require.NoError(t, err, "drop thinking_text column")
// Verify column is gone.
var count int
err = conn.QueryRow(
`SELECT count(*) FROM pragma_table_info('messages')` +
` WHERE name = 'thinking_text'`,
).Scan(&count)
require.NoError(t, err, "verify column removed")
require.Zero(t, count, "expected thinking_text column to be absent")
// Insert a legacy row with an explicit column list that
// cannot reference thinking_text (column doesn't exist yet).
_, err = conn.Exec(`
INSERT INTO messages (
session_id, ordinal, role, content, timestamp,
has_thinking, has_tool_use, content_length,
is_system, model, token_usage,
context_tokens, output_tokens,
has_context_tokens, has_output_tokens,
claude_message_id, claude_request_id,
source_type, source_subtype, source_uuid,
source_parent_uuid, is_sidechain,
is_compact_boundary
) VALUES (
's1', 2, 'user', 'legacy', '',
0, 0, 6,
0, '', '',
0, 0,
0, 0,
'', '',
'', '', '',
'', 0,
0
)`)
require.NoError(t, err, "insert legacy row")
conn.Close()
// Reopen with Open() — migration should add the column.
d2, err := Open(path)
require.NoError(t, err, "reopen after migration")
defer d2.Close()
// Verify column exists.
err = d2.getReader().QueryRow(
`SELECT count(*) FROM pragma_table_info('messages')` +
` WHERE name = 'thinking_text'`,
).Scan(&count)
require.NoError(t, err, "verify column added")
require.Equal(t, 1, count, "expected thinking_text column after migration")
// Verify all rows survive and the legacy row defaults to "".
msgs, err := d2.GetAllMessages(context.Background(), "s1")
require.NoError(t, err, "get messages")
require.Len(t, msgs, 3)
for _, m := range msgs {
assert.Empty(t, m.ThinkingText, "ord=%d ThinkingText", m.Ordinal)
}
// Insert a new message with ThinkingText and verify round-trip.
insertMessages(t, d2, Message{
SessionID: "s1",
Ordinal: 3,
Role: "assistant",
Content: "post-migration answer",
ThinkingText: "x",
})
msgs, err = d2.GetAllMessages(context.Background(), "s1")
require.NoError(t, err, "get messages after insert")
require.Len(t, msgs, 4)
assert.Equal(t, "x", msgs[3].ThinkingText, "ThinkingText")
}
// TestReplaceSessionMessages_LargeSession is a perf regression test
// for the FTS5 trigger-cascade hang fixed alongside the bulk-delete
// path in ReplaceSessionMessages. Before the fix, deleting a session
// whose messages contained multi-MB content blobs would fan out into
// per-row FTS 'delete' commands, each tokenizing the old content, and
// could stall the writer for minutes on real data. The bulk path
// makes the cost effectively flat regardless of blob size, so this
// test puts a hard 10s ceiling on the full replace cycle for a
// session that mixes 1000 small messages with one ~5MB content blob.
// Skipped under -short since a clean run is well under 1s but CI
// scheduling jitter can push slow paths up.
func TestReplaceSessionMessages_LargeSession(t *testing.T) {
if testing.Short() {
t.Skip("skipping perf test in -short mode")
}
t.Parallel()
d := openLargeSessionFixtureDB(t, false)
requireFTS(t, d)
// Replace with a different small set so the delete path has to
// remove all 1000 rows including the 5MB blob.
repl := make([]Message, 0, 10)
for i := range 10 {
repl = append(repl, userMsg(largeSessionFixtureID, i, "after"))
}
start := time.Now()
require.NoError(t, d.ReplaceSessionMessages(largeSessionFixtureID, repl),
"ReplaceSessionMessages")
elapsed := time.Since(start)
require.LessOrEqual(t, elapsed, largeSessionPerfCeiling,
"ReplaceSessionMessages took %s, want < 10s (per-row FTS trigger regression?)",
elapsed.Round(time.Millisecond))
got, err := d.GetAllMessages(context.Background(), largeSessionFixtureID)
require.NoError(t, err, "GetAllMessages after replace")
require.Len(t, got, len(repl), "after replace")
// Verify the FTS index was actually scrubbed: count rows in
// messages_fts that join back to the (now-deleted) original
// session rows. Should be zero. If the messages_ad trigger
// restoration failed silently or the bulk-delete INSERT...SELECT
// got skipped, stale tokens would still resolve here.
assertNoFTSLeak(t, d, largeSessionFixtureToken)
}
func TestWriteSessionBatch_ReplaceMessagesLargeSession(t *testing.T) {
if testing.Short() {
t.Skip("skipping perf test in -short mode")
}
t.Parallel()
d := openLargeSessionFixtureDB(t, true)
requireFTS(t, d)
repl := make([]Message, 0, 10)
for i := range 10 {
repl = append(repl, userMsg(largeSessionFixtureID, i, "after"))
}
start := time.Now()
result, err := d.WriteSessionBatch([]SessionBatchWrite{{
Session: Session{
ID: largeSessionFixtureID,
Project: "proj",
Machine: defaultMachine,
Agent: defaultAgent,
MessageCount: len(repl),
UserMessageCount: len(repl),
},
Messages: repl,
DataVersion: CurrentDataVersion(),
ReplaceMessages: true,
}})
require.NoError(t, err, "WriteSessionBatch")
elapsed := time.Since(start)
require.Equal(t, 1, result.WrittenSessions, "WrittenSessions")
require.Equal(t, len(repl), result.WrittenMessages, "WrittenMessages")
require.LessOrEqual(t, elapsed, largeSessionPerfCeiling,
"WriteSessionBatch replace took %s, want < 10s (per-row FTS trigger regression?)",
elapsed.Round(time.Millisecond))
got, err := d.GetAllMessages(context.Background(), largeSessionFixtureID)
require.NoError(t, err, "GetAllMessages after batch replace")
require.Len(t, got, len(repl), "after batch replace")
assertNoFTSLeak(t, d, largeSessionFixtureToken)
requireMessagesDeleteTriggerRestored(t, d)
var neighborToolCalls int
err = d.getReader().QueryRow(
"SELECT count(*) FROM tool_calls WHERE session_id LIKE ?",
largeSessionNeighborPrefix+"-%",
).Scan(&neighborToolCalls)
require.NoError(t, err, "neighbor tool_calls count")
assert.Equal(t, crossSessionToolCallTotal, neighborToolCalls,
"neighbor tool_calls count")
}
func TestWriteSessionBatchFreshReplaceMessagesSkipsDeletePath(t *testing.T) {
t.Parallel()
d := testDB(t)
requireFTS(t, d)
poisonMessagesDeleteTrigger(t, d)
const sessionID = "batch-fresh"
result, err := d.WriteSessionBatch([]SessionBatchWrite{{
Session: Session{
ID: sessionID,
Project: "proj",
Machine: defaultMachine,
Agent: defaultAgent,
MessageCount: 1,
UserMessageCount: 1,
},
Messages: []Message{
userMsg(sessionID, 0, "fresh"),
},
DataVersion: CurrentDataVersion(),
ReplaceMessages: true,
}})
require.NoError(t, err, "WriteSessionBatch")
assert.Equal(t, 1, result.WrittenSessions, "WrittenSessions")
assert.Equal(t, 1, result.WrittenMessages, "WrittenMessages")
requireMessagesDeleteTriggerPoisoned(t, d)
}
func TestWriteSessionBatchReplaceMessagesOnlyBumpsChangedTranscript(t *testing.T) {
t.Parallel()
d := testDB(t)
const sessionID = "batch-revision"
insertSession(t, d, sessionID, "proj")
original := Message{
SessionID: sessionID,
Ordinal: 0,
Role: "assistant",
Content: "same answer",
ContentLength: len("same answer"),
ToolCalls: []ToolCall{{
ToolName: "Read",
Category: "file",
ToolUseID: "toolu_1",
InputJSON: `{"path":"same"}`,
ResultContent: "same result",
ResultEvents: []ToolResultEvent{{
Status: "completed",
Content: "same result",
}},
}},
}
insertMessages(t, d, original)
_, err := d.getWriter().Exec(
`UPDATE sessions SET transcript_revision = '7' WHERE id = ?`,
sessionID,
)
require.NoError(t, err)
write := SessionBatchWrite{
Session: Session{
ID: sessionID,
Project: "proj",
Machine: defaultMachine,
Agent: defaultAgent,
MessageCount: 1,
},
Messages: []Message{original},
ReplaceMessages: true,
}
_, err = d.WriteSessionBatch([]SessionBatchWrite{write})
require.NoError(t, err)
unchanged, err := d.GetSession(context.Background(), sessionID)
require.NoError(t, err)
require.NotNil(t, unchanged)
require.NotNil(t, unchanged.TranscriptRevision)
assert.Equal(t, "7", *unchanged.TranscriptRevision)
write.Messages[0].ContentLength = 999
write.Messages[0].ToolCalls[0].ResultContentLength = 999
write.Messages[0].ToolCalls[0].ResultEvents[0].ContentLength = 999
_, err = d.WriteSessionBatch([]SessionBatchWrite{write})
require.NoError(t, err)
recalculated, err := d.GetSession(context.Background(), sessionID)
require.NoError(t, err)
require.NotNil(t, recalculated)
require.NotNil(t, recalculated.TranscriptRevision)
assert.Equal(t, "7", *recalculated.TranscriptRevision)
write.Messages[0].ToolCalls[0].ResultContent = "changed result"
_, err = d.WriteSessionBatch([]SessionBatchWrite{write})
require.NoError(t, err)
changed, err := d.GetSession(context.Background(), sessionID)
require.NoError(t, err)
require.NotNil(t, changed)
require.NotNil(t, changed.TranscriptRevision)
assert.Equal(t, "8", *changed.TranscriptRevision)
write.ReplaceMessages = false
_, err = d.WriteSessionBatch([]SessionBatchWrite{write})
require.NoError(t, err)
replayed, err := d.GetSession(context.Background(), sessionID)
require.NoError(t, err)
require.NotNil(t, replayed)
require.NotNil(t, replayed.TranscriptRevision)
assert.Equal(t, "8", *replayed.TranscriptRevision)
}
func TestReplacementAPIsOnlyBumpVisibleTranscriptChanges(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
replace func(*DB, string, []Message) error
}{
{
name: "messages",
replace: func(d *DB, id string, msgs []Message) error {
return d.ReplaceSessionMessages(id, msgs)
},
},
{
name: "content",
replace: func(d *DB, id string, msgs []Message) error {
return d.ReplaceSessionContent(
id, msgs, SessionSignalUpdate{}, nil,
)
},
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
d := testDB(t)
messageSessionID := "replacement-" + tc.name
insertSession(t, d, messageSessionID, "proj")
original := Message{
SessionID: messageSessionID,
Ordinal: 0,
Role: "assistant",
Content: "same answer",
ContentLength: len("same answer"),
}
insertMessages(t, d, original)
_, err := d.getWriter().Exec(
`UPDATE sessions SET transcript_revision = '7' WHERE id = ?`,
messageSessionID,
)
require.NoError(t, err)
derivedOnly := original
derivedOnly.ContentLength = 999
require.NoError(t, tc.replace(
d, messageSessionID, []Message{derivedOnly},
))
unchanged, err := d.GetSession(
context.Background(), messageSessionID,
)
require.NoError(t, err)
require.NotNil(t, unchanged)
require.NotNil(t, unchanged.TranscriptRevision)
assert.Equal(t, "7", *unchanged.TranscriptRevision)
visibleChange := derivedOnly
visibleChange.Content = "changed answer"
require.NoError(t, tc.replace(
d, messageSessionID, []Message{visibleChange},
))
changed, err := d.GetSession(
context.Background(), messageSessionID,
)
require.NoError(t, err)
require.NotNil(t, changed)
require.NotNil(t, changed.TranscriptRevision)
assert.Equal(t, "8", *changed.TranscriptRevision)
emptySessionID := "empty-replacement-" + tc.name
insertSession(t, d, emptySessionID, "proj")
_, err = d.getWriter().Exec(
`UPDATE sessions SET transcript_revision = '3' WHERE id = ?`,
emptySessionID,
)
require.NoError(t, err)
require.NoError(t, tc.replace(d, emptySessionID, nil))
empty, err := d.GetSession(
context.Background(), emptySessionID,
)
require.NoError(t, err)
require.NotNil(t, empty)
require.NotNil(t, empty.TranscriptRevision)
assert.Equal(t, "3", *empty.TranscriptRevision)
})
}
}
// TestMessageReadsTolerateNullTimestamp pins NULL-timestamp robustness
// across the three message read paths. timestamp is the only nullable
// text column in the messages table; fresh inserts always bind a Go
// string (never NULL), so a NULL only reaches a row via an imported or
// migrated archive. Before the COALESCE guard such a row made rows.Scan
// fail with "converting NULL to string is unsupported", which aborted
// the parse-diff run (MessageRoleTimeFingerprint) and broke ordinary
// reads (GetAllMessages, GetMessageByOrdinal).
func TestMessageReadsTolerateNullTimestamp(t *testing.T) {
t.Parallel()
d := testDB(t)
insertSession(t, d, "null-ts", "proj1")
insertMessages(t, d,
userMsgAt("null-ts", 0, "hello", "2024-01-01T10:00:00Z"),
asstMsgAt("null-ts", 1, "hi there", "2024-01-01T10:00:05Z"),
)
nullOrdinal1 := func() {
require.NoError(t, d.Update(func(tx *sql.Tx) error {
_, err := tx.Exec(
"UPDATE messages SET timestamp = NULL"+
" WHERE session_id = ? AND ordinal = ?", "null-ts", 1)
return err
}), "null the stored timestamp")
}
// Tier-1 fingerprint: must not error, and a NULL must fingerprint
// identically to an empty string so the report cannot distinguish
// the two. InsertMessages binds a Go string, so reach past it with
// raw SQL to plant the NULL.
nullOrdinal1()
fpNull, err := d.MessageRoleTimeFingerprint("null-ts")
require.NoError(t, err, "fingerprint over NULL timestamp")
require.NoError(t, d.Update(func(tx *sql.Tx) error {
_, err := tx.Exec(
"UPDATE messages SET timestamp = ''"+
" WHERE session_id = ? AND ordinal = ?", "null-ts", 1)
return err
}), "set the stored timestamp to empty string")
fpEmpty, err := d.MessageRoleTimeFingerprint("null-ts")
require.NoError(t, err, "fingerprint over empty timestamp")
assert.Equal(t, fpEmpty, fpNull,
"NULL timestamp must fingerprint identically to empty string")
// Tier-2 batch read path (scanMessages via selectMessageCols).
nullOrdinal1()
msgs, err := d.GetAllMessages(context.Background(), "null-ts")
require.NoError(t, err, "GetAllMessages over NULL timestamp")
require.Len(t, msgs, 2)
assert.Equal(t, "2024-01-01T10:00:00Z", msgs[0].Timestamp)
assert.Equal(t, "", msgs[1].Timestamp,
"NULL timestamp reads as empty string")
// Single-row read path (GetMessageByOrdinal via selectMessageCols).
m, err := d.GetMessageByOrdinal("null-ts", 1)
require.NoError(t, err, "GetMessageByOrdinal over NULL timestamp")
require.NotNil(t, m)
assert.Equal(t, "", m.Timestamp,
"NULL timestamp reads as empty string")
}
func TestToolCallFilePathCallIndexRoundTrip(t *testing.T) {
t.Parallel()
d := testDB(t)
ctx := context.Background()
insertSession(t, d, "sess-1", "proj")
insertMessages(t, d, userMsg("sess-1", 0, "hello"))
// Fetch the message id assigned by the DB.
var msgID int64
require.NoError(t, d.getReader().QueryRowContext(ctx,
`SELECT id FROM messages WHERE session_id = 'sess-1' AND ordinal = 0`,
).Scan(&msgID))
tx, err := d.getWriter().Begin()
require.NoError(t, err, "begin tx")
err = insertToolCallsChunkTx(tx, []ToolCall{
{
MessageID: msgID, SessionID: "sess-1",
ToolName: "Edit", Category: "Edit",
ToolUseID: "tu1", InputJSON: `{"file_path":"/a/b.go"}`,
FilePath: "/a/b.go", CallIndex: 0,
},
{
MessageID: msgID, SessionID: "sess-1",
ToolName: "Write", Category: "Write",
ToolUseID: "tu2", InputJSON: `{"file":"/c/d.go"}`,
FilePath: "/c/d.go", CallIndex: 1,
},
})
require.NoError(t, err, "insertToolCallsChunkTx")
require.NoError(t, tx.Commit(), "commit")
var fp string
var ci int
require.NoError(t, d.getReader().QueryRowContext(ctx,
`SELECT file_path, call_index FROM tool_calls
WHERE tool_use_id = 'tu2'`).Scan(&fp, &ci))
assert.Equal(t, "/c/d.go", fp)
assert.Equal(t, 1, ci)
}