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
921 lines
36 KiB
Go
921 lines
36 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"unicode/utf8"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// seedSearchSession inserts one session with the given messages
|
|
// (role/content pairs) for content-search tests.
|
|
func seedSearchSession(t *testing.T, d *DB, id, project string, msgs [][2]string) {
|
|
t.Helper()
|
|
// UserMessageCount > 1 so the session is not treated as one-shot and
|
|
// excluded by the default session-list-parity filter.
|
|
insertSession(t, d, id, project, func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.UserMessageCount = 2
|
|
})
|
|
var out []Message
|
|
for i, rc := range msgs {
|
|
out = append(out, Message{
|
|
SessionID: id, Ordinal: i, Role: rc[0],
|
|
Content: rc[1], Timestamp: "2026-05-20T12:00:0" + itoa(i) + "Z",
|
|
})
|
|
}
|
|
require.NoError(t, d.ReplaceSessionMessages(id, out), "ReplaceSessionMessages")
|
|
}
|
|
|
|
func TestSearchContentSubstringMessages(t *testing.T) {
|
|
d := testDB(t)
|
|
seedSearchSession(t, d, "s1", "proj", [][2]string{
|
|
{"user", "please find the DATABASE_URL value"},
|
|
{"assistant", "sure, here is the answer"},
|
|
})
|
|
got, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "database_url", Mode: "substring",
|
|
Sources: []string{"messages"}, Limit: 50,
|
|
})
|
|
require.NoError(t, err, "SearchContent")
|
|
require.Len(t, got.Matches, 1, "matches")
|
|
m := got.Matches[0]
|
|
assert.Equal(t, "s1", m.SessionID, "SessionID")
|
|
assert.Equal(t, "message", m.Location, "Location")
|
|
assert.Equal(t, 0, m.Ordinal, "Ordinal")
|
|
assert.Equal(t, "user", m.Role, "Role")
|
|
assert.Contains(t, m.Snippet, "DATABASE_URL", "snippet")
|
|
}
|
|
|
|
// TestSearchContentRedactsStraddlingSecret pins the default (non-reveal)
|
|
// content-search guarantee: a secret adjacent to the match that extends past
|
|
// the snippet window must not leak. A snippet-only redaction would cut the PEM
|
|
// block short and ship raw key bytes.
|
|
func TestSearchContentRedactsStraddlingSecret(t *testing.T) {
|
|
d := testDB(t)
|
|
pem := "-----BEGIN RSA PRIVATE KEY-----\n" +
|
|
strings.Repeat("MIIBSECRETKEYMATERIAL0123456789ABCDEF\n", 5) +
|
|
"-----END RSA PRIVATE KEY-----"
|
|
seedSearchSession(t, d, "s1", "proj", [][2]string{
|
|
{"user", "deploy with this attached key " + pem + " ok"},
|
|
{"assistant", "done"},
|
|
})
|
|
base := ContentSearchFilter{
|
|
Pattern: "attached key", Mode: "substring",
|
|
Sources: []string{"messages"}, Limit: 50,
|
|
}
|
|
got, err := d.SearchContent(context.Background(), base)
|
|
require.NoError(t, err, "SearchContent")
|
|
require.Len(t, got.Matches, 1)
|
|
assert.NotContains(t, got.Matches[0].Snippet, "SECRETKEYMATERIAL",
|
|
"default snippet leaked key material")
|
|
assert.Contains(t, got.Matches[0].Snippet, "attached key",
|
|
"snippet lost the matched context")
|
|
|
|
// Reveal opts out of redaction (localhost-gated upstream): raw bytes show.
|
|
base.RevealSecrets = true
|
|
rev, err := d.SearchContent(context.Background(), base)
|
|
require.NoError(t, err, "SearchContent reveal")
|
|
assert.Contains(t, rev.Matches[0].Snippet, "SECRETKEYMATERIAL",
|
|
"reveal snippet should show raw bytes")
|
|
}
|
|
|
|
// TestCaseInsensitiveIndexUnicodeOffset pins that the returned offset indexes
|
|
// the original string, not strings.ToLower(s). The Kelvin sign U+212A is three
|
|
// bytes but lowercases to one ('k'), so a ToLower-based index would report a
|
|
// byte offset shifted left of the real match position.
|
|
func TestCaseInsensitiveIndexUnicodeOffset(t *testing.T) {
|
|
body := strings.Repeat("K", 5) + "match here"
|
|
got := CaseInsensitiveIndex(body, "MATCH")
|
|
want := strings.Index(body, "match") // real offset into the original string
|
|
assert.Equal(t, want, got,
|
|
"CaseInsensitiveIndex offset into original body")
|
|
}
|
|
|
|
// TestSubstringSnippetUnicodeOffset guards against the snippet panic and
|
|
// mis-centering when lowercasing changes byte length. U+023A lowercases to the
|
|
// 3-byte U+2C65, so a ToLower-derived offset runs past the original bounds and
|
|
// slicing panics; the offset-preserving search must center on the real match.
|
|
func TestSubstringSnippetUnicodeOffset(t *testing.T) {
|
|
pat := "MATCH"
|
|
body := strings.Repeat("Ⱥ", 100) + pat + " trailing context here"
|
|
f := ContentSearchFilter{Pattern: pat, Mode: "substring"}
|
|
got := f.substringSnippet(body) // must not panic
|
|
assert.Contains(t, got, pat, "snippet did not center on the match")
|
|
}
|
|
|
|
func TestSearchContentToolIO(t *testing.T) {
|
|
d := testDB(t)
|
|
insertSession(t, d, "s2", "proj", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.UserMessageCount = 2
|
|
})
|
|
msgs := []Message{{
|
|
SessionID: "s2", Ordinal: 0, Role: "assistant", Content: "running it",
|
|
Timestamp: "2026-05-20T12:00:00Z",
|
|
ToolCalls: []ToolCall{{
|
|
ToolName: "Bash", Category: "Bash", ToolUseID: "tu1",
|
|
InputJSON: `{"command":"printenv"}`,
|
|
ResultContent: "AWS_SECRET=topsecretvalue123",
|
|
}},
|
|
}}
|
|
require.NoError(t, d.ReplaceSessionMessages("s2", msgs),
|
|
"ReplaceSessionMessages")
|
|
// match in tool input
|
|
in, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "printenv", Mode: "substring",
|
|
Sources: []string{"tool_input"}, Limit: 50,
|
|
})
|
|
require.NoError(t, err, "tool_input search")
|
|
require.Len(t, in.Matches, 1, "tool_input search")
|
|
require.Equal(t, "tool_input", in.Matches[0].Location, "Location")
|
|
assert.Equal(t, "Bash", in.Matches[0].ToolName, "ToolName")
|
|
// match in tool result
|
|
res, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "topsecretvalue", Mode: "substring",
|
|
Sources: []string{"tool_result"}, Limit: 50,
|
|
})
|
|
require.NoError(t, err, "tool_result search")
|
|
require.Len(t, res.Matches, 1, "tool_result search")
|
|
assert.Equal(t, "tool_result", res.Matches[0].Location, "Location")
|
|
}
|
|
|
|
// TestSearchContentEmptyToolUseIDNotSuppressed guards the tool-result dedup:
|
|
// when one empty-tool_use_id call has a result event, it must not suppress the
|
|
// result_content of a different empty-tool_use_id call. The dedup is keyed on
|
|
// tool_use_id, so one empty ID matching another would hide the second result.
|
|
func TestSearchContentEmptyToolUseIDNotSuppressed(t *testing.T) {
|
|
d := testDB(t)
|
|
insertSession(t, d, "empti", "proj", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.UserMessageCount = 2
|
|
})
|
|
msgs := []Message{{
|
|
SessionID: "empti", Ordinal: 0, Role: "assistant",
|
|
Content: "running tools", Timestamp: "2026-05-20T12:00:00Z",
|
|
ToolCalls: []ToolCall{
|
|
{ // empty tool_use_id, result only in result_content, no events
|
|
ToolName: "Bash", Category: "Bash", ToolUseID: "",
|
|
InputJSON: `{"command":"a"}`, ResultContent: "FINDA in result",
|
|
},
|
|
{ // empty tool_use_id, result delivered as an event
|
|
ToolName: "Bash", Category: "Bash", ToolUseID: "",
|
|
InputJSON: `{"command":"b"}`,
|
|
ResultEvents: []ToolResultEvent{
|
|
{Source: "stdout", Status: "ok", Content: "FINDB event"},
|
|
},
|
|
},
|
|
},
|
|
}}
|
|
require.NoError(t, d.ReplaceSessionMessages("empti", msgs),
|
|
"ReplaceSessionMessages")
|
|
// ReplaceSessionMessages routes empty ToolUseID through nilIfEmpty so
|
|
// it lands as NULL. NULL = NULL is false in SQL, so the dedup bug we
|
|
// want to pin (an empty string matching another empty string) only
|
|
// triggers when the column actually holds ''. Force both rows to the
|
|
// literal empty-string form here so the test fails on the old buggy
|
|
// query.
|
|
for _, sql := range []string{
|
|
"UPDATE tool_calls SET tool_use_id = '' WHERE session_id = 'empti'",
|
|
"UPDATE tool_result_events SET tool_use_id = '' WHERE session_id = 'empti'",
|
|
} {
|
|
_, err := d.getWriter().Exec(sql)
|
|
require.NoError(t, err, "force empty tool_use_id")
|
|
}
|
|
for _, mode := range []string{"substring", "regex"} {
|
|
got, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "FINDA", Mode: mode,
|
|
Sources: []string{"tool_result"}, Limit: 50,
|
|
})
|
|
require.NoError(t, err, "SearchContent %s", mode)
|
|
require.Len(t, got.Matches, 1,
|
|
"%s: empty-ID result_content suppressed", mode)
|
|
assert.Equal(t, "tool_result", got.Matches[0].Location,
|
|
"%s: want 1 tool_result", mode)
|
|
}
|
|
// The event-delivered result is still searchable via the events branch.
|
|
ev, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "FINDB", Mode: "substring",
|
|
Sources: []string{"tool_result"}, Limit: 50,
|
|
})
|
|
require.NoError(t, err, "SearchContent events")
|
|
assert.Len(t, ev.Matches, 1, "event content not found")
|
|
}
|
|
|
|
// TestSearchContentPaginationStableAcrossTies seeds one message ordinal that
|
|
// produces three hits tying on (session, ordinal) — the message body, the tool
|
|
// input, and the tool result. Without a stable tie-break, OFFSET paging over
|
|
// the UNION could duplicate or skip these rows; the src/row_id keys make
|
|
// page-by-page retrieval reproduce the single-page order exactly.
|
|
func TestSearchContentPaginationStableAcrossTies(t *testing.T) {
|
|
d := testDB(t)
|
|
insertSession(t, d, "tie", "proj", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.UserMessageCount = 2
|
|
})
|
|
msgs := []Message{{
|
|
SessionID: "tie", Ordinal: 0, Role: "assistant",
|
|
Content: "FINDME in message body",
|
|
Timestamp: "2026-05-20T12:00:00Z",
|
|
ToolCalls: []ToolCall{{
|
|
ToolName: "Bash", Category: "Bash", ToolUseID: "tu1",
|
|
InputJSON: `{"command":"FINDME"}`,
|
|
ResultContent: "FINDME in result",
|
|
}},
|
|
}}
|
|
require.NoError(t, d.ReplaceSessionMessages("tie", msgs),
|
|
"ReplaceSessionMessages")
|
|
base := ContentSearchFilter{
|
|
Pattern: "FINDME", Mode: "substring",
|
|
Sources: []string{"messages", "tool_input", "tool_result"},
|
|
}
|
|
full := base
|
|
full.Limit = 50
|
|
all, err := d.SearchContent(context.Background(), full)
|
|
require.NoError(t, err, "SearchContent full")
|
|
require.Len(t, all.Matches, 3, "tied matches")
|
|
// The tie-break orders the three sources deterministically by source rank.
|
|
wantOrder := []string{"message", "tool_input", "tool_result"}
|
|
for i, loc := range wantOrder {
|
|
assert.Equal(t, loc, all.Matches[i].Location, "match %d Location", i)
|
|
}
|
|
// Page one row at a time; the sequence must equal the single-page order.
|
|
var paged []ContentMatch
|
|
for cursor := 0; ; {
|
|
p := base
|
|
p.Limit = 1
|
|
p.Cursor = cursor
|
|
page, err := d.SearchContent(context.Background(), p)
|
|
require.NoError(t, err, "SearchContent page at cursor %d", cursor)
|
|
paged = append(paged, page.Matches...)
|
|
if page.NextCursor == 0 {
|
|
break
|
|
}
|
|
cursor = page.NextCursor
|
|
}
|
|
require.Len(t, paged, len(all.Matches),
|
|
"paged rows (duplicates or gaps)")
|
|
for i := range all.Matches {
|
|
assert.Equal(t, all.Matches[i].Location, paged[i].Location,
|
|
"row %d: paged Location != single-page", i)
|
|
}
|
|
}
|
|
|
|
func TestSearchContentRegex(t *testing.T) {
|
|
d := testDB(t)
|
|
seedSearchSession(t, d, "r1", "proj", [][2]string{
|
|
{"user", "key AKIA" + "7QHWN2DKR4FYPLJM here"},
|
|
{"assistant", "no secrets in this line"},
|
|
})
|
|
got, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: `AKIA[0-9A-Z]{16}`, Mode: "regex",
|
|
Sources: []string{"messages"}, Limit: 50,
|
|
})
|
|
require.NoError(t, err, "SearchContent regex")
|
|
require.Len(t, got.Matches, 1, "regex match")
|
|
assert.Equal(t, 0, got.Matches[0].Ordinal, "regex match ordinal")
|
|
}
|
|
|
|
func TestSearchContentUnknownSource(t *testing.T) {
|
|
d := testDB(t)
|
|
_, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "x", Mode: "substring", Sources: []string{"messages", "bogus"},
|
|
})
|
|
require.Error(t, err, "expected error for unknown source name")
|
|
}
|
|
|
|
func TestSearchContentRegexInvalid(t *testing.T) {
|
|
d := testDB(t)
|
|
_, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: `(unclosed`, Mode: "regex", Sources: []string{"messages"},
|
|
})
|
|
require.Error(t, err, "expected error for invalid regex")
|
|
}
|
|
|
|
func TestSearchContentFTS(t *testing.T) {
|
|
d := testDB(t)
|
|
if !d.HasFTS() {
|
|
t.Skip("fts5 not available")
|
|
}
|
|
seedSearchSession(t, d, "f1", "proj", [][2]string{
|
|
{"user", "optimize the database query performance"},
|
|
})
|
|
got, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "optimize", Mode: "fts",
|
|
Sources: []string{"messages"}, Limit: 50,
|
|
})
|
|
require.NoError(t, err, "SearchContent fts")
|
|
require.Len(t, got.Matches, 1, "fts match")
|
|
assert.Equal(t, "message", got.Matches[0].Location, "fts match Location")
|
|
}
|
|
|
|
func TestSearchContentFTSPhraseSnippetFallsBackToFirstToken(t *testing.T) {
|
|
d := testDB(t)
|
|
if !d.HasFTS() {
|
|
t.Skip("fts5 not available")
|
|
}
|
|
body := strings.Repeat("prefix ", 30) + "foo-bar lives here"
|
|
seedSearchSession(t, d, "f-phrase", "proj", [][2]string{
|
|
{"user", body},
|
|
})
|
|
|
|
got, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: `"foo bar"`, Mode: "fts",
|
|
Sources: []string{"messages"}, Limit: 50,
|
|
})
|
|
require.NoError(t, err, "SearchContent fts phrase")
|
|
require.Len(t, got.Matches, 1, "fts phrase match")
|
|
assert.Contains(t, got.Matches[0].Snippet, "foo-bar")
|
|
}
|
|
|
|
func TestSearchContentFTSInvalidQuery(t *testing.T) {
|
|
d := testDB(t)
|
|
if !d.HasFTS() {
|
|
t.Skip("fts5 not available")
|
|
}
|
|
seedSearchSession(t, d, "f2", "proj", [][2]string{
|
|
{"user", "hello world"},
|
|
})
|
|
// A lone double quote is an unbalanced FTS phrase, so SQLite raises a
|
|
// generic syntax error that must be classified as user input, not a 500.
|
|
_, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: `"`, Mode: "fts",
|
|
Sources: []string{"messages"}, Limit: 50,
|
|
})
|
|
var inputErr *SearchInputError
|
|
require.True(t, errors.As(err, &inputErr),
|
|
"malformed FTS query error = %v, want *SearchInputError", err)
|
|
}
|
|
|
|
func TestSearchContentFTSUnavailable(t *testing.T) {
|
|
d := testDB(t)
|
|
if !d.HasFTS() {
|
|
t.Skip("fts5 not available")
|
|
}
|
|
// Drop the FTS table so HasFTS reports unavailable; the FTS search must
|
|
// then fail with an internal (non-input) error rather than being
|
|
// misclassified as an invalid user query (HTTP 400).
|
|
_, err := d.getWriter().Exec("DROP TABLE IF EXISTS messages_fts")
|
|
require.NoError(t, err, "drop messages_fts")
|
|
_, err = d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "x", Mode: "fts",
|
|
Sources: []string{"messages"}, Limit: 50,
|
|
})
|
|
require.Error(t, err, "expected error when FTS is unavailable")
|
|
var inputErr *SearchInputError
|
|
assert.False(t, errors.As(err, &inputErr),
|
|
"FTS-unavailable misclassified as input error: %v", err)
|
|
}
|
|
|
|
func TestSearchContentExcludeSystem(t *testing.T) {
|
|
d := testDB(t)
|
|
insertSession(t, d, "s3", "proj", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.UserMessageCount = 2
|
|
})
|
|
// Plain content (no legacy system-prefix string) so the exclusion is
|
|
// driven solely by the persisted is_system flag, not SystemPrefixSQL.
|
|
msgs := []Message{
|
|
{SessionID: "s3", Ordinal: 0, Role: "user",
|
|
Content: "ordinary message holding NEEDLE", IsSystem: true,
|
|
Timestamp: "2026-05-20T12:00:00Z"},
|
|
}
|
|
require.NoError(t, d.ReplaceSessionMessages("s3", msgs),
|
|
"ReplaceSessionMessages")
|
|
withSys, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "NEEDLE", Mode: "substring", Sources: []string{"messages"}, Limit: 50,
|
|
})
|
|
require.NoError(t, err, "SearchContent with system")
|
|
assert.Len(t, withSys.Matches, 1, "default should include system messages")
|
|
noSys, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "NEEDLE", Mode: "substring", Sources: []string{"messages"},
|
|
ExcludeSystem: true, Limit: 50,
|
|
})
|
|
require.NoError(t, err, "SearchContent exclude system")
|
|
assert.Empty(t, noSys.Matches,
|
|
"ExcludeSystem should drop system messages")
|
|
}
|
|
|
|
func TestSearchContentExcludesAutomatedByDefault(t *testing.T) {
|
|
d := testDB(t)
|
|
// Automated sessions are single-turn by definition (UserMessageCount <= 1
|
|
// plus a recognized first message, per sessionIsAutomated), so this one is
|
|
// excluded by default. IncludeAutomated must re-include it via the one-shot
|
|
// automated exemption — which only works if the automated flag is wired.
|
|
insertSession(t, d, "auto", "proj", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.UserMessageCount = 1
|
|
fm := "Warmup"
|
|
s.FirstMessage = &fm
|
|
})
|
|
msgs := []Message{{
|
|
SessionID: "auto", Ordinal: 0, Role: "user",
|
|
Content: "automated NEEDLE run", Timestamp: "2026-05-20T12:00:00Z",
|
|
}}
|
|
require.NoError(t, d.ReplaceSessionMessages("auto", msgs),
|
|
"ReplaceSessionMessages")
|
|
def, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "NEEDLE", Mode: "substring", Sources: []string{"messages"}, Limit: 50,
|
|
})
|
|
require.NoError(t, err, "SearchContent")
|
|
assert.Empty(t, def.Matches,
|
|
"automated session should be excluded by default")
|
|
inc, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "NEEDLE", Mode: "substring", Sources: []string{"messages"},
|
|
IncludeAutomated: true, Limit: 50,
|
|
})
|
|
require.NoError(t, err, "SearchContent include")
|
|
assert.Len(t, inc.Matches, 1,
|
|
"IncludeAutomated should include the session")
|
|
}
|
|
|
|
func TestSearchContentExcludesOneShotByDefault(t *testing.T) {
|
|
d := testDB(t)
|
|
// A one-shot session: user_message_count <= 1.
|
|
insertSession(t, d, "one", "proj", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.UserMessageCount = 1
|
|
})
|
|
msgs := []Message{{
|
|
SessionID: "one", Ordinal: 0, Role: "user",
|
|
Content: "leaked NEEDLE token", Timestamp: "2026-05-20T12:00:00Z",
|
|
}}
|
|
require.NoError(t, d.ReplaceSessionMessages("one", msgs),
|
|
"ReplaceSessionMessages")
|
|
def, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "NEEDLE", Mode: "substring", Sources: []string{"messages"}, Limit: 50,
|
|
})
|
|
require.NoError(t, err, "SearchContent")
|
|
assert.Empty(t, def.Matches,
|
|
"one-shot session should be excluded by default")
|
|
inc, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "NEEDLE", Mode: "substring", Sources: []string{"messages"},
|
|
IncludeOneShot: true, Limit: 50,
|
|
})
|
|
require.NoError(t, err, "SearchContent include")
|
|
assert.Len(t, inc.Matches, 1,
|
|
"IncludeOneShot should include the session")
|
|
}
|
|
|
|
func TestSearchContentToolResultDedup(t *testing.T) {
|
|
d := testDB(t)
|
|
insertSession(t, d, "dup", "proj", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.UserMessageCount = 2
|
|
})
|
|
// The pattern appears in BOTH result_content and a result event. The
|
|
// canonical rule must report it once, from the event branch.
|
|
msgs := []Message{{
|
|
SessionID: "dup", Ordinal: 0, Role: "assistant", Content: "run",
|
|
Timestamp: "2026-05-20T12:00:00Z",
|
|
ToolCalls: []ToolCall{{
|
|
ToolName: "Bash", Category: "Bash", ToolUseID: "tu1",
|
|
InputJSON: `{"command":"echo"}`,
|
|
ResultContent: "DUPNEEDLE in result_content",
|
|
ResultEvents: []ToolResultEvent{{
|
|
ToolUseID: "tu1", Status: "success",
|
|
Content: "DUPNEEDLE in event", EventIndex: 0,
|
|
}},
|
|
}},
|
|
}}
|
|
require.NoError(t, d.ReplaceSessionMessages("dup", msgs),
|
|
"ReplaceSessionMessages")
|
|
got, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "DUPNEEDLE", Mode: "substring",
|
|
Sources: []string{"tool_result"}, Limit: 50,
|
|
})
|
|
require.NoError(t, err, "SearchContent")
|
|
require.Len(t, got.Matches, 1, "canonical dedup")
|
|
assert.Contains(t, got.Matches[0].Snippet, "event",
|
|
"expected the event content to win")
|
|
}
|
|
|
|
func TestSearchContentCursorPagination(t *testing.T) {
|
|
d := testDB(t)
|
|
seedSearchSession(t, d, "pg", "proj", [][2]string{
|
|
{"user", "alpha NEEDLE one"},
|
|
{"user", "beta NEEDLE two"},
|
|
{"user", "gamma NEEDLE three"},
|
|
})
|
|
first, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "NEEDLE", Mode: "substring", Sources: []string{"messages"}, Limit: 2,
|
|
})
|
|
require.NoError(t, err, "SearchContent page1")
|
|
require.Len(t, first.Matches, 2, "page1 matches")
|
|
require.Equal(t, 2, first.NextCursor, "page1 cursor")
|
|
second, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "NEEDLE", Mode: "substring", Sources: []string{"messages"},
|
|
Limit: 2, Cursor: first.NextCursor,
|
|
})
|
|
require.NoError(t, err, "SearchContent page2")
|
|
require.Len(t, second.Matches, 1, "page2 matches")
|
|
require.Equal(t, 0, second.NextCursor, "page2 cursor")
|
|
}
|
|
|
|
func TestSearchContentMultiSourceWithProjectFilter(t *testing.T) {
|
|
d := testDB(t)
|
|
for _, p := range [][2]string{{"a", "alpha"}, {"b", "beta"}} {
|
|
id, project := p[0], p[1]
|
|
insertSession(t, d, id, project, func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.UserMessageCount = 2
|
|
})
|
|
msgs := []Message{{
|
|
SessionID: id, Ordinal: 0, Role: "assistant", Content: "FINDME here",
|
|
Timestamp: "2026-05-20T12:00:00Z",
|
|
ToolCalls: []ToolCall{{
|
|
ToolName: "Bash", Category: "Bash", ToolUseID: id + "-tu",
|
|
InputJSON: `{"command":"FINDME"}`, ResultContent: "out FINDME",
|
|
}},
|
|
}}
|
|
require.NoError(t, d.ReplaceSessionMessages(id, msgs),
|
|
"ReplaceSessionMessages %s", id)
|
|
}
|
|
got, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "FINDME", Mode: "substring",
|
|
Sources: []string{"messages", "tool_input", "tool_result"},
|
|
Project: "alpha", Limit: 50,
|
|
})
|
|
require.NoError(t, err, "SearchContent")
|
|
require.NotEmpty(t, got.Matches, "expected matches in project alpha")
|
|
for _, m := range got.Matches {
|
|
assert.Equal(t, "a", m.SessionID, "session leaked")
|
|
assert.Equal(t, "alpha", m.Project, "project leaked")
|
|
}
|
|
}
|
|
|
|
func TestSnippetWindowRuneBoundaries(t *testing.T) {
|
|
// "é" and "ü" are two bytes each, so a byte-radius that lands inside them
|
|
// would slice mid-rune. The match itself is ASCII; only the padding edges
|
|
// are at risk.
|
|
text := strings.Repeat("é", 5) + "MATCH" + strings.Repeat("ü", 5)
|
|
start := strings.Index(text, "MATCH")
|
|
end := start + len("MATCH")
|
|
window := func(radius int) string {
|
|
lo, hi := snippetBounds(text, start, end, radius)
|
|
return text[lo:hi]
|
|
}
|
|
// radius 3 lands mid-rune on both sides, so the partial padding runes are
|
|
// trimmed back to a boundary, leaving one whole rune of padding each side.
|
|
got := window(3)
|
|
assert.Equal(t, "éMATCHü", got, "mid-rune radius")
|
|
// radius 4 lands exactly on rune boundaries, so aligned padding is kept.
|
|
assert.Equal(t, "ééMATCHüü", window(4), "boundary-aligned radius")
|
|
assert.True(t, utf8.ValidString(got), "snippet not valid UTF-8: %q", got)
|
|
}
|
|
|
|
func TestFTSSnippetCentersOnPhrase(t *testing.T) {
|
|
// A stray first token ("error") sits at the start; the real phrase ("error
|
|
// handler") is far past the snippet radius. Centering on the first token
|
|
// alone would window the stray match and drop the phrase.
|
|
prefix := "error in the early part " + strings.Repeat("x ", 80)
|
|
body := prefix + "the real error handler lives here"
|
|
|
|
t.Run("phrase present centers on phrase", func(t *testing.T) {
|
|
f := ContentSearchFilter{Pattern: `"error handler"`, Mode: "fts"}
|
|
assert.Contains(t, f.ftsSnippet(body), "error handler",
|
|
"snippet did not center on the phrase")
|
|
})
|
|
t.Run("phrase absent falls back to first token", func(t *testing.T) {
|
|
// No contiguous "error handler" substring, so centering falls back to
|
|
// the first token "error", windowing its early occurrence.
|
|
f := ContentSearchFilter{Pattern: `"error nonexistent"`, Mode: "fts"}
|
|
assert.Contains(t, f.ftsSnippet(body), "error in the early",
|
|
"fallback snippet not centered on first token")
|
|
})
|
|
}
|
|
|
|
// seedUnitSession inserts a session (lineage-configurable via opts) plus full
|
|
// Message rows, for derived-unit citation tests that need
|
|
// is_system/is_sidechain/tool fields beyond seedSearchSession's role/content
|
|
// pairs. SessionID and a per-ordinal timestamp are filled in when unset.
|
|
func seedUnitSession(
|
|
t *testing.T, d *DB, id string, opts func(*Session), msgs []Message,
|
|
) {
|
|
t.Helper()
|
|
insertSession(t, d, id, "proj", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.UserMessageCount = 2
|
|
if opts != nil {
|
|
opts(s)
|
|
}
|
|
})
|
|
for i := range msgs {
|
|
msgs[i].SessionID = id
|
|
if msgs[i].Timestamp == "" {
|
|
msgs[i].Timestamp = fmt.Sprintf("2026-05-20T12:00:%02dZ", i)
|
|
}
|
|
}
|
|
require.NoError(t, d.ReplaceSessionMessages(id, msgs),
|
|
"ReplaceSessionMessages %s", id)
|
|
}
|
|
|
|
// matchesByOrdinal indexes a page's matches by anchor ordinal, requiring the
|
|
// ordinals to be unique.
|
|
func matchesByOrdinal(t *testing.T, page ContentSearchPage) map[int]ContentMatch {
|
|
t.Helper()
|
|
out := make(map[int]ContentMatch, len(page.Matches))
|
|
for _, m := range page.Matches {
|
|
_, dup := out[m.Ordinal]
|
|
require.False(t, dup, "duplicate match ordinal %d", m.Ordinal)
|
|
out[m.Ordinal] = m
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestSearchContentSubstringDerivedRunRange pins the derived
|
|
// conversation-unit citation on substring rows: every match in one assistant
|
|
// run carries the run's full range (spanning a non-member system row), an
|
|
// embeddable user row and a system row are their own units, and ExcludeSystem
|
|
// changes nothing but which rows match.
|
|
func TestSearchContentSubstringDerivedRunRange(t *testing.T) {
|
|
d := testDB(t)
|
|
seedUnitSession(t, d, "run1", nil, []Message{
|
|
{Ordinal: 0, Role: "user", Content: "the RUNHIT question"},
|
|
{Ordinal: 1, Role: "assistant", Content: "RUNHIT step one"},
|
|
{Ordinal: 2, Role: "user", Content: "sys RUNHIT note", IsSystem: true},
|
|
{Ordinal: 3, Role: "assistant", Content: "RUNHIT step two"},
|
|
{Ordinal: 4, Role: "assistant", Content: "RUNHIT step three"},
|
|
{Ordinal: 5, Role: "user", Content: "next question"},
|
|
})
|
|
got, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "RUNHIT", Mode: "substring",
|
|
Sources: []string{"messages"}, Limit: 50,
|
|
})
|
|
require.NoError(t, err, "SearchContent")
|
|
require.Len(t, got.Matches, 5, "matches")
|
|
byOrd := matchesByOrdinal(t, got)
|
|
assert.Equal(t, [2]int{0, 0}, byOrd[0].OrdinalRange, "user row is its own unit")
|
|
assert.Equal(t, [2]int{2, 2}, byOrd[2].OrdinalRange, "system row is its own unit")
|
|
for _, o := range []int{1, 3, 4} {
|
|
m := byOrd[o]
|
|
assert.Equal(t, [2]int{1, 4}, m.OrdinalRange, "run member %d", o)
|
|
assert.Equal(t, o, m.Ordinal, "anchor ordinal %d", o)
|
|
assert.False(t, m.Subordinate, "top-level run member %d", o)
|
|
assert.False(t, m.Sidechain, "non-sidechain run member %d", o)
|
|
assert.Empty(t, m.Relationship, "top-level relationship %d", o)
|
|
assert.Empty(t, m.ParentSessionID, "top-level parent %d", o)
|
|
}
|
|
|
|
// ExcludeSystem drops the system row but leaves the derived ranges of the
|
|
// surviving rows unchanged.
|
|
ex, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "RUNHIT", Mode: "substring",
|
|
Sources: []string{"messages"}, ExcludeSystem: true, Limit: 50,
|
|
})
|
|
require.NoError(t, err, "SearchContent ExcludeSystem")
|
|
require.Len(t, ex.Matches, 4, "ExcludeSystem matches")
|
|
exByOrd := matchesByOrdinal(t, ex)
|
|
assert.NotContains(t, exByOrd, 2, "system row excluded")
|
|
assert.Equal(t, [2]int{0, 0}, exByOrd[0].OrdinalRange)
|
|
for _, o := range []int{1, 3, 4} {
|
|
assert.Equal(t, [2]int{1, 4}, exByOrd[o].OrdinalRange,
|
|
"ExcludeSystem run member %d", o)
|
|
}
|
|
}
|
|
|
|
// TestSearchContentSubstringSidechainRunSubordinate pins the sidechain rules:
|
|
// a sidechain run's members are Subordinate + Sidechain, and the sidechain
|
|
// flip bounds both the sidechain run and the following top-level run.
|
|
func TestSearchContentSubstringSidechainRunSubordinate(t *testing.T) {
|
|
d := testDB(t)
|
|
seedUnitSession(t, d, "side1", nil, []Message{
|
|
{Ordinal: 0, Role: "user", Content: "the question"},
|
|
{Ordinal: 1, Role: "assistant", Content: "SIDEHIT step a", IsSidechain: true},
|
|
{Ordinal: 2, Role: "assistant", Content: "SIDEHIT step b", IsSidechain: true},
|
|
{Ordinal: 3, Role: "assistant", Content: "main MAINHIT answer"},
|
|
})
|
|
side, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "SIDEHIT", Mode: "substring",
|
|
Sources: []string{"messages"}, Limit: 50,
|
|
})
|
|
require.NoError(t, err, "SearchContent sidechain")
|
|
require.Len(t, side.Matches, 2, "sidechain matches")
|
|
for _, m := range side.Matches {
|
|
assert.Equal(t, [2]int{1, 2}, m.OrdinalRange, "sidechain run range")
|
|
assert.True(t, m.Subordinate, "sidechain run is subordinate")
|
|
assert.True(t, m.Sidechain, "anchor sidechain flag")
|
|
assert.Empty(t, m.Relationship, "no session lineage")
|
|
}
|
|
main, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "MAINHIT", Mode: "substring",
|
|
Sources: []string{"messages"}, Limit: 50,
|
|
})
|
|
require.NoError(t, err, "SearchContent main")
|
|
require.Len(t, main.Matches, 1, "main matches")
|
|
m := main.Matches[0]
|
|
assert.Equal(t, [2]int{3, 3}, m.OrdinalRange,
|
|
"sidechain flip bounds the top-level run")
|
|
assert.False(t, m.Subordinate, "top-level run")
|
|
assert.False(t, m.Sidechain, "top-level anchor")
|
|
}
|
|
|
|
// TestSearchContentSubstringSubagentLineage pins session-level lineage on
|
|
// lexical rows: a match inside a subagent session is Subordinate with
|
|
// Relationship and ParentSessionID populated from the sessions join.
|
|
func TestSearchContentSubstringSubagentLineage(t *testing.T) {
|
|
d := testDB(t)
|
|
insertSession(t, d, "parent", "proj", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.UserMessageCount = 2
|
|
})
|
|
seedUnitSession(t, d, "child", func(s *Session) {
|
|
s.ParentSessionID = Ptr("parent")
|
|
s.RelationshipType = "subagent"
|
|
}, []Message{
|
|
{Ordinal: 0, Role: "user", Content: "subagent prompt"},
|
|
{Ordinal: 1, Role: "assistant", Content: "SUBHIT answer"},
|
|
})
|
|
got, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "SUBHIT", Mode: "substring",
|
|
Sources: []string{"messages"}, IncludeChildren: true, Limit: 50,
|
|
})
|
|
require.NoError(t, err, "SearchContent")
|
|
require.Len(t, got.Matches, 1, "matches")
|
|
m := got.Matches[0]
|
|
assert.Equal(t, [2]int{1, 1}, m.OrdinalRange, "single-member run")
|
|
assert.True(t, m.Subordinate, "subagent session is subordinate")
|
|
assert.Equal(t, "subagent", m.Relationship, "Relationship")
|
|
assert.Equal(t, "parent", m.ParentSessionID, "ParentSessionID")
|
|
assert.False(t, m.Sidechain, "anchor not sidechain")
|
|
}
|
|
|
|
// TestSearchContentToolDerivedRunRange pins derivation for tool_input and
|
|
// canonical tool_result rows: the anchor is the tool call's message row, so
|
|
// both locations carry the enclosing run's range while the wire Role stays
|
|
// the hard-coded "assistant".
|
|
func TestSearchContentToolDerivedRunRange(t *testing.T) {
|
|
d := testDB(t)
|
|
seedUnitSession(t, d, "tool1", nil, []Message{
|
|
{Ordinal: 0, Role: "user", Content: "the question"},
|
|
{Ordinal: 1, Role: "assistant", Content: "running the tool",
|
|
ToolCalls: []ToolCall{{
|
|
ToolName: "Bash", Category: "Bash", ToolUseID: "tu1",
|
|
InputJSON: `{"command":"TOOLHIT"}`,
|
|
ResultContent: "output RESHIT data",
|
|
}}},
|
|
{Ordinal: 2, Role: "assistant", Content: "continuing the answer"},
|
|
{Ordinal: 3, Role: "user", Content: "thanks"},
|
|
})
|
|
in, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "TOOLHIT", Mode: "substring",
|
|
Sources: []string{"tool_input"}, Limit: 50,
|
|
})
|
|
require.NoError(t, err, "tool_input search")
|
|
require.Len(t, in.Matches, 1, "tool_input matches")
|
|
assert.Equal(t, "assistant", in.Matches[0].Role, "wire role stays assistant")
|
|
assert.Equal(t, 1, in.Matches[0].Ordinal, "anchor ordinal")
|
|
assert.Equal(t, [2]int{1, 2}, in.Matches[0].OrdinalRange,
|
|
"tool_input anchor classified from the real message row")
|
|
|
|
res, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "RESHIT", Mode: "substring",
|
|
Sources: []string{"tool_result"}, Limit: 50,
|
|
})
|
|
require.NoError(t, err, "tool_result search")
|
|
require.Len(t, res.Matches, 1, "tool_result matches")
|
|
assert.Equal(t, [2]int{1, 2}, res.Matches[0].OrdinalRange,
|
|
"canonical tool_result anchor classified from the real message row")
|
|
}
|
|
|
|
// TestSearchContentToolAnchorUsesRealRowRole pins the role-sensitive anchor
|
|
// classification: a tool call hanging off a user-role message keeps the
|
|
// hard-coded "assistant" wire role, but derivation must classify the anchor
|
|
// by the REAL row's role — an embeddable user row is its own unit, never an
|
|
// assistant run member.
|
|
func TestSearchContentToolAnchorUsesRealRowRole(t *testing.T) {
|
|
d := testDB(t)
|
|
seedUnitSession(t, d, "toolu", nil, []Message{
|
|
{Ordinal: 0, Role: "user", Content: "prompt"},
|
|
{Ordinal: 1, Role: "user", Content: "user-attached call",
|
|
ToolCalls: []ToolCall{{
|
|
ToolName: "Bash", Category: "Bash", ToolUseID: "tuu",
|
|
InputJSON: `{"command":"UHIT"}`,
|
|
}}},
|
|
{Ordinal: 2, Role: "assistant", Content: "assistant reply"},
|
|
})
|
|
got, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "UHIT", Mode: "substring",
|
|
Sources: []string{"tool_input"}, Limit: 50,
|
|
})
|
|
require.NoError(t, err, "SearchContent")
|
|
require.Len(t, got.Matches, 1, "matches")
|
|
m := got.Matches[0]
|
|
assert.Equal(t, "assistant", m.Role, "wire role stays assistant")
|
|
assert.Equal(t, [2]int{1, 1}, m.OrdinalRange,
|
|
"user-role anchor row is its own unit")
|
|
}
|
|
|
|
// TestSearchContentToolResultEventsDerived pins the events branches: an
|
|
// orphaned event (no message row at its ordinal) still returns its match
|
|
// (row cardinality must not change) with the [o, o] fallback and session
|
|
// lineage, while an event whose message row sits inside a run gets the run's
|
|
// range via the post-scan anchor lookup.
|
|
func TestSearchContentToolResultEventsDerived(t *testing.T) {
|
|
d := testDB(t)
|
|
|
|
insertSession(t, d, "boss", "proj", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.UserMessageCount = 2
|
|
})
|
|
insertSession(t, d, "evorph", "proj", func(s *Session) {
|
|
s.Agent = "claude"
|
|
s.UserMessageCount = 2
|
|
s.ParentSessionID = Ptr("boss")
|
|
s.RelationshipType = "subagent"
|
|
})
|
|
_, err := d.getWriter().Exec(`INSERT INTO tool_result_events
|
|
(session_id, tool_call_message_ordinal, tool_use_id, source, status,
|
|
content, content_length, timestamp, event_index)
|
|
VALUES ('evorph', 7, 'tux', 'stdout', 'success',
|
|
'ORPHHIT event content', 21, '2026-05-20T12:00:00Z', 0)`)
|
|
require.NoError(t, err, "insert orphan event")
|
|
|
|
seedUnitSession(t, d, "evrun", nil, []Message{
|
|
{Ordinal: 0, Role: "user", Content: "the question"},
|
|
{Ordinal: 1, Role: "assistant", Content: "running",
|
|
ToolCalls: []ToolCall{{
|
|
ToolName: "Bash", Category: "Bash", ToolUseID: "tu1",
|
|
InputJSON: `{"command":"x"}`,
|
|
ResultEvents: []ToolResultEvent{{
|
|
ToolUseID: "tu1", Source: "stdout", Status: "success",
|
|
Content: "EVHIT streamed output", EventIndex: 0,
|
|
}},
|
|
}}},
|
|
{Ordinal: 2, Role: "assistant", Content: "wrapping up"},
|
|
})
|
|
|
|
orph, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "ORPHHIT", Mode: "substring",
|
|
Sources: []string{"tool_result"}, IncludeChildren: true, Limit: 50,
|
|
})
|
|
require.NoError(t, err, "orphan search")
|
|
require.Len(t, orph.Matches, 1, "orphaned event row must not be dropped")
|
|
m := orph.Matches[0]
|
|
assert.Equal(t, 7, m.Ordinal, "event ordinal")
|
|
assert.Equal(t, [2]int{7, 7}, m.OrdinalRange, "missing anchor falls back to [o, o]")
|
|
assert.False(t, m.Sidechain, "missing anchor has no sidechain flag")
|
|
assert.True(t, m.Subordinate, "session lineage still applies")
|
|
assert.Equal(t, "subagent", m.Relationship, "Relationship from sessions join")
|
|
assert.Equal(t, "boss", m.ParentSessionID, "ParentSessionID from sessions join")
|
|
|
|
ev, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "EVHIT", Mode: "substring",
|
|
Sources: []string{"tool_result"}, Limit: 50,
|
|
})
|
|
require.NoError(t, err, "event search")
|
|
require.Len(t, ev.Matches, 1, "event matches")
|
|
assert.Equal(t, 1, ev.Matches[0].Ordinal, "anchor ordinal")
|
|
assert.Equal(t, [2]int{1, 2}, ev.Matches[0].OrdinalRange,
|
|
"event with a message row inside a run gets the run's range")
|
|
}
|
|
|
|
// TestSearchContentRegexDerivedRange spot-checks that regex mode routes
|
|
// through the shared derivation pass.
|
|
func TestSearchContentRegexDerivedRange(t *testing.T) {
|
|
d := testDB(t)
|
|
seedUnitSession(t, d, "rx1", nil, []Message{
|
|
{Ordinal: 0, Role: "user", Content: "the question"},
|
|
{Ordinal: 1, Role: "assistant", Content: "RXHIT alpha"},
|
|
{Ordinal: 2, Role: "assistant", Content: "RXHIT beta"},
|
|
})
|
|
got, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: `RXHIT [a-z]+`, Mode: "regex",
|
|
Sources: []string{"messages"}, Limit: 50,
|
|
})
|
|
require.NoError(t, err, "SearchContent regex")
|
|
require.Len(t, got.Matches, 2, "regex matches")
|
|
for _, m := range got.Matches {
|
|
assert.Equal(t, [2]int{1, 2}, m.OrdinalRange, "derived run range")
|
|
}
|
|
}
|
|
|
|
// TestSearchContentFTSDerivedRange spot-checks that fts mode routes through
|
|
// the shared derivation pass.
|
|
func TestSearchContentFTSDerivedRange(t *testing.T) {
|
|
d := testDB(t)
|
|
if !d.HasFTS() {
|
|
t.Skip("fts5 not available")
|
|
}
|
|
seedUnitSession(t, d, "fx1", nil, []Message{
|
|
{Ordinal: 0, Role: "user", Content: "the question"},
|
|
{Ordinal: 1, Role: "assistant", Content: "ftshit alpha step"},
|
|
{Ordinal: 2, Role: "assistant", Content: "ftshit beta step"},
|
|
})
|
|
got, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "ftshit", Mode: "fts",
|
|
Sources: []string{"messages"}, Limit: 50,
|
|
})
|
|
require.NoError(t, err, "SearchContent fts")
|
|
require.Len(t, got.Matches, 2, "fts matches")
|
|
for _, m := range got.Matches {
|
|
assert.Equal(t, [2]int{1, 2}, m.OrdinalRange, "derived run range")
|
|
assert.False(t, m.Subordinate, "top-level run")
|
|
}
|
|
}
|