Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
208 changes: 208 additions & 0 deletions internal/db/filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,123 @@ func TestIncludeChildrenExcludesOrphanSubagents(t *testing.T) {
requireSessions(t, d, f, []string{"root", "root-sub"})
}

// TestIncludeOrphansPromotesToRoot verifies that canonical orphan rows
// surface as synthetic roots when IncludeOrphans is enabled, and remain
// hidden when IncludeOrphans is false.
func TestIncludeOrphansPromotesToRoot(t *testing.T) {
Comment thread
rodboev marked this conversation as resolved.
d := testDB(t)

// Control: legitimate root with a subagent child.
insertSession(t, d, "root", "proj", func(s *Session) {
s.MessageCount = 10
s.UserMessageCount = 5
})
insertSession(t, d, "root-sub", "proj", func(s *Session) {
s.MessageCount = 2
s.UserMessageCount = 1
s.ParentSessionID = new("root")
s.RelationshipType = "subagent"
})

// Orphan subagent: parent doesn't exist in DB.
insertSession(t, d, "orphan-sub", "proj", func(s *Session) {
s.MessageCount = 2
s.UserMessageCount = 1
s.ParentSessionID = new("missing-parent")
s.RelationshipType = "subagent"
})

// Orphan fork: parent doesn't exist in DB.
insertSession(t, d, "orphan-fork", "proj", func(s *Session) {
s.MessageCount = 2
s.UserMessageCount = 1
s.ParentSessionID = new("also-missing")
s.RelationshipType = "fork"
})

insertSession(t, d, "orphan-grandchild", "proj", func(s *Session) {
s.MessageCount = 2
s.UserMessageCount = 1
s.ParentSessionID = new("orphan-sub")
s.RelationshipType = "fork"
})

insertSession(t, d, "continuation-orphan", "proj", func(s *Session) {
s.MessageCount = 2
s.UserMessageCount = 1
s.ParentSessionID = new("missing-continuation-parent")
s.RelationshipType = "continuation"
})

tests := []struct {
name string
includeOrphans bool
want []string
}{
{
name: "WithIncludeOrphans",
includeOrphans: true,
want: []string{
"root", "root-sub", "orphan-sub", "orphan-fork", "orphan-grandchild", "continuation-orphan",
},
},
{
name: "WithoutIncludeOrphans",
includeOrphans: false,
want: []string{"root", "root-sub"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := SessionFilter{
IncludeChildren: true,
IncludeOrphans: tt.includeOrphans,
}
requireSessions(t, d, f, tt.want)
})
}
}

// TestIncludeOrphansWithExcludeAutomated verifies that when both
// IncludeOrphans and ExcludeAutomated are set, automated orphans
// are still excluded while non-automated orphans are promoted to roots.
func TestIncludeOrphansWithExcludeAutomated(t *testing.T) {
d := testDB(t)

// Non-automated root.
insertSession(t, d, "root", "proj", func(s *Session) {
s.MessageCount = 10
s.UserMessageCount = 5
})

// Non-automated orphan subagent — should be included.
insertSession(t, d, "orphan-sub", "proj", func(s *Session) {
s.MessageCount = 2
s.UserMessageCount = 1
s.ParentSessionID = new("missing-parent")
s.RelationshipType = "subagent"
})

// Automated orphan fork — should be excluded.
fm := "You are a code reviewer. Review the code."
insertSession(t, d, "orphan-auto-fork", "proj", func(s *Session) {
s.FirstMessage = &fm
s.MessageCount = 2
s.UserMessageCount = 1
s.ParentSessionID = new("also-missing")
s.RelationshipType = "fork"
})

f := SessionFilter{
IncludeChildren: true,
IncludeOrphans: true,
ExcludeAutomated: true,
}
// Expected: root + non-automated orphan-sub, automated orphan-auto-fork excluded.
requireSessions(t, d, f, []string{"root", "orphan-sub"})
}

// TestIncludeChildrenKeepsNestedDescendants guards against a
// regression where a fork spawned inside a subagent thread
// (root → subagent → fork) was dropped. The direct-match side
Expand Down Expand Up @@ -1132,6 +1249,97 @@ func TestSidebarSessionIndexStarredIncludesStarredDescendantRoot(t *testing.T) {
requireSidebarIndexIDs(t, index.Sessions, []string{"root", "starred-child"})
}

