diff --git a/internal/db/filter_test.go b/internal/db/filter_test.go index 82164bf8c..563948947 100644 --- a/internal/db/filter_test.go +++ b/internal/db/filter_test.go @@ -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) { + 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 @@ -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) diff --git a/internal/db/query_dialect.go b/internal/db/query_dialect.go index f02736976..257905be1 100644 --- a/internal/db/query_dialect.go +++ b/internal/db/query_dialect.go @@ -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. @@ -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"}, } } @@ -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, } } @@ -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, } } @@ -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 { @@ -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 { @@ -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 (" + @@ -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" diff --git a/internal/db/query_dialect_test.go b/internal/db/query_dialect_test.go index 0ea5d1d22..60356914c 100644 --- a/internal/db/query_dialect_test.go +++ b/internal/db/query_dialect_test.go @@ -171,7 +171,7 @@ func TestBuildSessionFilterSQLRendersIncludeChildrenCTE(t *testing.T) { assert.Contains(t, normalized, "JOIN tree t ON s.parent_session_id = t.id") assert.Contains(t, normalized, "id IN (WITH RECURSIVE tree(id) AS") assert.Contains(t, normalized, - "root_session.relationship_type NOT IN ('subagent', 'fork')") + "NOT (root_session.relationship_type IN ('subagent', 'fork', 'continuation'))") assert.NotContains(t, normalized, "relationship_type NOT IN ('subagent', 'fork') AND id IN") assert.Equal(t, tt.wantArgs, args) diff --git a/internal/db/sessions.go b/internal/db/sessions.go index bc2cb8848..4b0cd0e2b 100644 --- a/internal/db/sessions.go +++ b/internal/db/sessions.go @@ -296,6 +296,7 @@ type SessionFilter struct { ExcludeOneShot bool // exclude sessions with user_message_count <= 1 ExcludeAutomated bool // exclude sessions where is_automated = 1 IncludeChildren bool // include subagent sessions (for sidebar grouping) + IncludeOrphans bool // promote orphan child rows to sidebar roots Outcome []string // filter by outcome values HealthGrade []string // filter by health grade values MinToolFailures *int // minimum tool_failure_signal_count @@ -332,8 +333,6 @@ const activityExprSQLite = "CAST(strftime('%s', " + const sidebarActivityExprSQLiteS = "COALESCE(" + "NULLIF(s.ended_at, ''), NULLIF(s.started_at, ''), s.created_at)" -const sidebarChildRelationshipsSQL = "'subagent', 'fork', 'continuation'" - func sidebarStarredRootCTE(enabled bool) string { if !enabled { return "" @@ -353,6 +352,13 @@ func sidebarStarredRootJoin(enabled bool) string { return "JOIN eligible_roots e ON e.id = t.root_id" } +// buildCanonicalRootWhere returns a WHERE fragment that identifies canonical root +// sessions for sidebar pagination. Child rows remain nested under their parent +// unless IncludeOrphans explicitly promotes missing-parent child rows to roots. +func buildCanonicalRootWhere(includeOrphans bool) string { + return BuildCanonicalRootWhere(SQLiteQueryDialect(), "sessions", includeOrphans) +} + // buildTerminationPredSQLite returns a WHERE fragment and args for // the multi-state termination filter (active / stale / unclean). // The status value may be comma-separated to OR multiple states @@ -497,6 +503,7 @@ func (db *DB) GetSidebarSessionIndex( ctx context.Context, f SessionFilter, ) (SidebarSessionIndex, error) { f.IncludeChildren = true + f.IncludeOrphans = true if f.Limit > 0 || f.Cursor != "" || f.Starred { return db.getSidebarSessionIndexPage(ctx, f) @@ -580,18 +587,11 @@ func (db *DB) getSidebarSessionIndexPage( } rootFilter := f - rootFilter.IncludeChildren = false rootFilter.Cursor = "" rootFilter.Starred = false - rootWhere, rootArgs := buildSessionFilter(rootFilter) - canonicalRootWhere := ` - NOT EXISTS ( - SELECT 1 - FROM sessions parent - WHERE parent.id = sessions.parent_session_id - AND parent.deleted_at IS NULL - AND sessions.relationship_type IN (` + sidebarChildRelationshipsSQL + `) - )` + rootFilter.IncludeChildren = false + rootWhere, rootArgs := buildSessionBaseFilter(rootFilter) + canonicalRootWhere := buildCanonicalRootWhere(f.IncludeOrphans) var total int var cur SessionCursor diff --git a/internal/duckdb/store.go b/internal/duckdb/store.go index 6a752306d..9d189ae0a 100644 --- a/internal/duckdb/store.go +++ b/internal/duckdb/store.go @@ -272,6 +272,7 @@ func (s *Store) ListSessions(ctx context.Context, f db.SessionFilter) (db.Sessio func (s *Store) GetSidebarSessionIndex(ctx context.Context, f db.SessionFilter) (db.SidebarSessionIndex, error) { f.IncludeChildren = true + f.IncludeOrphans = true f.Cursor = "" f.Limit = 0 diff --git a/internal/postgres/sessions.go b/internal/postgres/sessions.go index 1d85e34e1..62ac5a0bb 100644 --- a/internal/postgres/sessions.go +++ b/internal/postgres/sessions.go @@ -81,8 +81,6 @@ const pgActivityExpr = "COALESCE(ended_at, started_at, created_at)" const pgSidebarActivityExprS = "COALESCE(s.ended_at, s.started_at, s.created_at)" -const pgSidebarChildRelationshipsSQL = "'subagent', 'fork', 'continuation'" - func pgSidebarStarredRootCTE(enabled bool) string { if !enabled { return "" @@ -237,6 +235,12 @@ func buildPGSessionFilter( return db.BuildSessionFilterSQL(f, db.PostgresQueryDialect()) } +func buildPGSessionBaseFilter( + f db.SessionFilter, +) (string, []any) { + return db.BuildSessionBaseFilterSQL(f, db.PostgresQueryDialect()) +} + // EncodeCursor returns a base64-encoded, HMAC-signed cursor. func (s *Store) EncodeCursor( endedAt, id string, total ...int, @@ -421,6 +425,7 @@ func (s *Store) GetSidebarSessionIndex( ctx context.Context, f db.SessionFilter, ) (db.SidebarSessionIndex, error) { f.IncludeChildren = true + f.IncludeOrphans = true if f.Limit > 0 || f.Cursor != "" || f.Starred { return s.getSidebarSessionIndexPage(ctx, f) @@ -482,15 +487,8 @@ func (s *Store) getSidebarSessionIndexPage( rootFilter.IncludeChildren = false rootFilter.Cursor = "" rootFilter.Starred = false - rootWhere, rootArgs := buildPGSessionFilter(rootFilter) - canonicalRootWhere := ` - NOT EXISTS ( - SELECT 1 - FROM sessions parent - WHERE parent.id = sessions.parent_session_id - AND parent.deleted_at IS NULL - AND sessions.relationship_type IN (` + pgSidebarChildRelationshipsSQL + `) - )` + rootWhere, rootArgs := buildPGSessionBaseFilter(rootFilter) + canonicalRootWhere := db.BuildCanonicalRootWhere(db.PostgresQueryDialect(), "sessions", f.IncludeOrphans) var total int var cur db.SessionCursor diff --git a/internal/postgres/store_test.go b/internal/postgres/store_test.go index c7196c860..04e755310 100644 --- a/internal/postgres/store_test.go +++ b/internal/postgres/store_test.go @@ -224,17 +224,17 @@ func insertSidebarIndexSession( id, machine, project, agent, first_message, display_name, started_at, ended_at, message_count, user_message_count, parent_session_id, - relationship_type, is_automated + relationship_type, is_automated, deleted_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7::timestamptz, $8::timestamptz, $9, - $10, $11, $12, $13 + $10, $11, $12, $13, $14::timestamptz ) `, row.id, row.machine, row.project, row.agent, row.firstMessage, row.displayName, row.startedAt, row.endedAt, row.messageCount, row.userMessageCount, row.parentSessionID, row.relationshipType, - row.isAutomated) + row.isAutomated, row.deletedAt) require.NoError(t, err, "inserting sidebar index session %s", id) } @@ -252,6 +252,7 @@ type sidebarIndexSessionSeed struct { parentSessionID *string relationshipType string isAutomated bool + deletedAt *string } func sidebarIndexRowsByID( @@ -595,6 +596,80 @@ func TestStoreGetSidebarSessionIndexStarredIncludesStarredDescendantRoot( ) } +func TestStoreGetSidebarSessionIndexPaginatesOrphanRoots(t *testing.T) { + pgURL := testPGURL(t) + store := ensureSidebarIndexStoreSchema(t, pgURL) + defer store.Close() + + insertSidebarIndexSession(t, store, "root", func(s *sidebarIndexSessionSeed) { + s.endedAt = "2024-01-20T00:00:00Z" + s.userMessageCount = 2 + }) + insertSidebarIndexSession(t, store, "orphan-sub", func(s *sidebarIndexSessionSeed) { + s.endedAt = "2024-01-19T00:00:00Z" + s.parentSessionID = strPtr("missing-parent") + s.relationshipType = "subagent" + }) + insertSidebarIndexSession(t, store, "orphan-fork", func(s *sidebarIndexSessionSeed) { + s.endedAt = "2024-01-18T00:00:00Z" + s.parentSessionID = strPtr("orphan-sub") + s.relationshipType = "fork" + }) + insertSidebarIndexSession(t, store, "continuation-orphan", func(s *sidebarIndexSessionSeed) { + s.endedAt = "2024-01-17T00:00:00Z" + s.parentSessionID = strPtr("missing-continuation-parent") + s.relationshipType = "continuation" + }) + + first, err := store.GetSidebarSessionIndex( + context.Background(), db.SessionFilter{Limit: 2}, + ) + require.NoError(t, err, "first page") + assert.Equal(t, 3, first.Total) + assert.NotEmpty(t, first.NextCursor) + assert.ElementsMatch(t, + []string{"root", "orphan-sub", "orphan-fork"}, + sidebarIndexIDs(first.Sessions), + ) + + second, err := store.GetSidebarSessionIndex( + context.Background(), db.SessionFilter{Limit: 2, Cursor: first.NextCursor}, + ) + require.NoError(t, err, "second page") + assert.Equal(t, 3, second.Total) + assert.Empty(t, second.NextCursor) + assert.Equal(t, []string{"continuation-orphan"}, sidebarIndexIDs(second.Sessions)) +} + +func TestStoreGetSidebarSessionIndexDoesNotPromoteSoftDeletedParentChildren(t *testing.T) { + pgURL := testPGURL(t) + store := ensureSidebarIndexStoreSchema(t, pgURL) + defer store.Close() + + rootID := "soft-deleted-root" + insertSidebarIndexSession(t, store, rootID, func(s *sidebarIndexSessionSeed) { + s.endedAt = "2024-01-20T00:00:00Z" + s.deletedAt = strPtr("2024-01-21T00:00:00Z") + }) + insertSidebarIndexSession(t, store, "child-of-deleted-parent", func(s *sidebarIndexSessionSeed) { + s.endedAt = "2024-01-19T00:00:00Z" + s.parentSessionID = &rootID + s.relationshipType = "subagent" + }) + insertSidebarIndexSession(t, store, "other", func(s *sidebarIndexSessionSeed) { + s.endedAt = "2024-01-18T00:00:00Z" + }) + + index, err := store.GetSidebarSessionIndex( + context.Background(), db.SessionFilter{Limit: 10}, + ) + require.NoError(t, err, "GetSidebarSessionIndex") + assert.ElementsMatch(t, + []string{"other"}, + sidebarIndexIDs(index.Sessions), + ) +} + func TestStoreGetSession(t *testing.T) { pgURL := testPGURL(t) ensureStoreSchema(t, pgURL)