Skip to content
34 changes: 26 additions & 8 deletions cmd/agentsview/session_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,32 @@ func newSessionListCommand() *cobra.Command {
HasSecret: hasSecret,
Cursor: cursor,
Limit: limit,
OrderBy: sort,
}
if cmd.Flags().Changed("min-tool-failures") {
f.MinToolFailures = &minToolFailures
}
// --reverse flips the sort key's canonical direction; leave
// Descending nil otherwise so the default applies.
if cmd.Flags().Changed("reverse") {
d := db.SortDefaultDescending(sort) != reverse
f.Descending = &d
// Parse the multi-key sort spec; --reverse flips the natural
// direction of any term left without an explicit :asc/:desc, which
// is folded into the canonical spec string so the wire form fully
// captures the ordering.
keys, err := db.ParseSortSpec(sort)
if err != nil {
return fmt.Errorf("invalid sort %q: %w", sort, err)
}
// An empty spec means the implicit default; materialize it so
// --reverse has a term to flip instead of silently no-opping.
if len(keys) == 0 {
keys = []db.SortKey{{Key: db.DefaultSortKey()}}
}
if reverse {
for i := range keys {
if keys[i].Descending == nil {
d := !db.SortDefaultDescending(keys[i].Key)
keys[i].Descending = &d
}
}
}
f.OrderBy = db.FormatSortSpec(keys)

list, err := svc.List(cmd.Context(), f)
if err != nil {
Expand Down Expand Up @@ -129,9 +144,12 @@ func newSessionListCommand() *cobra.Command {
db.DefaultSessionLimit, db.MaxSessionLimit,
))
flags.StringVar(&sort, "sort", "recent",
"Sort by: "+strings.Join(db.SortKeys(), ", "))
"Sort by a comma-separated list of keys, each optionally key:asc or "+
"key:desc (e.g. messages:desc,started:asc). Keys: "+
strings.Join(db.SortKeys(), ", "))
flags.BoolVarP(&reverse, "reverse", "r", false,
"Reverse the sort direction")
"Reverse the natural direction of sort keys that have no explicit "+
":asc/:desc suffix")

return cmd
}
Expand Down
56 changes: 56 additions & 0 deletions cmd/agentsview/sort_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,62 @@ func TestSessionList_SortAndReverse(t *testing.T) {
assert.Equal(t, []string{"hi", "mid", "lo"}, sessionListIDs(t, out))
}

func TestSessionList_MultiKeySort(t *testing.T) {
dataDir := t.TempDir()
t.Setenv("AGENTSVIEW_DATA_DIR", dataDir)
seedSessionWithOpts(t, dataDir, "a", "p", func(s *db.Session) {
s.MessageCount = 1
s.StartedAt = new("2024-03-01T00:00:00Z")
})
seedSessionWithOpts(t, dataDir, "b", "p", func(s *db.Session) {
s.MessageCount = 1
s.StartedAt = new("2024-01-01T00:00:00Z")
})
seedSessionWithOpts(t, dataDir, "c", "p", func(s *db.Session) {
s.MessageCount = 2
s.StartedAt = new("2024-02-01T00:00:00Z")
})

// Per-key directions: messages asc, then started desc.
out, err := executeCommand(newRootCommand(),
"session", "list", "--sort", "messages:asc,started:desc", "--format", "json")
require.NoError(t, err)
assert.Equal(t, []string{"a", "b", "c"}, sessionListIDs(t, out))

// --reverse flips only the unsuffixed key (messages -> desc); the explicit
// started:asc is left untouched.
out, err = executeCommand(newRootCommand(),
"session", "list", "--sort", "messages,started:asc", "-r", "--format", "json")
require.NoError(t, err)
assert.Equal(t, []string{"c", "b", "a"}, sessionListIDs(t, out))
}

// TestSessionList_EmptySortReverse guards the edge where --sort is explicitly
// cleared: --reverse must still flip the implicit default recent sort (to
// ascending) rather than silently no-opping.
func TestSessionList_EmptySortReverse(t *testing.T) {
dataDir := t.TempDir()
t.Setenv("AGENTSVIEW_DATA_DIR", dataDir)
seedSessionWithOpts(t, dataDir, "old", "p", func(s *db.Session) {
s.EndedAt = new("2024-01-01T00:00:00Z")
})
seedSessionWithOpts(t, dataDir, "new", "p", func(s *db.Session) {
s.EndedAt = new("2024-03-01T00:00:00Z")
})

// Default recent is newest-first.
out, err := executeCommand(newRootCommand(),
"session", "list", "--sort", "", "--format", "json")
require.NoError(t, err)
assert.Equal(t, []string{"new", "old"}, sessionListIDs(t, out))

// --reverse on the empty (default) sort flips recent to oldest-first.
out, err = executeCommand(newRootCommand(),
"session", "list", "--sort", "", "--reverse", "--format", "json")
require.NoError(t, err)
assert.Equal(t, []string{"old", "new"}, sessionListIDs(t, out))
}