func TestSidebarSessionIndexPagedPromotesNestedOrphans(t *testing.T) {
d := testDB(t)
ctx := context.Background()

insertSession(t, d, "root", "proj", func(s *Session) {
s.EndedAt = new("2024-01-20T00:00:00Z")
s.MessageCount = 5
s.UserMessageCount = 2
})
insertSession(t, d, "orphan-sub", "proj", func(s *Session) {
s.EndedAt = new("2024-01-19T00:00:00Z")
s.MessageCount = 3
s.UserMessageCount = 1
s.ParentSessionID = new("missing-parent")
s.RelationshipType = "subagent"
})
insertSession(t, d, "orphan-fork", "proj", func(s *Session) {
s.EndedAt = new("2024-01-18T00:00:00Z")
s.MessageCount = 2
s.UserMessageCount = 1
s.ParentSessionID = new("orphan-sub")
s.RelationshipType = "fork"
})
insertSession(t, d, "continuation-orphan", "proj", func(s *Session) {
s.EndedAt = new("2024-01-17T00:00:00Z")
s.MessageCount = 2
s.UserMessageCount = 1
s.ParentSessionID = new("missing-continuation-parent")
s.RelationshipType = "continuation"
})

index, err := d.GetSidebarSessionIndex(ctx, SessionFilter{Limit: 2})
requireNoError(t, err, "GetSidebarSessionIndex")
require.Equal(t, 3, index.Total, "total paged root groups")
require.NotEmpty(t, index.NextCursor, "paged sidebar should expose a next cursor when more root groups remain")
requireSidebarIndexIDs(t, index.Sessions, []string{"root", "orphan-sub", "orphan-fork"})
}

func TestSidebarSessionIndexPagedExcludesContinuationWithSoftDeletedParent(t *testing.T) {
d := testDB(t)
ctx := context.Background()

insertSession(t, d, "root", "proj", func(s *Session) {
s.EndedAt = new("2024-01-20T00:00:00Z")
s.MessageCount = 5
s.UserMessageCount = 2
})
insertSession(t, d, "deleted-parent", "proj", func(s *Session) {
s.EndedAt = new("2024-01-19T00:00:00Z")
s.MessageCount = 5
s.UserMessageCount = 2
})
require.NoError(t, d.SoftDeleteSession("deleted-parent"), "SoftDeleteSession")
insertSession(t, d, "continuation-child", "proj", func(s *Session) {
s.EndedAt = new("2024-01-18T00:00:00Z")
s.MessageCount = 2
s.UserMessageCount = 1
s.ParentSessionID = new("deleted-parent")
s.RelationshipType = "continuation"
})

index, err := d.GetSidebarSessionIndex(ctx, SessionFilter{Limit: 10})
requireNoError(t, err, "GetSidebarSessionIndex")
require.Equal(t, 1, index.Total, "soft-deleted parent should not promote continuation root")
requireSidebarIndexIDs(t, index.Sessions, []string{"root"})
}

func TestSidebarSessionIndexPagedKeepsContinuationUnderLiveParent(t *testing.T) {
d := testDB(t)
ctx := context.Background()

insertSession(t, d, "root", "proj", func(s *Session) {
s.EndedAt = new("2024-01-20T00:00:00Z")
s.MessageCount = 5
s.UserMessageCount = 2
})
insertSession(t, d, "continuation-child", "proj", func(s *Session) {
s.EndedAt = new("2024-01-19T00:00:00Z")
s.MessageCount = 2
s.UserMessageCount = 1
s.ParentSessionID = new("root")
s.RelationshipType = "continuation"
})

index, err := d.GetSidebarSessionIndex(ctx, SessionFilter{Limit: 1})
requireNoError(t, err, "GetSidebarSessionIndex")
require.Equal(t, 1, index.Total, "live continuation should stay nested")
require.Empty(t, index.NextCursor, "only one root group exists")
requireSidebarIndexIDs(t, index.Sessions, []string{"root", "continuation-child"})
}

func TestSidebarSessionIndexReturnsDisplayName(t *testing.T) {
d := testDB(t)

Expand Down
118 changes: 98 additions & 20 deletions internal/db/query_dialect.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,22 +28,24 @@ const (
// ORM: callers still own SELECTs, JOINs, backend-specific search paths, and
// table schemas.
type QueryDialect struct {
name string
placeholderStyle placeholderStyle
trueLiteral string
falseLiteral string
dateExpr string
dateParam func(string) string
activityExpr string
activityParam func(string) string
cursorActivityExpr string
cursorParam func(string) string
terminationExpr string
terminationKind timestampKind
caseInsensitiveLike string
caseInsensitiveLikeEsc string
regexPredicate func(string, string) string
nullsLast bool
name string
placeholderStyle placeholderStyle
trueLiteral string
falseLiteral string
dateExpr string
dateParam func(string) string
activityExpr string
activityParam func(string) string
cursorActivityExpr string
cursorParam func(string) string
terminationExpr string
terminationKind timestampKind
caseInsensitiveLike string
caseInsensitiveLikeEsc string
regexPredicate func(string, string) string
sidebarChildRelationships []string
canonicalChildRelationships []string
nullsLast bool
}

// SQLiteQueryDialect returns the SQLite SQL fragments used by the local store.
Expand All @@ -67,6 +69,8 @@ func SQLiteQueryDialect() QueryDialect {
regexPredicate: func(col, ph string) string {
return col + " REGEXP " + ph
},
sidebarChildRelationships: []string{"subagent", "fork"},
canonicalChildRelationships: []string{"subagent", "fork", "continuation"},
}
}

Expand Down Expand Up @@ -96,7 +100,9 @@ func PostgresQueryDialect() QueryDialect {
regexPredicate: func(col, ph string) string {
return col + " ~* " + ph
},
nullsLast: true,
sidebarChildRelationships: []string{"subagent", "fork"},
canonicalChildRelationships: []string{"subagent", "fork", "continuation"},
nullsLast: true,
}
}

