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
853 lines
26 KiB
Go
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)
|
|
}
|