Files
kenn-io--agentsview/internal/db/search_content_scope_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

344 lines
13 KiB
Go

package db
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// seedSubagentSession inserts a subagent child session (a sidebar-child
// relationship, excluded by default from session lists) with the given
// messages for scope-filter tests.
func seedSubagentSession(
t *testing.T, d *DB, id, parent, project string, msgs [][2]string,
) {
t.Helper()
insertSession(t, d, id, project, func(s *Session) {
s.Agent = "claude"
s.UserMessageCount = 2
s.ParentSessionID = Ptr(parent)
s.RelationshipType = "subagent"
})
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")
}
// seedScopeFixture seeds one top-level session ("top") and one subagent
// child ("sub"), both matching the FTS pattern "zebra", and returns a
// searcher whose hits cover both (the subagent hit flagged Subordinate).
func seedScopeFixture(t *testing.T, d *DB) *fakeVectorSearcher {
t.Helper()
seedSearchSession(t, d, "top", "proj", [][2]string{
{"user", "zebra question at top level"},
})
seedSubagentSession(t, d, "sub", "top", "proj", [][2]string{
{"user", "zebra prompt inside the subagent"},
})
return &fakeVectorSearcher{hits: []VectorHit{
{SessionID: "sub", Ordinal: 0, Subordinate: true, Score: 0.9,
Snippet: "zebra prompt inside the subagent"},
{SessionID: "top", Ordinal: 0, Score: 0.5,
Snippet: "zebra question at top level"},
}}
}
func matchSessionIDs(page ContentSearchPage) []string {
ids := make([]string, 0, len(page.Matches))
for _, m := range page.Matches {
ids = append(ids, m.SessionID)
}
return ids
}
// requireHybridReady skips hybrid-mode subtests when FTS5 is unavailable.
func requireHybridReady(t *testing.T, d *DB, mode string) {
t.Helper()
if mode == "hybrid" && !d.HasFTS() {
t.Skip("fts5 not available")
}
}
// TestSearchContentScopeDefaultAllIncludesSubordinate is the precedence
// rule's critical test: with the default scope ("all") a subagent-session
// unit IS returned by semantic and hybrid search even though
// IncludeChildren is false — include_children must not hide subordinate
// units from either leg in these modes.
func TestSearchContentScopeDefaultAllIncludesSubordinate(t *testing.T) {
for _, mode := range []string{"semantic", "hybrid"} {
t.Run(mode, func(t *testing.T) {
d := testDB(t)
requireHybridReady(t, d, mode)
d.SetVectorSearcher(seedScopeFixture(t, d))
page, err := d.SearchContent(context.Background(), ContentSearchFilter{
Pattern: "zebra", Mode: mode, Limit: 50,
})
require.NoError(t, err, "SearchContent")
ids := matchSessionIDs(page)
assert.Contains(t, ids, "sub",
"subordinate unit must be visible with the default scope despite IncludeChildren=false")
assert.Contains(t, ids, "top")
})
}
}
// TestSearchContentScopeTopExcludesSubordinate pins scope=top: subordinate
// units are excluded entirely from both modes.
func TestSearchContentScopeTopExcludesSubordinate(t *testing.T) {
for _, mode := range []string{"semantic", "hybrid"} {
t.Run(mode, func(t *testing.T) {
d := testDB(t)
requireHybridReady(t, d, mode)
d.SetVectorSearcher(seedScopeFixture(t, d))
page, err := d.SearchContent(context.Background(), ContentSearchFilter{
Pattern: "zebra", Mode: mode, Scope: "top", Limit: 50,
})
require.NoError(t, err, "SearchContent")
assert.Equal(t, []string{"top"}, matchSessionIDs(page))
})
}
}
// TestSearchContentScopeSubordinateOnly pins scope=subordinate: only
// subordinate units are returned.
func TestSearchContentScopeSubordinateOnly(t *testing.T) {
for _, mode := range []string{"semantic", "hybrid"} {
t.Run(mode, func(t *testing.T) {
d := testDB(t)
requireHybridReady(t, d, mode)
d.SetVectorSearcher(seedScopeFixture(t, d))
page, err := d.SearchContent(context.Background(), ContentSearchFilter{
Pattern: "zebra", Mode: mode, Scope: "subordinate", Limit: 50,
})
require.NoError(t, err, "SearchContent")
assert.Equal(t, []string{"sub"}, matchSessionIDs(page))
})
}
}
// TestSearchContentScopeSupersedesIncludeChildren pins that explicit
// include_children (either value) does not change the semantic-mode unit
// universe: scope governs visibility, so false and true return the same
// session set.
func TestSearchContentScopeSupersedesIncludeChildren(t *testing.T) {
d := testDB(t)
searcher := seedScopeFixture(t, d)
d.SetVectorSearcher(searcher)
withoutChildren, err := d.SearchContent(context.Background(), ContentSearchFilter{
Pattern: "zebra", Mode: "semantic", IncludeChildren: false, Limit: 50,
})
require.NoError(t, err, "SearchContent IncludeChildren=false")
withChildren, err := d.SearchContent(context.Background(), ContentSearchFilter{
Pattern: "zebra", Mode: "semantic", IncludeChildren: true, Limit: 50,
})
require.NoError(t, err, "SearchContent IncludeChildren=true")
assert.Equal(t, matchSessionIDs(withChildren), matchSessionIDs(withoutChildren),
"include_children must be superseded in semantic mode")
assert.Contains(t, matchSessionIDs(withoutChildren), "sub")
}
// TestSearchContentScopeStillAppliesSessionFilters guards that lifting the
// child exclusion in semantic mode does not lift the other session
// predicates: project, automated, and one-shot filtering still drop hits.
func TestSearchContentScopeStillAppliesSessionFilters(t *testing.T) {
seedExtra := func(t *testing.T, d *DB) *fakeVectorSearcher {
t.Helper()
searcher := seedScopeFixture(t, d)
// ReplaceSessionMessages recomputes is_automated from the stored
// transcript, so the automated session must genuinely classify as
// automated: one user message with a recognized automation prefix.
autoContent := "You are a code reviewer. Find the zebra issue."
insertSession(t, d, "auto", "proj", func(s *Session) {
s.Agent = "claude"
s.UserMessageCount = 1
s.FirstMessage = Ptr(autoContent)
})
require.NoError(t, d.ReplaceSessionMessages("auto", []Message{
{SessionID: "auto", Ordinal: 0, Role: "user",
Content: autoContent, Timestamp: "2026-05-20T12:00:00Z"},
}))
insertSession(t, d, "oneshot", "proj", func(s *Session) {
s.Agent = "claude"
s.UserMessageCount = 1
})
require.NoError(t, d.ReplaceSessionMessages("oneshot", []Message{
{SessionID: "oneshot", Ordinal: 0, Role: "user",
Content: "zebra one-shot", Timestamp: "2026-05-20T12:00:00Z"},
}))
insertSession(t, d, "elsewhere", "otherproj", func(s *Session) {
s.Agent = "claude"
s.UserMessageCount = 2
})
require.NoError(t, d.ReplaceSessionMessages("elsewhere", []Message{
{SessionID: "elsewhere", Ordinal: 0, Role: "user",
Content: "zebra elsewhere", Timestamp: "2026-05-20T12:00:00Z"},
}))
searcher.hits = append(searcher.hits,
VectorHit{SessionID: "auto", Ordinal: 0, Score: 0.4, Snippet: "zebra from automation"},
VectorHit{SessionID: "oneshot", Ordinal: 0, Score: 0.3, Snippet: "zebra one-shot"},
VectorHit{SessionID: "elsewhere", Ordinal: 0, Score: 0.2, Snippet: "zebra elsewhere"},
)
return searcher
}
t.Run("defaults drop automated one-shot and other projects", func(t *testing.T) {
d := testDB(t)
d.SetVectorSearcher(seedExtra(t, d))
page, err := d.SearchContent(context.Background(), ContentSearchFilter{
Pattern: "zebra", Mode: "semantic", Project: "proj", Limit: 50,
})
require.NoError(t, err, "SearchContent")
assert.ElementsMatch(t, []string{"top", "sub"}, matchSessionIDs(page))
})
t.Run("opt-ins restore automated and one-shot", func(t *testing.T) {
d := testDB(t)
d.SetVectorSearcher(seedExtra(t, d))
page, err := d.SearchContent(context.Background(), ContentSearchFilter{
Pattern: "zebra", Mode: "semantic", Project: "proj",
IncludeAutomated: true, IncludeOneShot: true, Limit: 50,
})
require.NoError(t, err, "SearchContent")
assert.ElementsMatch(t, []string{"top", "sub", "auto", "oneshot"},
matchSessionIDs(page))
})
}
// TestSearchContentFTSModeIncludeChildrenUnchanged is the regression guard
// for the non-semantic paths: mode "fts" keeps today's include_children
// semantics — a subagent child is hidden by default and reachable only via
// IncludeChildren=true.
func TestSearchContentFTSModeIncludeChildrenUnchanged(t *testing.T) {
d := testDB(t)
if !d.HasFTS() {
t.Skip("fts5 not available")
}
d.SetVectorSearcher(seedScopeFixture(t, d))
base := ContentSearchFilter{
Pattern: "zebra", Mode: "fts", Sources: []string{"messages"}, Limit: 50,
}
page, err := d.SearchContent(context.Background(), base)
require.NoError(t, err, "SearchContent fts default")
assert.Equal(t, []string{"top"}, matchSessionIDs(page),
"fts mode must keep excluding sidebar children by default")
base.IncludeChildren = true
page, err = d.SearchContent(context.Background(), base)
require.NoError(t, err, "SearchContent fts include_children")
assert.ElementsMatch(t, []string{"top", "sub"}, matchSessionIDs(page))
}
// seedOneShotSubagentFixture seeds a normal top-level session ("top"), a
// one-shot subagent child ("sub1": exactly one user message, the shape
// nearly all non-automated subagent transcripts have), and a top-level
// one-shot ("solo"), all matching "zebra", plus a searcher covering all
// three (sub1 subordinate).
func seedOneShotSubagentFixture(t *testing.T, d *DB) *fakeVectorSearcher {
t.Helper()
seedSearchSession(t, d, "top", "proj", [][2]string{
{"user", "zebra question at top level"},
})
insertSession(t, d, "sub1", "proj", func(s *Session) {
s.Agent = "claude"
s.UserMessageCount = 1
s.ParentSessionID = Ptr("top")
s.RelationshipType = "subagent"
})
require.NoError(t, d.ReplaceSessionMessages("sub1", []Message{
{SessionID: "sub1", Ordinal: 0, Role: "user",
Content: "zebra prompt for the subagent", Timestamp: "2026-05-20T12:00:00Z"},
}), "ReplaceSessionMessages sub1")
insertSession(t, d, "solo", "proj", func(s *Session) {
s.Agent = "claude"
s.UserMessageCount = 1
})
require.NoError(t, d.ReplaceSessionMessages("solo", []Message{
{SessionID: "solo", Ordinal: 0, Role: "user",
Content: "zebra one-shot at top level", Timestamp: "2026-05-20T12:00:00Z"},
}), "ReplaceSessionMessages solo")
return &fakeVectorSearcher{hits: []VectorHit{
{SessionID: "sub1", Ordinal: 0, Subordinate: true, Score: 0.9,
Snippet: "zebra prompt for the subagent"},
{SessionID: "top", Ordinal: 0, Score: 0.5,
Snippet: "zebra question at top level"},
{SessionID: "solo", Ordinal: 0, Score: 0.4,
Snippet: "zebra one-shot at top level"},
}}
}
// TestSearchContentOneShotSubagentVisibleInSemanticModes pins the child
// carve-out from the one-shot gate: a subagent session with exactly one
// user message IS returned by semantic and hybrid under default filters
// (scope=all), while a TOP-LEVEL one-shot stays excluded.
func TestSearchContentOneShotSubagentVisibleInSemanticModes(t *testing.T) {
for _, mode := range []string{"semantic", "hybrid"} {
t.Run(mode, func(t *testing.T) {
d := testDB(t)
requireHybridReady(t, d, mode)
d.SetVectorSearcher(seedOneShotSubagentFixture(t, d))
page, err := d.SearchContent(context.Background(), ContentSearchFilter{
Pattern: "zebra", Mode: mode, Limit: 50,
})
require.NoError(t, err, "SearchContent")
ids := matchSessionIDs(page)
assert.Contains(t, ids, "sub1",
"one-shot subagent unit must survive the one-shot gate in %s mode", mode)
assert.Contains(t, ids, "top")
assert.NotContains(t, ids, "solo",
"top-level one-shot must keep being excluded by default")
})
}
}
// TestSearchContentFTSModeOneShotSubagentStillExcluded guards the untouched
// path: mode "fts" with default filters keeps excluding the one-shot
// subagent session (both as a child and as a one-shot).
func TestSearchContentFTSModeOneShotSubagentStillExcluded(t *testing.T) {
d := testDB(t)
if !d.HasFTS() {
t.Skip("fts5 not available")
}
d.SetVectorSearcher(seedOneShotSubagentFixture(t, d))
page, err := d.SearchContent(context.Background(), ContentSearchFilter{
Pattern: "zebra", Mode: "fts", Sources: []string{"messages"}, Limit: 50,
})
require.NoError(t, err, "SearchContent fts")
assert.Equal(t, []string{"top"}, matchSessionIDs(page),
"fts mode must keep today's one-shot and child exclusions")
}
// TestSearchContentScopeInvalidRejected pins the db-side backstop: an
// unknown scope value is a SearchInputError for both modes.
func TestSearchContentScopeInvalidRejected(t *testing.T) {
for _, mode := range []string{"semantic", "hybrid"} {
t.Run(mode, func(t *testing.T) {
d := testDB(t)
d.SetVectorSearcher(&fakeVectorSearcher{})
_, err := d.SearchContent(context.Background(), ContentSearchFilter{
Pattern: "zebra", Mode: mode, Scope: "bogus",
})
require.Error(t, err)
var inputErr *SearchInputError
assert.True(t, errors.As(err, &inputErr),
"expected *SearchInputError, got %T: %v", err, err)
})
}
}