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
+4
View File
@@ -13375,6 +13375,10 @@ const docTemplate = `{
"initiator": {
"$ref": "#/definitions/codersdk.MinimalUser"
},
"last_active_at": {
"type": "string",
"format": "date-time"
},
"last_prompt": {
"type": "string"
},
+4
View File
@@ -11923,6 +11923,10 @@
"initiator": {
"$ref": "#/definitions/codersdk.MinimalUser"
},
"last_active_at": {
"type": "string",
"format": "date-time"
},
"last_prompt": {
"type": "string"
},
+6 -5
View File
@@ -1038,11 +1038,12 @@ func AIBridgeSession(row database.ListAIBridgeSessionsRow) codersdk.AIBridgeSess
Name: row.UserName,
AvatarURL: row.UserAvatarUrl,
}),
Providers: row.Providers,
Models: row.Models,
Metadata: jsonOrEmptyMap(pqtype.NullRawMessage{RawMessage: row.Metadata, Valid: len(row.Metadata) > 0}),
StartedAt: row.StartedAt,
Threads: row.Threads,
Providers: row.Providers,
Models: row.Models,
Metadata: jsonOrEmptyMap(pqtype.NullRawMessage{RawMessage: row.Metadata, Valid: len(row.Metadata) > 0}),
StartedAt: row.StartedAt,
Threads: row.Threads,
LastActiveAt: row.LastActiveAt,
TokenUsageSummary: codersdk.AIBridgeSessionTokenUsageSummary{
InputTokens: row.InputTokens,
OutputTokens: row.OutputTokens,
+1
View File
@@ -1040,6 +1040,7 @@ func (q *sqlQuerier) ListAuthorizedAIBridgeSessions(ctx context.Context, arg Lis
&i.CacheReadInputTokens,
&i.CacheWriteInputTokens,
&i.LastPrompt,
&i.LastActiveAt,
); err != nil {
return nil, err
}
+39 -16
View File
@@ -1364,22 +1364,43 @@ func (q *sqlQuerier) ListAIBridgeSessionThreads(ctx context.Context, arg ListAIB
const listAIBridgeSessions = `-- name: ListAIBridgeSessions :many
WITH cursor_pos AS (
-- Resolve the cursor's started_at once, outside the HAVING clause,
-- so the planner cannot accidentally re-evaluate it per group.
SELECT MIN(aibridge_interceptions.started_at) AS started_at
FROM aibridge_interceptions
WHERE aibridge_interceptions.session_id = $1 AND aibridge_interceptions.ended_at IS NOT NULL
-- Resolve the cursor's last_active_at once, outside the HAVING clause,
-- so the planner cannot accidentally re-evaluate it per group. Direct
-- LEFT JOIN is safe here since we only use MAX/MIN aggregates (no COUNT
-- affected by fan-out from multiple prompts per interception).
-- COALESCE falls back to MIN(ai.started_at) so the cursor value is
-- never NULL, which would silently drop rows from the HAVING comparison.
SELECT COALESCE(MAX(up.created_at), MIN(ai.started_at)) AS last_active_at
FROM aibridge_interceptions ai
LEFT JOIN aibridge_user_prompts up ON up.interception_id = ai.id
WHERE ai.session_id = $1 AND ai.ended_at IS NOT NULL
),
session_page AS (
-- Paginate at the session level first; only cheap aggregates here.
-- A lateral correlated subquery for prompts keeps the join one-to-one
-- with aibridge_interceptions so COUNT(*) for thread tallies is not
-- inflated. LIMIT 1 combined with the (interception_id, created_at DESC)
-- index makes this an index-only lookup per interception row rather than
-- a full-table-scan GROUP BY over all prompts.
-- last_active_at is the latest prompt timestamp, falling back to
-- MIN(started_at) for sessions with no prompts. The COALESCE ensures
-- it is never NULL so the HAVING row-value cursor comparison is safe.
SELECT
ai.session_id,
ai.initiator_id,
MIN(ai.started_at) AS started_at,
MAX(ai.ended_at) AS ended_at,
COUNT(*) FILTER (WHERE ai.thread_root_id IS NULL) AS threads
COUNT(*) FILTER (WHERE ai.thread_root_id IS NULL) AS threads,
COALESCE(MAX(latest_prompt.latest_prompt_at), MIN(ai.started_at))::timestamptz AS last_active_at
FROM
aibridge_interceptions ai
LEFT JOIN LATERAL (
SELECT created_at AS latest_prompt_at
FROM aibridge_user_prompts
WHERE interception_id = ai.id
ORDER BY created_at DESC
LIMIT 1
) latest_prompt ON true
WHERE
-- Remove inflight interceptions (ones which lack an ended_at value).
ai.ended_at IS NOT NULL
@@ -1422,22 +1443,21 @@ session_page AS (
GROUP BY
ai.session_id, ai.initiator_id
HAVING
-- Cursor pagination: uses a composite (started_at, session_id)
-- cursor to support keyset pagination. The less-than comparison
-- matches the DESC sort order so rows after the cursor come
-- later in results. The cursor value comes from cursor_pos to
-- guarantee single evaluation.
-- Cursor pagination: uses a composite (last_active_at, session_id) cursor to
-- support keyset pagination. The less-than comparison matches the DESC
-- sort order so rows after the cursor come later in results. The cursor
-- value comes from cursor_pos to guarantee single evaluation.
CASE
WHEN $1::text != '' THEN (
(MIN(ai.started_at), ai.session_id) < (
(SELECT started_at FROM cursor_pos),
(COALESCE(MAX(latest_prompt.latest_prompt_at), MIN(ai.started_at)), ai.session_id) < (
(SELECT last_active_at FROM cursor_pos),
$1::text
)
)
ELSE true
END
ORDER BY
MIN(ai.started_at) DESC,
last_active_at DESC,
ai.session_id DESC
LIMIT COALESCE(NULLIF($10::integer, 0), 100)
OFFSET $9
@@ -1459,7 +1479,8 @@ SELECT
COALESCE(st.output_tokens, 0)::bigint AS output_tokens,
COALESCE(st.cache_read_input_tokens, 0)::bigint AS cache_read_input_tokens,
COALESCE(st.cache_write_input_tokens, 0)::bigint AS cache_write_input_tokens,
COALESCE(slp.prompt, '') AS last_prompt
COALESCE(slp.prompt, '') AS last_prompt,
sp.last_active_at AS last_active_at
FROM
session_page sp
JOIN
@@ -1496,7 +1517,7 @@ LEFT JOIN LATERAL (
LIMIT 1
) slp ON true
ORDER BY
sp.started_at DESC,
sp.last_active_at DESC,
sp.session_id DESC
`
@@ -1531,6 +1552,7 @@ type ListAIBridgeSessionsRow struct {
CacheReadInputTokens int64 `db:"cache_read_input_tokens" json:"cache_read_input_tokens"`
CacheWriteInputTokens int64 `db:"cache_write_input_tokens" json:"cache_write_input_tokens"`
LastPrompt string `db:"last_prompt" json:"last_prompt"`
LastActiveAt time.Time `db:"last_active_at" json:"last_active_at"`
}
// Returns paginated sessions with aggregated metadata, token counts, and
@@ -1578,6 +1600,7 @@ func (q *sqlQuerier) ListAIBridgeSessions(ctx context.Context, arg ListAIBridgeS
&i.CacheReadInputTokens,
&i.CacheWriteInputTokens,
&i.LastPrompt,
&i.LastActiveAt,
); err != nil {
return nil, err
}
+37 -16
View File
@@ -446,22 +446,43 @@ WHERE
-- single GROUP BY scan, then do expensive lateral joins (tokens, prompts,
-- first-interception metadata) only for the ~page-size result set.
WITH cursor_pos AS (
-- Resolve the cursor's started_at once, outside the HAVING clause,
-- so the planner cannot accidentally re-evaluate it per group.
SELECT MIN(aibridge_interceptions.started_at) AS started_at
FROM aibridge_interceptions
WHERE aibridge_interceptions.session_id = @after_session_id AND aibridge_interceptions.ended_at IS NOT NULL
-- Resolve the cursor's last_active_at once, outside the HAVING clause,
-- so the planner cannot accidentally re-evaluate it per group. Direct
-- LEFT JOIN is safe here since we only use MAX/MIN aggregates (no COUNT
-- affected by fan-out from multiple prompts per interception).
-- COALESCE falls back to MIN(ai.started_at) so the cursor value is
-- never NULL, which would silently drop rows from the HAVING comparison.
SELECT COALESCE(MAX(up.created_at), MIN(ai.started_at)) AS last_active_at
FROM aibridge_interceptions ai
LEFT JOIN aibridge_user_prompts up ON up.interception_id = ai.id
WHERE ai.session_id = @after_session_id AND ai.ended_at IS NOT NULL
),
session_page AS (
-- Paginate at the session level first; only cheap aggregates here.
-- A lateral correlated subquery for prompts keeps the join one-to-one
-- with aibridge_interceptions so COUNT(*) for thread tallies is not
-- inflated. LIMIT 1 combined with the (interception_id, created_at DESC)
-- index makes this an index-only lookup per interception row rather than
-- a full-table-scan GROUP BY over all prompts.
-- last_active_at is the latest prompt timestamp, falling back to
-- MIN(started_at) for sessions with no prompts. The COALESCE ensures
-- it is never NULL so the HAVING row-value cursor comparison is safe.
SELECT
ai.session_id,
ai.initiator_id,
MIN(ai.started_at) AS started_at,
MAX(ai.ended_at) AS ended_at,
COUNT(*) FILTER (WHERE ai.thread_root_id IS NULL) AS threads
COUNT(*) FILTER (WHERE ai.thread_root_id IS NULL) AS threads,
COALESCE(MAX(latest_prompt.latest_prompt_at), MIN(ai.started_at))::timestamptz AS last_active_at
FROM
aibridge_interceptions ai
LEFT JOIN LATERAL (
SELECT created_at AS latest_prompt_at
FROM aibridge_user_prompts
WHERE interception_id = ai.id
ORDER BY created_at DESC
LIMIT 1
) latest_prompt ON true
WHERE
-- Remove inflight interceptions (ones which lack an ended_at value).
ai.ended_at IS NOT NULL
@@ -504,22 +525,21 @@ session_page AS (
GROUP BY
ai.session_id, ai.initiator_id
HAVING
-- Cursor pagination: uses a composite (started_at, session_id)
-- cursor to support keyset pagination. The less-than comparison
-- matches the DESC sort order so rows after the cursor come
-- later in results. The cursor value comes from cursor_pos to
-- guarantee single evaluation.
-- Cursor pagination: uses a composite (last_active_at, session_id) cursor to
-- support keyset pagination. The less-than comparison matches the DESC
-- sort order so rows after the cursor come later in results. The cursor
-- value comes from cursor_pos to guarantee single evaluation.
CASE
WHEN @after_session_id::text != '' THEN (
(MIN(ai.started_at), ai.session_id) < (
(SELECT started_at FROM cursor_pos),
(COALESCE(MAX(latest_prompt.latest_prompt_at), MIN(ai.started_at)), ai.session_id) < (
(SELECT last_active_at FROM cursor_pos),
@after_session_id::text
)
)
ELSE true
END
ORDER BY
MIN(ai.started_at) DESC,
last_active_at DESC,
ai.session_id DESC
LIMIT COALESCE(NULLIF(@limit_::integer, 0), 100)
OFFSET @offset_
@@ -541,7 +561,8 @@ SELECT
COALESCE(st.output_tokens, 0)::bigint AS output_tokens,
COALESCE(st.cache_read_input_tokens, 0)::bigint AS cache_read_input_tokens,
COALESCE(st.cache_write_input_tokens, 0)::bigint AS cache_write_input_tokens,
COALESCE(slp.prompt, '') AS last_prompt
COALESCE(slp.prompt, '') AS last_prompt,
sp.last_active_at AS last_active_at
FROM
session_page sp
JOIN
@@ -578,7 +599,7 @@ LEFT JOIN LATERAL (
LIMIT 1
) slp ON true
ORDER BY
sp.started_at DESC,
sp.last_active_at DESC,
sp.session_id DESC
;
+1
View File
@@ -78,6 +78,7 @@ type AIBridgeSession struct {
Threads int64 `json:"threads"`
TokenUsageSummary AIBridgeSessionTokenUsageSummary `json:"token_usage_summary"`
LastPrompt *string `json:"last_prompt,omitempty"`
LastActiveAt time.Time `json:"last_active_at" format:"date-time"`
}
type AIBridgeSessionTokenUsageSummary struct {
+1
View File
@@ -214,6 +214,7 @@ curl -X GET http://coder-server:8080/api/v2/aibridge/sessions \
"name": "string",
"username": "string"
},
"last_active_at": "2019-08-24T14:15:22Z",
"last_prompt": "string",
"metadata": {
"property1": null,
+3
View File
@@ -690,6 +690,7 @@
"name": "string",
"username": "string"
},
"last_active_at": "2019-08-24T14:15:22Z",
"last_prompt": "string",
"metadata": {
"property1": null,
@@ -824,6 +825,7 @@
"name": "string",
"username": "string"
},
"last_active_at": "2019-08-24T14:15:22Z",
"last_prompt": "string",
"metadata": {
"property1": null,
@@ -854,6 +856,7 @@
| `ended_at` | string | false | | |
| `id` | string | false | | |
| `initiator` | [codersdk.MinimalUser](#codersdkminimaluser) | false | | |
| `last_active_at` | string | false | | |
| `last_prompt` | string | false | | |
| `metadata` | object | false | | |
| » `[any property]` | any | false | | |
+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) {
+1
View File
@@ -177,6 +177,7 @@ export interface AIBridgeSession {
readonly threads: number;
readonly token_usage_summary: AIBridgeSessionTokenUsageSummary;
readonly last_prompt?: string;
readonly last_active_at: string;
}
// From codersdk/aibridge.go
@@ -40,9 +40,7 @@ interface ListSessionsPageViewProps {
const ThreadTooltip: FC<PropsWithChildren> = ({ children }) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex-shrink-0 flex items-center">{children}</div>
</TooltipTrigger>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent
side="top"
align="end"
@@ -83,18 +81,20 @@ export const ListSessionsPageView: FC<ListSessionsPageViewProps> = ({
<Table className="text-sm font-normal">
<TableHeader>
<TableRow>
<TableHead>Last Prompt</TableHead>
<TableHead>User</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Client</TableHead>
<TableHead>In/Out Tokens</TableHead>
<TableHead className="flex items-center gap-1">
<TableHead className="text-nowrap">Last Prompt</TableHead>
<TableHead className="text-nowrap">User</TableHead>
<TableHead className="text-nowrap">Provider</TableHead>
<TableHead className="text-nowrap">Client</TableHead>
<TableHead className="text-nowrap">In/Out Tokens</TableHead>
<TableHead className="flex items-center flex-nowrap gap-1">
Threads
<ThreadTooltip>
<InfoIcon className="size-icon-xs" />
</ThreadTooltip>
</TableHead>
<TableHead>Timestamp [UTC{utcOffset}]</TableHead>
<TableHead className="text-nowrap">
Last Prompt At [UTC{utcOffset}]
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -108,7 +108,7 @@ export const ListSessionsRow: FC<ListSessionsRowProps> = ({
<div className="flex items-center justify-between">
<span>
{formatDateTime(
new Date(session.started_at),
new Date(session.last_active_at),
DATE_FORMAT.FULL_DATETIME,
)}
</span>
+1
View File
@@ -5316,4 +5316,5 @@ export const MockSession: TypesGen.AIBridgeSession = {
cache_write_input_tokens: 120,
},
last_prompt: "But *can* I really fix it?",
last_active_at: "2026-03-09T10:28:15.03152Z",
};