func TestSessionList_InvalidSort(t *testing.T) {
dataDir := t.TempDir()
t.Setenv("AGENTSVIEW_DATA_DIR", dataDir)
Expand Down
75 changes: 50 additions & 25 deletions internal/db/query_dialect.go
Original file line number Diff line number Diff line change
Expand Up @@ -253,40 +253,65 @@ func (d QueryDialect) timestampExpr(col string) string {
return col
}

// OrderByClause renders the session-list ordering for a resolved sort and
// direction, with id as a same-direction tie-breaker so keyset pagination is
// deterministic. The sort expression may add bind parameters (secrets sort), so
// OrderByClause renders the session-list ordering for the resolved sort terms,
// each in its own direction, with id appended as a unique same-direction
// tie-breaker (unless id is already a sort term) so keyset pagination is
// deterministic. Sort expressions may add bind parameters (the secrets sort), so
// callers must render this at its textual position.
func (b *QueryBuilder) OrderByClause(sp SessionSort, desc bool, f SessionFilter) string {
orderExpr := sp.orderExpr(b, desc, f)
dir := "ASC"
if desc {
dir = "DESC"
}
if orderExpr == "id" {
return "ORDER BY id " + dir
func (b *QueryBuilder) OrderByClause(rs []ResolvedSort, f SessionFilter) string {
cols := appendIDTiebreaker(rs)
parts := make([]string, len(cols))
for i, c := range cols {
parts[i] = c.Sort.orderExpr(b, c.Desc, f) + " " + orderDirSQL(c.Desc)
}
return "ORDER BY " + orderExpr + " " + dir + ", id " + dir
return "ORDER BY " + strings.Join(parts, ", ")
}

// CursorPredicate renders the keyset pagination predicate matching an
// OrderByClause built from the same sort and direction. The bound value is cast
// per dialect for the column's kind; it must already be the Go type produced by
// SessionSort.CursorPredicateValue.
// OrderByClause built from the same sort terms. Because per-key directions may
// differ, the predicate is the lexicographic expansion
//
// (c1 OP1 v1) OR (c1 = v1 AND c2 OP2 v2) OR ...
//
// rather than a single row-value comparison (which is only valid when every
// column shares one direction). Each value is bound and cast per dialect for its
// column kind, and must already be the Go type produced by CursorPredicateValues
// (one value per resolved term, in order). Sort expressions are re-rendered for
// each clause they appear in so any bind parameters they add (the secrets sort)
// stay positionally aligned across dialects.
func (b *QueryBuilder) CursorPredicate(
sp SessionSort, desc bool, f SessionFilter, value any, id string,
rs []ResolvedSort, f SessionFilter, values []any, id string,
) string {
orderExpr := sp.orderExpr(b, desc, f)
op := ">"
if desc {
op = "<"
cols := appendIDTiebreaker(rs)
vals := values
if len(cols) > len(rs) {
vals = append(append(make([]any, 0, len(cols)), values...), id)
}
clauses := make([]string, 0, len(cols))
for j := range cols {
parts := make([]string, 0, j+1)
for i := range j {
e := cols[i].Sort.orderExpr(b, cols[i].Desc, f)
vp := b.dialect.castCursor(b.Add(vals[i]), cols[i].Sort.kind)
parts = append(parts, e+" = "+vp)
}
op := ">"
if cols[j].Desc {
op = "<"
}
e := cols[j].Sort.orderExpr(b, cols[j].Desc, f)
vp := b.dialect.castCursor(b.Add(vals[j]), cols[j].Sort.kind)
parts = append(parts, e+" "+op+" "+vp)
clauses = append(clauses, "("+strings.Join(parts, " AND ")+")")
}
if orderExpr == "id" {
return "id " + op + " " + b.dialect.castCursor(b.Add(id), kindText)
return "(" + strings.Join(clauses, " OR ") + ")"
}

func orderDirSQL(desc bool) string {
if desc {
return "DESC"
}
vp := b.dialect.castCursor(b.Add(value), sp.kind)
ip := b.Add(id)
return "(" + orderExpr + ", id) " + op + " (" + vp + ", " + ip + ")"
return "ASC"
}

// LimitOffset renders a parameterized LIMIT/OFFSET clause.
Expand Down
51 changes: 42 additions & 9 deletions internal/db/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,33 @@ type SessionCursor struct {
// Value is the sort column's value for the page's last row, encoded as a
// string and re-typed per the sort's kind when comparing.
Value string `json:"v,omitempty"`
// Keys carries one keyset term per column for multi-key sorts. When present
// it is authoritative; the single-key Sort/Desc/Value (and EndedAt) fields
// are only populated for single-key sorts so older readers still decode.
Keys []SessionCursorKey `json:"ks,omitempty"`
}

// SessionCursorKey is one column's keyset term inside a multi-key cursor: the
// sort key it was minted under, its direction, and the page's last-row value
// (re-typed per the sort's kind when comparing).
type SessionCursorKey struct {
Sort string `json:"k"`
Desc bool `json:"d,omitempty"`
Value string `json:"v,omitempty"`
}

// resolvedKeys returns the cursor's keyset terms, synthesizing the single-key
// list from the legacy fields when the multi-key Keys slice is absent. A cursor
// with neither Keys nor Sort is a pre-sort legacy token, valid only for the
// default recent-descending order it was always minted under.
func (cur SessionCursor) resolvedKeys() []SessionCursorKey {
if len(cur.Keys) > 0 {
return cur.Keys
}
if cur.Sort != "" {
return []SessionCursorKey{{Sort: cur.Sort, Desc: cur.Desc, Value: cur.Value}}
}
return []SessionCursorKey{{Sort: defaultSortKey, Desc: true, Value: cur.EndedAt}}
}

// EncodeCursor returns a base64-encoded, HMAC-signed cursor string.
Expand Down Expand Up @@ -319,10 +346,17 @@ type SessionFilter struct {
// "unclean" → only sessions with status IN
// ('tool_call_pending', 'truncated')
Termination string
// OrderBy selects the sort column ("" = recent activity, the default).
// Valid keys are enumerated by SortKeys / ValidSortKey.
// Sort is the ordered, structured sort specification: each term is a sort
// key with an optional per-key direction. When non-empty it is the canonical
// source of ordering and takes precedence over OrderBy/Descending. This is
// the field new callers should set to express per-key sort direction.
Sort []SortKey
// OrderBy is the legacy single-key shorthand, kept for existing callers. It
// accepts the same comma-separated "key:dir" spec ParseSortSpec parses and is
// used only when Sort is empty. "" means recent activity, the default.
OrderBy string
// Descending overrides the sort key's canonical direction when non-nil.
// Descending is the legacy fallback direction applied to OrderBy terms that
// carry no explicit direction. Used only when Sort is empty.
Descending *bool
}

Expand Down Expand Up @@ -436,8 +470,7 @@ func (db *DB) ListSessions(
where, args := buildSessionFilter(f)

dialect := SQLiteQueryDialect()
sp, _ := SessionSortFor(f.OrderBy)
desc := sp.ResolveDescending(f.Descending)
rs := ResolveSort(f)

var total int
var cur SessionCursor
Expand Down Expand Up @@ -467,18 +500,18 @@ func (db *DB) ListSessions(
pageBuilder := NewQueryBuilder(dialect, len(args))
cursorWhere := where
if f.Cursor != "" {
val, err := sp.CursorPredicateValue(cur, desc)
vals, err := CursorPredicateValues(cur, rs)
if err != nil {
return SessionPage{}, err
}
cursorWhere += " AND " + pageBuilder.CursorPredicate(
sp, desc, f, val, cur.ID,
rs, f, vals, cur.ID,
)
}

query := "SELECT " + sessionBaseCols +
" FROM sessions WHERE " + cursorWhere + " " +
pageBuilder.OrderByClause(sp, desc, f) + " " +
pageBuilder.OrderByClause(rs, f) + " " +
pageBuilder.Limit(f.Limit+1)
cursorArgs = append(cursorArgs, pageBuilder.Args()...)

Expand All @@ -499,7 +532,7 @@ func (db *DB) ListSessions(
page.Sessions = sessions[:f.Limit]
last := page.Sessions[f.Limit-1]
page.NextCursor = db.EncodeCursor(
sp.NextCursor(&last, desc, total, f),
NextSessionCursor(&last, rs, total, f),
)
}

Expand Down
Loading