Expand Down Expand Up @@ -125,7 +131,9 @@ func DuckDBQueryDialect() QueryDialect {
regexPredicate: func(col, ph string) string {
return "regexp_matches(" + col + ", " + ph + ")"
},
nullsLast: true,
sidebarChildRelationships: []string{"subagent", "fork"},
canonicalChildRelationships: []string{"subagent", "fork", "continuation"},
nullsLast: true,
}
}

Expand Down Expand Up @@ -238,6 +246,67 @@ func BuildSessionFilterSQL(
return where, b.Args()
}

// BuildSessionBaseFilterSQL returns the base sidebar/list predicates without the
// child-relationship exclusion. Callers that handle root-vs-child selection
// separately should use this to avoid diverging filter logic across backends.
func BuildSessionBaseFilterSQL(
f SessionFilter, dialect QueryDialect,
) (string, []any) {
b := NewQueryBuilder(dialect, 0)
preds := []string{
"message_count > 0",
"deleted_at IS NULL",
}
filterPreds, oneShotPred := sessionFilterPredicates(f, b, func(col string) string { return col })
preds = append(preds, filterPreds...)
if oneShotPred != "" {
preds = append(preds, oneShotPred)
}
return strings.Join(preds, " AND "), b.Args()
}

func (d QueryDialect) SidebarChildRelationshipsSQL() string {
quoted := make([]string, 0, len(d.sidebarChildRelationships))
for _, rel := range d.sidebarChildRelationships {
quoted = append(quoted, "'"+rel+"'")
}
return strings.Join(quoted, ", ")
}

func (d QueryDialect) CanonicalChildRelationshipsSQL() string {
quoted := make([]string, 0, len(d.canonicalChildRelationships))
for _, rel := range d.canonicalChildRelationships {
quoted = append(quoted, "'"+rel+"'")
}
return strings.Join(quoted, ", ")
}

func SidebarChildRelationshipPredicate(dialect QueryDialect, sessionAlias string) string {
return sessionAlias + ".relationship_type IN (" + dialect.SidebarChildRelationshipsSQL() + ")"
}

func CanonicalChildRelationshipPredicate(dialect QueryDialect, sessionAlias string) string {
return sessionAlias + ".relationship_type IN (" + dialect.CanonicalChildRelationshipsSQL() + ")"
}

func SidebarOrphanPredicate(sessionAlias, parentAlias string) string {
return `NOT EXISTS (
SELECT 1
FROM sessions ` + parentAlias + `
WHERE ` + parentAlias + `.id = ` + sessionAlias + `.parent_session_id
)`
}

func BuildCanonicalRootWhere(dialect QueryDialect, sessionAlias string, includeOrphans bool) string {
base := `NOT (` + CanonicalChildRelationshipPredicate(dialect, sessionAlias) + `)`
if !includeOrphans {
return base
}
return `(` + base + ` OR (` +
CanonicalChildRelationshipPredicate(dialect, sessionAlias) + ` AND ` +
SidebarOrphanPredicate(sessionAlias, "parent") + `))`
}

func buildSessionFilterWithBuilder(
f SessionFilter, b *QueryBuilder, qualifier string,
) string {
Expand All @@ -254,7 +323,7 @@ func buildSessionFilterWithBuilder(
}
if !f.IncludeChildren {
basePreds = append(basePreds,
q("relationship_type")+" NOT IN ('subagent', 'fork')")
q("relationship_type")+" NOT IN ("+b.dialect.SidebarChildRelationshipsSQL()+")")
}

if !f.IncludeChildren {
Expand All @@ -275,7 +344,7 @@ func buildSessionFilterWithBuilder(
rootMatchParts = append(rootMatchParts, oneShotPred)
}
rootMatchParts = append(rootMatchParts,
"root_session.relationship_type NOT IN ('subagent', 'fork')")
BuildCanonicalRootWhere(b.dialect, "root_session", f.IncludeOrphans))
rootMatch := strings.Join(rootMatchParts, " AND ")

cte := "WITH RECURSIVE tree(id) AS (" +
Expand Down Expand Up @@ -391,6 +460,15 @@ func sessionFilterPredicates(
return preds, oneShotPred
}

// buildSessionBaseFilter returns a WHERE clause and args containing the base
// predicates (message_count > 0, deleted_at IS NULL) plus user-facing filter
// predicates (project, machine, agent, date, etc.) WITHOUT the relationship_type
// exclusion. Callers that handle root-vs-child discrimination externally (e.g.
// via buildCanonicalRootWhere) should use this instead of buildSessionFilter.
func buildSessionBaseFilter(f SessionFilter) (string, []any) {
return BuildSessionBaseFilterSQL(f, SQLiteQueryDialect())
}

func inPredicate(col string, values []string, b *QueryBuilder) string {
if len(values) == 0 {
return "1 = 0"
Expand Down
Loading