feat: sort AI sessions by last prompt time (#24440)

Previously, the sessions list sorted by `MIN(started_at)` across
interceptions, so sessions with old start times but recent activity
would sink to the bottom of the list regardless of how recently they
were used.

`ListAIBridgeSessions` now sorts by `COALESCE(MAX(prompt.created_at),
MIN(started_at)) DESC`, exposed as the non-nullable `last_active_at`
field. Sessions with prompts surface by last activity; sessions with no
prompts fall back to their start time.

The original implementation used two separate columns (`last_active_at`
as a nullable prompt timestamp and `sort_at` as the non-nullable cursor
key). This revision collapses them into a single `last_active_at` that
is always set — simplifying the SQL, the Go conversion, the API type,
and the frontend.

🤖 Generated with [Claude Code](https://claude.ai/claude-code)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeremy Ruppel
2026-04-22 12:06:49 -04:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent 60186b2489
commit c23abc691f
14 changed files with 340 additions and 59 deletions
+231 -11
View File
@@ -748,8 +748,14 @@ func TestAIBridgeListSessions(t *testing.T) {
ThreadRootInterceptionID: uuid.NullUUID{UUID: s2i1.ID, Valid: true},
ThreadParentInterceptionID: uuid.NullUUID{UUID: s2i1.ID, Valid: true},
}, &s2i2EndedAt)
dbgen.AIBridgeUserPrompt(t, db, database.InsertAIBridgeUserPromptParams{
InterceptionID: s2i1.ID,
Prompt: "prompt from session 2",
CreatedAt: now.Add(-30 * time.Minute),
})
// Session 3: Standalone interception (no client_session_id, no thread_root_id).
// No prompt; last_active_at falls back to started_at.
s3EndedAt := now.Add(-2*time.Hour + time.Minute)
s3i1 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: firstUser.UserID,
@@ -760,7 +766,7 @@ func TestAIBridgeListSessions(t *testing.T) {
// Session 4: Two distinct thread roots in one client_session_id.
s4i1EndedAt := now.Add(-3*time.Hour + time.Minute)
dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
s4i1 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: firstUser.UserID,
Provider: "anthropic",
Model: "claude-4",
@@ -775,6 +781,11 @@ func TestAIBridgeListSessions(t *testing.T) {
StartedAt: now.Add(-3*time.Hour + time.Minute),
ClientSessionID: sql.NullString{String: "session-multi", Valid: true},
}, &s4i2EndedAt)
dbgen.AIBridgeUserPrompt(t, db, database.InsertAIBridgeUserPromptParams{
InterceptionID: s4i1.ID,
Prompt: "prompt from session 4",
CreatedAt: now.Add(-150 * time.Minute),
})
//nolint:gocritic // Owner role is irrelevant here.
res, err := client.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{})
@@ -782,9 +793,9 @@ func TestAIBridgeListSessions(t *testing.T) {
require.EqualValues(t, 4, res.Count)
require.Len(t, res.Sessions, 4)
// Sessions ordered by started_at DESC: session-A (now), then
// thread-based (now-1h), then standalone (now-2h), then
// multi-thread (now-3h).
// Sessions ordered by last_active_at DESC:
// session-A (now+1m), thread-based (now-30m), standalone
// (now-2h via started_at fallback), multi-thread (now-150m).
require.Equal(t, "session-A", res.Sessions[0].ID)
require.Equal(t, s2i1.ID.String(), res.Sessions[1].ID)
require.Equal(t, s3i1.ID.String(), res.Sessions[2].ID)
@@ -811,7 +822,7 @@ func TestAIBridgeListSessions(t *testing.T) {
// thread root, so count is 1.
require.EqualValues(t, 1, s2.Threads)
// Verify session 3 (standalone).
// Verify session 3 (standalone, no prompts).
s3 := res.Sessions[2]
require.EqualValues(t, 1, s3.Threads)
require.Nil(t, s3.LastPrompt)
@@ -832,12 +843,15 @@ func TestAIBridgeListSessions(t *testing.T) {
now := dbtime.Now()
// Create 5 standalone sessions with different start times.
// Without prompts, last_active_at falls back to started_at, so the
// expected descending order is preserved.
allSessionIDs := make([]string, 5)
for i := range 5 {
endedAt := now.Add(-time.Duration(i)*time.Hour + time.Minute)
startedAt := now.Add(-time.Duration(i) * time.Hour)
endedAt := startedAt.Add(time.Minute)
intc := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: firstUser.UserID,
StartedAt: now.Add(-time.Duration(i) * time.Hour),
StartedAt: startedAt,
}, &endedAt)
// Standalone session: ID = interception UUID string.
allSessionIDs[i] = intc.ID.String()
@@ -1023,10 +1037,20 @@ func TestAIBridgeListSessions(t *testing.T) {
InitiatorID: firstUser.UserID,
StartedAt: now,
}, &i1EndedAt)
dbgen.AIBridgeUserPrompt(t, db, database.InsertAIBridgeUserPromptParams{
InterceptionID: i1.ID,
Prompt: "prompt",
CreatedAt: now,
})
i2 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: auditorUser.ID,
StartedAt: now.Add(-time.Hour),
}, &now)
dbgen.AIBridgeUserPrompt(t, db, database.InsertAIBridgeUserPromptParams{
InterceptionID: i2.ID,
Prompt: "prompt",
CreatedAt: now.Add(-time.Hour),
})
// Site-level auditors can see all sessions.
res, err := auditorClient.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{})
@@ -1457,17 +1481,22 @@ func TestAIBridgeListSessions(t *testing.T) {
now := dbtime.Now()
// Create 3 standalone sessions all starting at the same time.
// The tie-breaker is session_id DESC.
// Create 3 standalone sessions all starting and with a prompt at
// the same time. The tie-breaker on last_active_at is session_id DESC.
for range 3 {
endedAt := now.Add(time.Minute)
dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
interception := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: firstUser.UserID,
StartedAt: now,
}, &endedAt)
dbgen.AIBridgeUserPrompt(t, db, database.InsertAIBridgeUserPromptParams{
InterceptionID: interception.ID,
Prompt: "prompt",
CreatedAt: now,
})
}
// Fetch all to learn the sort order (started_at DESC,
// Fetch all to learn the sort order (last_active_at DESC,
// session_id DESC).
//nolint:gocritic // Owner role is irrelevant; testing cursor.
all, err := client.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{})
@@ -1510,6 +1539,197 @@ func TestAIBridgeListSessions(t *testing.T) {
require.Len(t, res.Sessions, 3)
require.EqualValues(t, 3, res.Count)
})
// LastActiveAtAlwaysSet verifies that last_active_at is always non-zero,
// even for sessions without prompts. Prompted sessions use the latest
// prompt timestamp; promptless sessions fall back to started_at.
t.Run("LastActiveAtAlwaysSet", func(t *testing.T) {
t.Parallel()
client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
ctx := testutil.Context(t, testutil.WaitLong)
now := dbtime.Now()
sessionIDs := []string{"session-a", "session-b", "session-c"}
promptOffsets := []time.Duration{0, -30 * time.Minute, -time.Hour}
for i, sid := range sessionIDs {
endedAt := now.Add(time.Minute)
interception := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: firstUser.UserID,
StartedAt: now.Add(-time.Duration(i) * time.Hour),
ClientSessionID: sql.NullString{String: sid, Valid: true},
}, &endedAt)
dbgen.AIBridgeUserPrompt(t, db, database.InsertAIBridgeUserPromptParams{
InterceptionID: interception.ID,
Prompt: "prompt",
CreatedAt: now.Add(promptOffsets[i]),
})
}
//nolint:gocritic // Owner role is irrelevant; testing last_active_at.
res, err := client.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{})
require.NoError(t, err)
require.Len(t, res.Sessions, 3)
for i, s := range res.Sessions {
require.NotZero(t, s.LastActiveAt, "session %d (%s) should have last_active_at set", i, s.ID)
}
// Sorted by last_active_at DESC: a (now), b (now-30m), c (now-1h).
require.Equal(t, "session-a", res.Sessions[0].ID)
require.Equal(t, "session-b", res.Sessions[1].ID)
require.Equal(t, "session-c", res.Sessions[2].ID)
})
// PromptlessSessionSortsByStartedAt verifies that a session whose root
// interception has no associated user prompts still appears in results and
// sorts by MIN(started_at) as a fallback. Without the COALESCE fallback a
// NULL last_active_at would cause the HAVING row-value comparison to
// evaluate to NULL (not false), silently dropping the session from all
// result pages.
//
// Three sessions are arranged so that the promptless session sits between
// two prompted sessions in sort order:
//
// A: started=now, prompt=now → last_active_at=now
// B: started=now-1h, NO prompt → last_active_at=now-1h (fallback)
// C: started=now-2h, prompt=now-30m → last_active_at=now-30m
//
// Sort order by last_active_at DESC: C (now-30m) > B (now-1h), so: A, C, B.
// B disappearing would indicate the fallback is broken.
t.Run("PromptlessSessionSortsByStartedAt", func(t *testing.T) {
t.Parallel()
client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
ctx := testutil.Context(t, testutil.WaitLong)
now := dbtime.Now()
// Session A: has a prompt.
aEndedAt := now.Add(time.Minute)
aInterception := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: firstUser.UserID,
StartedAt: now,
ClientSessionID: sql.NullString{String: "session-a", Valid: true},
}, &aEndedAt)
dbgen.AIBridgeUserPrompt(t, db, database.InsertAIBridgeUserPromptParams{
InterceptionID: aInterception.ID,
Prompt: "prompt from session a",
CreatedAt: now,
})
// Session B: no prompt at all, exercises the MIN(started_at) fallback.
bEndedAt := now.Add(time.Minute)
bInterception := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: firstUser.UserID,
StartedAt: now.Add(-1 * time.Hour),
ClientSessionID: sql.NullString{String: "session-b", Valid: true},
}, &bEndedAt)
// Session C: has a prompt more recent than B's started_at, so C sorts
// above B even though C started earlier.
cEndedAt := now.Add(time.Minute)
cInterception := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: firstUser.UserID,
StartedAt: now.Add(-2 * time.Hour),
ClientSessionID: sql.NullString{String: "session-c", Valid: true},
}, &cEndedAt)
dbgen.AIBridgeUserPrompt(t, db, database.InsertAIBridgeUserPromptParams{
InterceptionID: cInterception.ID,
Prompt: "prompt from session c",
CreatedAt: now.Add(-30 * time.Minute),
})
//nolint:gocritic // Owner role is irrelevant; testing sort fallback.
res, err := client.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{})
require.NoError(t, err)
require.Len(t, res.Sessions, 3, "promptless session B must appear in results")
// Expected order: A (last_active_at=now), C (last_active_at=now-30m), B (last_active_at=now-1h via fallback).
require.Equal(t, aInterception.SessionID, res.Sessions[0].ID, "session A should be first")
require.Equal(t, cInterception.SessionID, res.Sessions[1].ID, "session C should be second (prompt=now-30m beats B's started_at=now-1h)")
require.Equal(t, bInterception.SessionID, res.Sessions[2].ID, "session B should be last (no prompt, falls back to started_at=now-1h)")
// All sessions have last_active_at; session B falls back to started_at.
require.NotZero(t, res.Sessions[0].LastActiveAt, "session A should have last_active_at set")
require.NotZero(t, res.Sessions[1].LastActiveAt, "session C should have last_active_at set")
require.WithinDuration(t, bInterception.StartedAt, res.Sessions[2].LastActiveAt, time.Millisecond, "session B has no prompts, last_active_at should equal started_at")
})
// SortsByLastActive verifies that sessions are ordered by last_active_at.
// Every session here has at least one prompt, so last_active_at equals
// the latest prompt timestamp rather than the started_at fallback.
//
// Three sessions are created with intentionally crossing timestamps so that
// the "prompt time" order differs from the "started_at" order:
//
// X: started=now, prompt=now → last_active_at = now
// Y: started=now-2h, prompt=now-30m → last_active_at = now-30m
// Z: started=now-1h, prompt=now-1h → last_active_at = now-1h
//
// Order by started_at DESC: X, Z, Y
// Order by last_active_at DESC: X, Y, Z
t.Run("SortsByLastActive", func(t *testing.T) {
t.Parallel()
client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t))
ctx := testutil.Context(t, testutil.WaitLong)
now := dbtime.Now()
// Session X: started now, prompt now.
xEndedAt := now.Add(time.Minute)
xInterception := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: firstUser.UserID,
StartedAt: now,
ClientSessionID: sql.NullString{String: "session-x", Valid: true},
}, &xEndedAt)
dbgen.AIBridgeUserPrompt(t, db, database.InsertAIBridgeUserPromptParams{
InterceptionID: xInterception.ID,
Prompt: "prompt from session x",
CreatedAt: now,
})
// Session Y: started 2 hours ago, prompt 30 minutes ago.
yEndedAt := now.Add(time.Minute)
yInterception := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: firstUser.UserID,
StartedAt: now.Add(-2 * time.Hour),
ClientSessionID: sql.NullString{String: "session-y", Valid: true},
}, &yEndedAt)
dbgen.AIBridgeUserPrompt(t, db, database.InsertAIBridgeUserPromptParams{
InterceptionID: yInterception.ID,
Prompt: "prompt from session y",
CreatedAt: now.Add(-30 * time.Minute),
})
// Session Z: started 1 hour ago, prompt 1 hour ago.
zEndedAt := now.Add(time.Minute)
zInterception := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{
InitiatorID: firstUser.UserID,
StartedAt: now.Add(-1 * time.Hour),
ClientSessionID: sql.NullString{String: "session-z", Valid: true},
}, &zEndedAt)
dbgen.AIBridgeUserPrompt(t, db, database.InsertAIBridgeUserPromptParams{
InterceptionID: zInterception.ID,
Prompt: "prompt from session z",
CreatedAt: now.Add(-1 * time.Hour),
})
//nolint:gocritic // Owner role is irrelevant; testing sort order.
res, err := client.AIBridgeListSessions(ctx, codersdk.AIBridgeListSessionsFilter{})
require.NoError(t, err)
require.Len(t, res.Sessions, 3)
// Expected order: X (now), Y (now-30m), Z (now-1h).
// If sorted by started_at the order would be X, Z, Y.
require.Equal(t, xInterception.SessionID, res.Sessions[0].ID, "session X should be first (prompt=now)")
require.Equal(t, yInterception.SessionID, res.Sessions[1].ID, "session Y should be second (prompt=now-30m beats Z's now-1h)")
require.Equal(t, zInterception.SessionID, res.Sessions[2].ID, "session Z should be last (prompt=now-1h)")
// All sessions have LastActiveAt populated.
require.NotNil(t, res.Sessions[0].LastActiveAt, "session X should have last_active_at set")
require.NotNil(t, res.Sessions[1].LastActiveAt, "session Y should have last_active_at set")
require.NotNil(t, res.Sessions[2].LastActiveAt, "session Z should have last_active_at set")
})
}
func TestAIBridgeListClients(t *testing.T) {