mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
co-authored by
Claude Sonnet 4.6
parent
60186b2489
commit
c23abc691f
Generated
+4
@@ -13375,6 +13375,10 @@ const docTemplate = `{
|
||||
"initiator": {
|
||||
"$ref": "#/definitions/codersdk.MinimalUser"
|
||||
},
|
||||
"last_active_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"last_prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
Generated
+4
@@ -11923,6 +11923,10 @@
|
||||
"initiator": {
|
||||
"$ref": "#/definitions/codersdk.MinimalUser"
|
||||
},
|
||||
"last_active_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"last_prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Generated
+1
@@ -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,
|
||||
|
||||
Generated
+3
@@ -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 | | |
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Generated
+1
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user