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
283 lines
7.7 KiB
Go
283 lines
7.7 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func diffTestMsg(
|
|
sessionID string, ord int, role, content string,
|
|
mut ...func(*Message),
|
|
) Message {
|
|
m := Message{
|
|
SessionID: sessionID,
|
|
Ordinal: ord,
|
|
Role: role,
|
|
Content: content,
|
|
Timestamp: "2026-06-20T10:00:00Z",
|
|
ContentLength: len(content),
|
|
}
|
|
for _, f := range mut {
|
|
f(&m)
|
|
}
|
|
return m
|
|
}
|
|
|
|
func seedDiffSession(
|
|
t *testing.T, d *DB, sessionID string, msgs []Message,
|
|
) {
|
|
t.Helper()
|
|
require.NoError(t, d.UpsertSession(Session{
|
|
ID: sessionID,
|
|
Project: "proj",
|
|
Machine: defaultMachine,
|
|
Agent: defaultAgent,
|
|
}), "seed session %s", sessionID)
|
|
require.NoError(t, d.InsertMessages(msgs),
|
|
"seed messages for %s", sessionID)
|
|
}
|
|
|
|
func messageIDsByOrdinal(
|
|
t *testing.T, d *DB, sessionID string,
|
|
) map[int]int64 {
|
|
t.Helper()
|
|
rows, err := d.getReader().Query(
|
|
"SELECT ordinal, id FROM messages WHERE session_id = ?",
|
|
sessionID,
|
|
)
|
|
require.NoError(t, err)
|
|
defer rows.Close()
|
|
out := make(map[int]int64)
|
|
for rows.Next() {
|
|
var ord int
|
|
var id int64
|
|
require.NoError(t, rows.Scan(&ord, &id))
|
|
out[ord] = id
|
|
}
|
|
require.NoError(t, rows.Err())
|
|
return out
|
|
}
|
|
|
|
// TestReplaceSessionMessagesUpdatesChangedRowsInPlace verifies the
|
|
// streaming chunk-merge shape: a replace whose new message set only
|
|
// extends the stored tail and appends must keep the rowids of every
|
|
// stored message — unchanged rows untouched, the merged tail updated
|
|
// in place — instead of delete+reinserting the whole session.
|
|
func TestReplaceSessionMessagesUpdatesChangedRowsInPlace(t *testing.T) {
|
|
d := testDB(t)
|
|
|
|
v1 := []Message{
|
|
diffTestMsg("diff-a", 0, "user", "hello there"),
|
|
diffTestMsg("diff-a", 1, "assistant", "partial chunk",
|
|
func(m *Message) {
|
|
m.ClaudeMessageID = "m1"
|
|
m.ToolCalls = []ToolCall{{
|
|
ToolName: "Bash",
|
|
Category: "execution",
|
|
ResultEvents: []ToolResultEvent{{
|
|
Source: "result", Status: "ok",
|
|
Content: "one",
|
|
}},
|
|
}}
|
|
}),
|
|
}
|
|
seedDiffSession(t, d, "diff-a", v1)
|
|
// A second session claims MAX(id), so a delete+reinsert of
|
|
// diff-a would visibly reassign its rowids.
|
|
seedDiffSession(t, d, "diff-b", []Message{
|
|
diffTestMsg("diff-b", 0, "user", "other session"),
|
|
})
|
|
before := messageIDsByOrdinal(t, d, "diff-a")
|
|
require.Len(t, before, 2)
|
|
|
|
v2 := []Message{
|
|
v1[0],
|
|
diffTestMsg("diff-a", 1, "assistant",
|
|
"partial chunk plus merged tail zqmergetoken",
|
|
func(m *Message) {
|
|
m.ClaudeMessageID = "m1"
|
|
m.ToolCalls = v1[1].ToolCalls
|
|
}),
|
|
diffTestMsg("diff-a", 2, "assistant", "follow-up"),
|
|
}
|
|
require.NoError(t, d.ReplaceSessionMessages("diff-a", v2))
|
|
|
|
after := messageIDsByOrdinal(t, d, "diff-a")
|
|
require.Len(t, after, 3)
|
|
assert.Equal(t, before[0], after[0],
|
|
"unchanged row must keep its rowid")
|
|
assert.Equal(t, before[1], after[1],
|
|
"merged tail row must be updated in place, not reinserted")
|
|
|
|
msgs, err := d.GetAllMessages(context.Background(), "diff-a")
|
|
require.NoError(t, err)
|
|
require.Len(t, msgs, 3)
|
|
assert.Contains(t, msgs[1].Content, "zqmergetoken",
|
|
"merged content must be persisted")
|
|
|
|
if d.HasFTS() {
|
|
var n int
|
|
require.NoError(t, d.getReader().QueryRow(
|
|
`SELECT count(*) FROM messages_fts
|
|
WHERE messages_fts MATCH 'zqmergetoken'`,
|
|
).Scan(&n))
|
|
assert.Equal(t, 1, n,
|
|
"FTS index must cover the updated row content")
|
|
}
|
|
}
|
|
|
|
// TestReplaceSessionMessagesDiffMatchesFullReplace checks that the
|
|
// stored state after replacing v1 with v2 is identical (modulo
|
|
// rowids) to inserting v2 from scratch, across shapes that exercise
|
|
// the in-place diff, the append path, and the full-replace
|
|
// fallbacks (truncation, wholesale rewrites).
|
|
func TestReplaceSessionMessagesDiffMatchesFullReplace(t *testing.T) {
|
|
base := func(sid string) []Message {
|
|
return []Message{
|
|
diffTestMsg(sid, 0, "user", "question",
|
|
func(m *Message) {
|
|
m.ToolCalls = []ToolCall{{
|
|
ToolName: "Read",
|
|
Category: "file",
|
|
InputJSON: `{"path":"a.go"}`,
|
|
}}
|
|
}),
|
|
diffTestMsg(sid, 1, "assistant", "partial answer",
|
|
func(m *Message) {
|
|
m.ClaudeMessageID = "m1"
|
|
m.ContextTokens = 100
|
|
m.HasContextTokens = true
|
|
m.ToolCalls = []ToolCall{{
|
|
ToolName: "Task",
|
|
Category: "agent",
|
|
ToolUseID: "tu1",
|
|
ResultEvents: []ToolResultEvent{{
|
|
Source: "progress", Status: "running",
|
|
Content: "spawning",
|
|
}},
|
|
}}
|
|
}),
|
|
}
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
v2 func(sid string) []Message
|
|
}{
|
|
{"identical no-op", func(sid string) []Message {
|
|
return base(sid)
|
|
}},
|
|
{"chunk merge tail plus append", func(sid string) []Message {
|
|
msgs := base(sid)
|
|
msgs[1].Content = "partial answer now completed"
|
|
msgs[1].ContentLength = len(msgs[1].Content)
|
|
return append(msgs, diffTestMsg(sid, 2, "user", "thanks"))
|
|
}},
|
|
{"subagent linkage event appended", func(sid string) []Message {
|
|
msgs := base(sid)
|
|
tc := &msgs[1].ToolCalls[0]
|
|
tc.SubagentSessionID = "sub-1"
|
|
tc.ResultEvents = append(tc.ResultEvents, ToolResultEvent{
|
|
Source: "result", Status: "ok",
|
|
Content: "done", AgentID: "agent-9",
|
|
})
|
|
return msgs
|
|
}},
|
|
{"token fields updated", func(sid string) []Message {
|
|
msgs := base(sid)
|
|
msgs[1].OutputTokens = 42
|
|
msgs[1].HasOutputTokens = true
|
|
return msgs
|
|
}},
|
|
{"tool input changed", func(sid string) []Message {
|
|
msgs := base(sid)
|
|
msgs[0].ToolCalls[0].InputJSON = `{"path":"b.go"}`
|
|
return msgs
|
|
}},
|
|
{"truncated", func(sid string) []Message {
|
|
return base(sid)[:1]
|
|
}},
|
|
{"wholesale rewrite", func(sid string) []Message {
|
|
msgs := base(sid)
|
|
for i := range msgs {
|
|
msgs[i].Content = "rewritten " + msgs[i].Content
|
|
msgs[i].ContentLength = len(msgs[i].Content)
|
|
}
|
|
return msgs
|
|
}},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := testDB(t)
|
|
seedDiffSession(t, got, "par", base("par"))
|
|
require.NoError(t,
|
|
got.ReplaceSessionMessages("par", tc.v2("par")))
|
|
|
|
want := testDB(t)
|
|
seedDiffSession(t, want, "par", tc.v2("par"))
|
|
|
|
gotMsgs, err := got.GetAllMessages(
|
|
context.Background(), "par",
|
|
)
|
|
require.NoError(t, err)
|
|
wantMsgs, err := want.GetAllMessages(
|
|
context.Background(), "par",
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Equal(t,
|
|
stripRowIdentity(wantMsgs), stripRowIdentity(gotMsgs),
|
|
"replaced state must match a from-scratch insert")
|
|
})
|
|
}
|
|
}
|
|
|
|
// stripRowIdentity zeroes rowid-derived fields so state comparisons
|
|
// ignore legitimately different id allocations.
|
|
func stripRowIdentity(msgs []Message) []Message {
|
|
out := append([]Message(nil), msgs...)
|
|
for i := range out {
|
|
out[i].ID = 0
|
|
out[i].ToolCalls = append([]ToolCall(nil), out[i].ToolCalls...)
|
|
for j := range out[i].ToolCalls {
|
|
out[i].ToolCalls[j].MessageID = 0
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestReplaceSessionMessagesKeepsPinOnMergedRow guards pin survival
|
|
// through a chunk-merge replace: the pinned tail row is updated, not
|
|
// deleted, so its pin must remain.
|
|
func TestReplaceSessionMessagesKeepsPinOnMergedRow(t *testing.T) {
|
|
d := testDB(t)
|
|
v1 := []Message{
|
|
diffTestMsg("pin-s", 0, "user", "hello"),
|
|
diffTestMsg("pin-s", 1, "assistant", "partial"),
|
|
}
|
|
seedDiffSession(t, d, "pin-s", v1)
|
|
ids := messageIDsByOrdinal(t, d, "pin-s")
|
|
note := "keep me"
|
|
pinID, err := d.PinMessage("pin-s", ids[1], ¬e)
|
|
require.NoError(t, err)
|
|
require.NotZero(t, pinID)
|
|
|
|
v2 := []Message{
|
|
v1[0],
|
|
diffTestMsg("pin-s", 1, "assistant", "partial now complete"),
|
|
diffTestMsg("pin-s", 2, "user", "more"),
|
|
}
|
|
require.NoError(t, d.ReplaceSessionMessages("pin-s", v2))
|
|
|
|
var n int
|
|
require.NoError(t, d.getReader().QueryRow(
|
|
`SELECT count(*) FROM pinned_messages
|
|
WHERE session_id = 'pin-s' AND ordinal = 1 AND note = ?`,
|
|
note,
|
|
).Scan(&n))
|
|
assert.Equal(t, 1, n, "pin on the merged row must survive")
|
|
}
|