Files
kenn-io--agentsview/internal/db/search_content_semantic_test.go
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

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")
}
}