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
466 lines
18 KiB
Go
466 lines
18 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// fakeVectorSearcher is a canned VectorSearcher for db-layer semantic search
|
|
// tests: it returns a fixed, rank-ordered slice of hits (optionally trimmed
|
|
// to limit) or a fixed error, so tests can pin the db layer's handling of
|
|
// searcher output without a real embedding index. ResolveMessageUnits
|
|
// resolves against the explicit units list first, then against the units
|
|
// implied by hits (the real resolver reads the same mirror the hits come
|
|
// from), so hybrid tests fuse without wiring a real index.
|
|
type fakeVectorSearcher struct {
|
|
hits []VectorHit
|
|
units []UnitRef
|
|
err error
|
|
resolveErr error
|
|
calls int
|
|
}
|
|
|
|
func (f *fakeVectorSearcher) SemanticSearch(
|
|
_ context.Context, _ string, limit int,
|
|
) ([]VectorHit, error) {
|
|
f.calls++
|
|
if f.err != nil {
|
|
return nil, f.err
|
|
}
|
|
hits := f.hits
|
|
if limit > 0 && limit < len(hits) {
|
|
hits = hits[:limit]
|
|
}
|
|
return hits, nil
|
|
}
|
|
|
|
func (f *fakeVectorSearcher) ResolveMessageUnits(
|
|
_ context.Context, refs []MessageRef,
|
|
) ([]UnitRef, error) {
|
|
if f.resolveErr != nil {
|
|
return nil, f.resolveErr
|
|
}
|
|
out := make([]UnitRef, len(refs))
|
|
for i, ref := range refs {
|
|
out[i] = f.resolveRef(ref)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeVectorSearcher) resolveRef(ref MessageRef) UnitRef {
|
|
for _, u := range f.units {
|
|
if u.SessionID == ref.SessionID &&
|
|
ref.Ordinal >= u.OrdinalStart && ref.Ordinal <= u.OrdinalEnd {
|
|
return u
|
|
}
|
|
}
|
|
for _, h := range f.hits {
|
|
if h.SessionID == ref.SessionID &&
|
|
ref.Ordinal >= h.OrdinalStart && ref.Ordinal <= h.OrdinalEnd {
|
|
return UnitRef{
|
|
DocKey: fmt.Sprintf("fake:%s:%d", h.SessionID, h.OrdinalStart),
|
|
SessionID: h.SessionID,
|
|
OrdinalStart: h.OrdinalStart,
|
|
OrdinalEnd: h.OrdinalEnd,
|
|
Subordinate: h.Subordinate,
|
|
}
|
|
}
|
|
}
|
|
return UnitRef{}
|
|
}
|
|
|
|
func TestSearchContentSemanticNoSearcherUnavailable(t *testing.T) {
|
|
d := testDB(t)
|
|
assert.False(t, d.HasSemantic(), "HasSemantic before wiring a searcher")
|
|
|
|
_, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "hello", Mode: "semantic",
|
|
})
|
|
require.Error(t, err)
|
|
assert.True(t, errors.Is(err, ErrSemanticUnavailable),
|
|
"expected ErrSemanticUnavailable, got %v", err)
|
|
}
|
|
|
|
func TestHasSemanticFlipsWithSetVectorSearcher(t *testing.T) {
|
|
d := testDB(t)
|
|
require.False(t, d.HasSemantic(), "HasSemantic before SetVectorSearcher")
|
|
|
|
d.SetVectorSearcher(&fakeVectorSearcher{})
|
|
assert.True(t, d.HasSemantic(), "HasSemantic after SetVectorSearcher")
|
|
|
|
d.SetVectorSearcher(nil)
|
|
assert.False(t, d.HasSemantic(), "HasSemantic after clearing the searcher")
|
|
}
|
|
|
|
func TestSearchContentSemanticRoutesAndPreservesRank(t *testing.T) {
|
|
d := testDB(t)
|
|
seedSearchSession(t, d, "s1", "alpha", [][2]string{
|
|
{"user", "hello world foo"},
|
|
})
|
|
seedSearchSession(t, d, "s2", "beta", [][2]string{
|
|
{"user", "another message"},
|
|
})
|
|
// s2 ranks first despite sorting after "s1" alphabetically, so preserved
|
|
// order can only come from the searcher, not a re-sort by session id.
|
|
d.SetVectorSearcher(&fakeVectorSearcher{hits: []VectorHit{
|
|
{SessionID: "s2", Ordinal: 0, Score: 0.9, Snippet: "another message"},
|
|
{SessionID: "s1", Ordinal: 0, Score: 0.5, Snippet: "hello world foo"},
|
|
}})
|
|
|
|
page, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "hello", Mode: "semantic", Limit: 50,
|
|
})
|
|
require.NoError(t, err, "SearchContent")
|
|
require.Len(t, page.Matches, 2, "matches")
|
|
|
|
assert.Equal(t, "s2", page.Matches[0].SessionID, "rank order: s2 first")
|
|
assert.Equal(t, "s1", page.Matches[1].SessionID, "rank order: s1 second")
|
|
|
|
m0 := page.Matches[0]
|
|
assert.Equal(t, "beta", m0.Project, "Project")
|
|
assert.Equal(t, "message", m0.Location, "Location")
|
|
require.NotNil(t, m0.Score, "Score")
|
|
assert.InDelta(t, 0.9, *m0.Score, 0.0001, "Score value")
|
|
assert.Equal(t, "another message", m0.Snippet, "Snippet")
|
|
}
|
|
|
|
func TestSearchContentSemanticProjectFilterDropsNonMatching(t *testing.T) {
|
|
d := testDB(t)
|
|
seedSearchSession(t, d, "s1", "alpha", [][2]string{
|
|
{"user", "hello world foo"},
|
|
})
|
|
seedSearchSession(t, d, "s2", "beta", [][2]string{
|
|
{"user", "another message"},
|
|
})
|
|
d.SetVectorSearcher(&fakeVectorSearcher{hits: []VectorHit{
|
|
{SessionID: "s1", Ordinal: 0, Score: 0.9, Snippet: "hello world foo"},
|
|
{SessionID: "s2", Ordinal: 0, Score: 0.5, Snippet: "another message"},
|
|
}})
|
|
|
|
page, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "hello", Mode: "semantic", Limit: 50, Project: "alpha",
|
|
})
|
|
require.NoError(t, err, "SearchContent")
|
|
require.Len(t, page.Matches, 1, "matches after project filter")
|
|
assert.Equal(t, "s1", page.Matches[0].SessionID, "surviving session")
|
|
}
|
|
|
|
func TestSearchContentSemanticLimitTrims(t *testing.T) {
|
|
d := testDB(t)
|
|
seedSearchSession(t, d, "s1", "alpha", [][2]string{{"user", "a"}})
|
|
seedSearchSession(t, d, "s2", "alpha", [][2]string{{"user", "b"}})
|
|
seedSearchSession(t, d, "s3", "alpha", [][2]string{{"user", "c"}})
|
|
d.SetVectorSearcher(&fakeVectorSearcher{hits: []VectorHit{
|
|
{SessionID: "s1", Ordinal: 0, Score: 0.9, Snippet: "a"},
|
|
{SessionID: "s2", Ordinal: 0, Score: 0.8, Snippet: "b"},
|
|
{SessionID: "s3", Ordinal: 0, Score: 0.7, Snippet: "c"},
|
|
}})
|
|
|
|
page, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "x", Mode: "semantic", Limit: 2,
|
|
})
|
|
require.NoError(t, err, "SearchContent")
|
|
require.Len(t, page.Matches, 2, "matches trimmed to limit")
|
|
assert.Equal(t, "s1", page.Matches[0].SessionID)
|
|
assert.Equal(t, "s2", page.Matches[1].SessionID)
|
|
}
|
|
|
|
func TestSearchContentSemanticCursorRejected(t *testing.T) {
|
|
d := testDB(t)
|
|
d.SetVectorSearcher(&fakeVectorSearcher{})
|
|
|
|
_, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "hello", Mode: "semantic", Cursor: 1,
|
|
})
|
|
require.Error(t, err)
|
|
var inputErr *SearchInputError
|
|
assert.True(t, errors.As(err, &inputErr),
|
|
"expected *SearchInputError, got %T: %v", err, err)
|
|
}
|
|
|
|
func TestSearchContentSemanticToolInputSourceRejected(t *testing.T) {
|
|
d := testDB(t)
|
|
d.SetVectorSearcher(&fakeVectorSearcher{})
|
|
|
|
_, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "hello", Mode: "semantic", Sources: []string{"tool_input"},
|
|
})
|
|
require.Error(t, err)
|
|
var inputErr *SearchInputError
|
|
assert.True(t, errors.As(err, &inputErr),
|
|
"expected *SearchInputError, got %T: %v", err, err)
|
|
}
|
|
|
|
func TestSearchContentSemanticMessagesSourceAllowed(t *testing.T) {
|
|
d := testDB(t)
|
|
seedSearchSession(t, d, "s1", "alpha", [][2]string{{"user", "hello"}})
|
|
d.SetVectorSearcher(&fakeVectorSearcher{hits: []VectorHit{
|
|
{SessionID: "s1", Ordinal: 0, Score: 0.9, Snippet: "hello"},
|
|
}})
|
|
|
|
page, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "hello", Mode: "semantic", Sources: []string{"messages"},
|
|
})
|
|
require.NoError(t, err, "SearchContent with explicit messages source")
|
|
require.Len(t, page.Matches, 1)
|
|
}
|
|
|
|
// TestEnrichSemanticHitsCarriesLineage pins the enrichment join for
|
|
// run-anchored hits: relationship_type and parent_session_id come from the
|
|
// hit's session row and is_sidechain from the anchor ordinal's message row,
|
|
// while a top-level session yields empty lineage.
|
|
func TestEnrichSemanticHitsCarriesLineage(t *testing.T) {
|
|
d := testDB(t)
|
|
insertSession(t, d, "parent", "proj", func(s *Session) {
|
|
s.UserMessageCount = 2
|
|
})
|
|
insertSession(t, d, "child", "proj", func(s *Session) {
|
|
s.UserMessageCount = 2
|
|
s.ParentSessionID = Ptr("parent")
|
|
s.RelationshipType = "subagent"
|
|
})
|
|
require.NoError(t, d.ReplaceSessionMessages("parent", []Message{
|
|
{SessionID: "parent", Ordinal: 0, Role: "user",
|
|
Content: "top-level question", Timestamp: "2026-05-20T12:00:00Z"},
|
|
}))
|
|
require.NoError(t, d.ReplaceSessionMessages("child", []Message{
|
|
{SessionID: "child", Ordinal: 0, Role: "user",
|
|
Content: "subagent prompt", Timestamp: "2026-05-20T12:00:01Z"},
|
|
{SessionID: "child", Ordinal: 1, Role: "assistant", IsSidechain: true,
|
|
Content: "sidechain step one", Timestamp: "2026-05-20T12:00:02Z"},
|
|
{SessionID: "child", Ordinal: 2, Role: "assistant", IsSidechain: true,
|
|
Content: "sidechain step two", Timestamp: "2026-05-20T12:00:03Z"},
|
|
}))
|
|
|
|
meta, err := d.enrichSemanticHits(context.Background(), []VectorHit{
|
|
{SessionID: "child", Ordinal: 1, OrdinalStart: 1, OrdinalEnd: 2,
|
|
Subordinate: true, Score: 0.9},
|
|
{SessionID: "parent", Ordinal: 0, OrdinalStart: 0, OrdinalEnd: 0,
|
|
Score: 0.5},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
child, ok := meta[semanticHitKey{"child", 1}]
|
|
require.True(t, ok, "child hit enriched")
|
|
assert.Equal(t, "subagent", child.relationshipType)
|
|
assert.Equal(t, "parent", child.parentSessionID)
|
|
assert.True(t, child.isSidechain, "anchor message is_sidechain")
|
|
assert.Equal(t, "assistant", child.role)
|
|
|
|
top, ok := meta[semanticHitKey{"parent", 0}]
|
|
require.True(t, ok, "parent hit enriched")
|
|
assert.Empty(t, top.relationshipType)
|
|
assert.Empty(t, top.parentSessionID)
|
|
assert.False(t, top.isSidechain)
|
|
}
|
|
|
|
// TestSearchContentSemanticAnchorOrdinalHit pins that a run-anchored hit
|
|
// (anchor ordinal pointing at an assistant message inside the run, with the
|
|
// range and subordinate flag populated) enriches by the anchor ordinal: the
|
|
// match carries the anchor message's role and timestamp.
|
|
func TestSearchContentSemanticAnchorOrdinalHit(t *testing.T) {
|
|
d := testDB(t)
|
|
seedSearchSession(t, d, "s1", "alpha", [][2]string{
|
|
{"user", "the question"},
|
|
{"assistant", "first step of the answer"},
|
|
{"assistant", "second step of the answer"},
|
|
})
|
|
d.SetVectorSearcher(&fakeVectorSearcher{hits: []VectorHit{
|
|
{SessionID: "s1", Ordinal: 2, OrdinalStart: 1, OrdinalEnd: 2,
|
|
Score: 0.9, Snippet: "second step of the answer"},
|
|
}})
|
|
|
|
page, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "answer", Mode: "semantic", Limit: 50,
|
|
})
|
|
require.NoError(t, err, "SearchContent")
|
|
require.Len(t, page.Matches, 1, "matches")
|
|
m := page.Matches[0]
|
|
assert.Equal(t, 2, m.Ordinal, "anchor ordinal")
|
|
assert.Equal(t, "assistant", m.Role, "anchor message role")
|
|
assert.Contains(t, m.Snippet, "second step")
|
|
}
|
|
|
|
// TestSearchContentSemanticRedactsSecretPastChunkTruncation pins that
|
|
// semantic mode redacts against the message's full content, not the
|
|
// searcher's pre-truncated chunk snippet. The fake searcher's Snippet is cut
|
|
// off mid-PEM-body (before the "-----END" marker), mimicking a real chunk
|
|
// boundary or the 200-rune vector snippet truncation landing inside a
|
|
// secret. The PEM rule only fires on a BEGIN/END pair, so redacting the
|
|
// truncated snippet in isolation finds no match and ships the key material
|
|
// raw; redacting the full message content (which has both markers) must
|
|
// still catch and mask it.
|
|
func TestSearchContentSemanticRedactsSecretPastChunkTruncation(t *testing.T) {
|
|
d := testDB(t)
|
|
pem := "-----BEGIN RSA PRIVATE KEY-----\n" +
|
|
strings.Repeat("MIIBSECRETKEYMATERIAL0123456789ABCDEF\n", 5) +
|
|
"-----END RSA PRIVATE KEY-----"
|
|
content := "deploy with this attached key " + pem + " ok"
|
|
seedSearchSession(t, d, "s1", "proj", [][2]string{
|
|
{"user", content},
|
|
})
|
|
|
|
// Cut the chunk snippet well before the END marker so the raw fragment
|
|
// itself never contains a BEGIN/END pair.
|
|
cut := strings.Index(content, "MIIBSECRETKEYMATERIAL") + len("MIIBSECRETKEYMATERIAL") + 3
|
|
require.Less(t, cut, strings.Index(content, "-----END"),
|
|
"test setup: cut must land before the END marker")
|
|
truncatedSnippet := content[:cut] + "…"
|
|
|
|
d.SetVectorSearcher(&fakeVectorSearcher{hits: []VectorHit{
|
|
{SessionID: "s1", Ordinal: 0, Score: 0.9, Snippet: truncatedSnippet},
|
|
}})
|
|
|
|
page, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "attached key", Mode: "semantic", Limit: 50,
|
|
})
|
|
require.NoError(t, err, "SearchContent")
|
|
require.Len(t, page.Matches, 1, "matches")
|
|
assert.NotContains(t, page.Matches[0].Snippet, "SECRETKEYMATERIAL",
|
|
"semantic snippet leaked key material truncated out of the chunk")
|
|
assert.Contains(t, page.Matches[0].Snippet, "attached key",
|
|
"snippet lost the matched context")
|
|
}
|
|
|
|
// TestSearchContentSemanticSubordinatePenaltyReorders pins the spec's
|
|
// one-leg fusion requirement: semantic-only results route through the same
|
|
// RRF merge hybrid uses, so a subordinate unit ranked (by cosine score)
|
|
// above a top-level unit drops below it, while each match keeps the
|
|
// searcher's own score.
|
|
func TestSearchContentSemanticSubordinatePenaltyReorders(t *testing.T) {
|
|
d := testDB(t)
|
|
seedSearchSession(t, d, "subchain", "alpha", [][2]string{
|
|
{"assistant", "sidechain answer text"},
|
|
})
|
|
seedSearchSession(t, d, "toplevel", "alpha", [][2]string{
|
|
{"user", "top-level question text"},
|
|
})
|
|
d.SetVectorSearcher(&fakeVectorSearcher{hits: []VectorHit{
|
|
{SessionID: "subchain", Ordinal: 0, Subordinate: true,
|
|
Score: 0.9, Snippet: "sidechain answer text"},
|
|
{SessionID: "toplevel", Ordinal: 0,
|
|
Score: 0.5, Snippet: "top-level question text"},
|
|
}})
|
|
|
|
page, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "answer", Mode: "semantic", Limit: 50,
|
|
})
|
|
require.NoError(t, err, "SearchContent")
|
|
require.Len(t, page.Matches, 2, "matches")
|
|
assert.Equal(t, "toplevel", page.Matches[0].SessionID,
|
|
"top-level unit must overtake the subordinate unit after the one-leg merge")
|
|
assert.Equal(t, "subchain", page.Matches[1].SessionID)
|
|
require.NotNil(t, page.Matches[0].Score)
|
|
assert.InDelta(t, 0.5, *page.Matches[0].Score, 0.0001,
|
|
"semantic mode keeps the searcher's score, not the fusion score")
|
|
}
|
|
|
|
// TestSearchContentSemanticMatchCarriesUnitRangeAndLineage pins the API
|
|
// surface added for run-grouped units: a semantic match exposes the unit's
|
|
// ordinal range, subordinate flag, and lineage (relationship, parent session,
|
|
// anchor sidechain flag) while Ordinal stays the anchor ordinal; a top-level
|
|
// single-message unit leaves them all zero so its JSON is unchanged.
|
|
func TestSearchContentSemanticMatchCarriesUnitRangeAndLineage(t *testing.T) {
|
|
d := testDB(t)
|
|
insertSession(t, d, "parent", "proj", func(s *Session) {
|
|
s.UserMessageCount = 2
|
|
})
|
|
insertSession(t, d, "child", "proj", func(s *Session) {
|
|
s.UserMessageCount = 2
|
|
s.ParentSessionID = Ptr("parent")
|
|
s.RelationshipType = "subagent"
|
|
})
|
|
require.NoError(t, d.ReplaceSessionMessages("parent", []Message{
|
|
{SessionID: "parent", Ordinal: 0, Role: "user",
|
|
Content: "top-level step question", Timestamp: "2026-05-20T12:00:00Z"},
|
|
}))
|
|
require.NoError(t, d.ReplaceSessionMessages("child", []Message{
|
|
{SessionID: "child", Ordinal: 0, Role: "user",
|
|
Content: "subagent prompt", Timestamp: "2026-05-20T12:00:01Z"},
|
|
{SessionID: "child", Ordinal: 1, Role: "assistant", IsSidechain: true,
|
|
Content: "sidechain step one", Timestamp: "2026-05-20T12:00:02Z"},
|
|
{SessionID: "child", Ordinal: 2, Role: "assistant", IsSidechain: true,
|
|
Content: "sidechain step two", Timestamp: "2026-05-20T12:00:03Z"},
|
|
}))
|
|
d.SetVectorSearcher(&fakeVectorSearcher{hits: []VectorHit{
|
|
{SessionID: "child", Ordinal: 1, OrdinalStart: 1, OrdinalEnd: 2,
|
|
Subordinate: true, Score: 0.9, Snippet: "sidechain step one"},
|
|
{SessionID: "parent", Ordinal: 0, OrdinalStart: 0, OrdinalEnd: 0,
|
|
Score: 0.5, Snippet: "top-level step question"},
|
|
}})
|
|
|
|
page, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "step", Mode: "semantic", Limit: 50,
|
|
})
|
|
require.NoError(t, err, "SearchContent")
|
|
require.Len(t, page.Matches, 2, "matches")
|
|
byID := map[string]ContentMatch{}
|
|
for _, m := range page.Matches {
|
|
byID[m.SessionID] = m
|
|
}
|
|
|
|
sub, ok := byID["child"]
|
|
require.True(t, ok, "subordinate run hit present")
|
|
assert.Equal(t, 1, sub.Ordinal, "Ordinal stays the anchor ordinal")
|
|
assert.Equal(t, [2]int{1, 2}, sub.OrdinalRange, "OrdinalRange spans the unit")
|
|
assert.True(t, sub.Subordinate, "Subordinate carries the unit flag")
|
|
assert.Equal(t, "subagent", sub.Relationship)
|
|
assert.Equal(t, "parent", sub.ParentSessionID)
|
|
assert.True(t, sub.Sidechain, "anchor message is_sidechain")
|
|
|
|
data, err := json.Marshal(sub)
|
|
require.NoError(t, err)
|
|
for _, want := range []string{
|
|
`"ordinal":1`, `"ordinal_range":[1,2]`,
|
|
`"subordinate":true`, `"relationship":"subagent"`,
|
|
`"parent_session_id":"parent"`, `"is_sidechain":true`,
|
|
} {
|
|
assert.Contains(t, string(data), want)
|
|
}
|
|
|
|
top, ok := byID["parent"]
|
|
require.True(t, ok, "top-level hit present")
|
|
assert.Equal(t, [2]int{0, 0}, top.OrdinalRange)
|
|
assert.False(t, top.Subordinate)
|
|
assert.Empty(t, top.Relationship)
|
|
assert.Empty(t, top.ParentSessionID)
|
|
assert.False(t, top.Sidechain)
|
|
}
|
|
|
|
// TestContentMatchJSONUnitFieldsOmittedForLexicalMatches guards the lexical
|
|
// modes' wire format: a substring match always carries an ordinal_range (here
|
|
// the derived [0,0] — a user row is its own conversation unit), while the
|
|
// omitempty lineage keys stay absent when zero-valued (top-level session, no
|
|
// sidechain), keeping FTS/substring/regex responses free of noise keys.
|
|
func TestContentMatchJSONUnitFieldsOmittedForLexicalMatches(t *testing.T) {
|
|
d := testDB(t)
|
|
seedSearchSession(t, d, "s1", "proj", [][2]string{
|
|
{"user", "find the zebra here"},
|
|
})
|
|
|
|
page, err := d.SearchContent(context.Background(), ContentSearchFilter{
|
|
Pattern: "zebra", Mode: "substring", Sources: []string{"messages"}, Limit: 50,
|
|
})
|
|
require.NoError(t, err, "SearchContent")
|
|
require.Len(t, page.Matches, 1)
|
|
|
|
data, err := json.Marshal(page.Matches[0])
|
|
require.NoError(t, err)
|
|
assert.Contains(t, string(data), `"ordinal_range":[0,0]`,
|
|
"lexical match carries the derived unit range (user row at ordinal 0)")
|
|
for _, key := range []string{
|
|
"score", "ordinal_start", "ordinal_end", "subordinate",
|
|
"relationship", "parent_session_id", "is_sidechain",
|
|
} {
|
|
assert.NotContains(t, string(data), `"`+key+`"`,
|
|
"lexical match JSON must not grow semantic-only keys")
|
|
}
|
|
}
|