From c23abc691f48fc7a1329a488e06c323222e96448 Mon Sep 17 00:00:00 2001 From: Jeremy Ruppel Date: Wed, 22 Apr 2026 12:06:49 -0400 Subject: [PATCH] feat: sort AI sessions by last prompt time (#24440) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- coderd/apidoc/docs.go | 4 + coderd/apidoc/swagger.json | 4 + coderd/database/db2sdk/db2sdk.go | 11 +- coderd/database/modelqueries.go | 1 + coderd/database/queries.sql.go | 55 ++-- coderd/database/queries/aibridge.sql | 53 ++-- codersdk/aibridge.go | 1 + docs/reference/api/aibridge.md | 1 + docs/reference/api/schemas.md | 3 + enterprise/coderd/aibridge_test.go | 242 +++++++++++++++++- site/src/api/typesGenerated.ts | 1 + .../ListSessionsPage/ListSessionsPageView.tsx | 20 +- .../ListSessionsPage/ListSessionsRow.tsx | 2 +- site/src/testHelpers/entities.ts | 1 + 14 files changed, 340 insertions(+), 59 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 9d927e03a5..2a00a4616c 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -13375,6 +13375,10 @@ const docTemplate = `{ "initiator": { "$ref": "#/definitions/codersdk.MinimalUser" }, + "last_active_at": { + "type": "string", + "format": "date-time" + }, "last_prompt": { "type": "string" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 5c0e1af16a..f723051ce2 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -11923,6 +11923,10 @@ "initiator": { "$ref": "#/definitions/codersdk.MinimalUser" }, + "last_active_at": { + "type": "string", + "format": "date-time" + }, "last_prompt": { "type": "string" }, diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 6213c71673..49cac265d3 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -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, diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index cf8f969692..c1d89c8a12 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -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 } diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index a9a27f265b..94f9f58589 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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 } diff --git a/coderd/database/queries/aibridge.sql b/coderd/database/queries/aibridge.sql index 2e9a599ce7..bacec83dd6 100644 --- a/coderd/database/queries/aibridge.sql +++ b/coderd/database/queries/aibridge.sql @@ -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 ; diff --git a/codersdk/aibridge.go b/codersdk/aibridge.go index ab8c215e94..d9397cd979 100644 --- a/codersdk/aibridge.go +++ b/codersdk/aibridge.go @@ -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 { diff --git a/docs/reference/api/aibridge.md b/docs/reference/api/aibridge.md index 3c3f73413b..a3f5484767 100644 --- a/docs/reference/api/aibridge.md +++ b/docs/reference/api/aibridge.md @@ -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, diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 716e93e01e..9d23823eaa 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -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 | | | diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go index 8d52c1d642..fa78c61956 100644 --- a/enterprise/coderd/aibridge_test.go +++ b/enterprise/coderd/aibridge_test.go @@ -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) { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index e04e2463d5..600339c0f5 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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 diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.tsx index c525df1863..ed85496fb5 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.tsx @@ -40,9 +40,7 @@ interface ListSessionsPageViewProps { const ThreadTooltip: FC = ({ children }) => ( - -
{children}
-
+ {children} = ({ - Last Prompt - User - Provider - Client - In/Out Tokens - + Last Prompt + User + Provider + Client + In/Out Tokens + Threads - Timestamp [UTC{utcOffset}] + + Last Prompt At [UTC{utcOffset}] + diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.tsx index 9addc7cfe4..a630512b49 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsRow.tsx @@ -108,7 +108,7 @@ export const ListSessionsRow: FC = ({
{formatDateTime( - new Date(session.started_at), + new Date(session.last_active_at), DATE_FORMAT.FULL_DATETIME, )} diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index b441585e1c..ce9e45892c 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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", };