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
344 lines
13 KiB
Go
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)
|
|
})
|
|
}
|
|
}
|