diff --git a/go/core/internal/database/client_test.go b/go/core/internal/database/client_test.go index f7cdc4cf1..3a8af9218 100644 --- a/go/core/internal/database/client_test.go +++ b/go/core/internal/database/client_test.go @@ -13,6 +13,7 @@ import ( "github.com/pgvector/pgvector-go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "trpc.group/trpc-go/trpc-a2a-go/protocol" ) // TestConcurrentAgentUpserts verifies that concurrent StoreAgent calls @@ -233,6 +234,124 @@ func TestStoreSessionIdempotence(t *testing.T) { assert.Equal(t, "Updated", *retrieved.Name, "Session should have updated name") } +func TestListSessionsOrdersByRecentActivity(t *testing.T) { + db := setupTestDB(t) + client := NewClient(db) + ctx := context.Background() + + userID := "test-user" + agentID := "test-agent" + base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + sessions := []struct { + id string + createdAt time.Time + updatedAt time.Time + }{ + {id: "old-active", createdAt: base, updatedAt: base.Add(4 * time.Hour)}, + {id: "new-inactive", createdAt: base.Add(3 * time.Hour), updatedAt: base.Add(3 * time.Hour)}, + {id: "old-inactive", createdAt: base.Add(time.Hour), updatedAt: base.Add(2 * time.Hour)}, + } + + for _, s := range sessions { + err := client.StoreSession(ctx, &dbpkg.Session{ + ID: s.id, + UserID: userID, + AgentID: &agentID, + }) + require.NoError(t, err) + _, err = db.Exec(ctx, ` + UPDATE session + SET created_at = $1, updated_at = $2 + WHERE id = $3 AND user_id = $4 + `, s.createdAt, s.updatedAt, s.id, userID) + require.NoError(t, err) + } + + allSessions, err := client.ListSessions(ctx, userID) + require.NoError(t, err) + require.Len(t, allSessions, 3) + assert.Equal(t, []string{"old-active", "new-inactive", "old-inactive"}, []string{ + allSessions[0].ID, + allSessions[1].ID, + allSessions[2].ID, + }) + + agentSessions, err := client.ListSessionsForAgent(ctx, agentID, userID) + require.NoError(t, err) + require.Len(t, agentSessions, 3) + assert.Equal(t, []string{"old-active", "new-inactive", "old-inactive"}, []string{ + agentSessions[0].ID, + agentSessions[1].ID, + agentSessions[2].ID, + }) +} + +func TestStoreEventTouchesSessionActivity(t *testing.T) { + db := setupTestDB(t) + client := NewClient(db) + ctx := context.Background() + + userID := "test-user" + sessionID := "active-session" + oldActivity := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + + err := client.StoreSession(ctx, &dbpkg.Session{ + ID: sessionID, + UserID: userID, + }) + require.NoError(t, err) + _, err = db.Exec(ctx, ` + UPDATE session + SET updated_at = $1 + WHERE id = $2 AND user_id = $3 + `, oldActivity, sessionID, userID) + require.NoError(t, err) + + err = client.StoreEvents(ctx, &dbpkg.Event{ + ID: "event-1", + SessionID: sessionID, + UserID: userID, + Data: "{}", + }) + require.NoError(t, err) + + got, err := client.GetSession(ctx, sessionID, userID) + require.NoError(t, err) + assert.True(t, got.UpdatedAt.After(oldActivity), "session updated_at should advance after storing an event") +} + +func TestStoreTaskTouchesSessionActivity(t *testing.T) { + db := setupTestDB(t) + client := NewClient(db) + ctx := context.Background() + + userID := "test-user" + sessionID := "active-session" + oldActivity := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + + err := client.StoreSession(ctx, &dbpkg.Session{ + ID: sessionID, + UserID: userID, + }) + require.NoError(t, err) + _, err = db.Exec(ctx, ` + UPDATE session + SET updated_at = $1 + WHERE id = $2 AND user_id = $3 + `, oldActivity, sessionID, userID) + require.NoError(t, err) + + err = client.StoreTask(ctx, &protocol.Task{ + ID: "task-1", + ContextID: sessionID, + }) + require.NoError(t, err) + + got, err := client.GetSession(ctx, sessionID, userID) + require.NoError(t, err) + assert.True(t, got.UpdatedAt.After(oldActivity), "session updated_at should advance after storing a task") +} + // TestStoreAgentIdempotence verifies that calling StoreAgent multiple times // with the same data is idempotent and doesn't error. This is critical for // the lock-free concurrency model where concurrent upserts must succeed. diff --git a/go/core/internal/database/gen/events.sql.go b/go/core/internal/database/gen/events.sql.go index 4c16cfd3e..d34d42298 100644 --- a/go/core/internal/database/gen/events.sql.go +++ b/go/core/internal/database/gen/events.sql.go @@ -37,8 +37,18 @@ func (q *Queries) GetEvent(ctx context.Context, arg GetEventParams) (Event, erro } const insertEvent = `-- name: InsertEvent :exec +WITH inserted_event AS ( INSERT INTO event (id, user_id, session_id, data, created_at, updated_at) VALUES ($1, $2, $3, $4, NOW(), NOW()) +RETURNING user_id, session_id +) +UPDATE session +SET updated_at = NOW() +FROM inserted_event +WHERE inserted_event.session_id IS NOT NULL + AND session.id = inserted_event.session_id + AND session.user_id = inserted_event.user_id + AND session.deleted_at IS NULL ` type InsertEventParams struct { diff --git a/go/core/internal/database/gen/sessions.sql.go b/go/core/internal/database/gen/sessions.sql.go index 4b44ddeb1..24490bc62 100644 --- a/go/core/internal/database/gen/sessions.sql.go +++ b/go/core/internal/database/gen/sessions.sql.go @@ -39,7 +39,7 @@ func (q *Queries) GetSession(ctx context.Context, arg GetSessionParams) (Session const listSessions = `-- name: ListSessions :many SELECT id, user_id, name, created_at, updated_at, deleted_at, agent_id, source FROM session WHERE user_id = $1 AND deleted_at IS NULL -ORDER BY created_at ASC +ORDER BY updated_at DESC, created_at DESC ` func (q *Queries) ListSessions(ctx context.Context, userID string) ([]Session, error) { @@ -75,7 +75,7 @@ const listSessionsForAgent = `-- name: ListSessionsForAgent :many SELECT id, user_id, name, created_at, updated_at, deleted_at, agent_id, source FROM session WHERE agent_id = $1 AND user_id = $2 AND deleted_at IS NULL AND (source IS NULL OR source != 'agent') -ORDER BY created_at ASC +ORDER BY updated_at DESC, created_at DESC ` type ListSessionsForAgentParams struct { @@ -116,7 +116,7 @@ const listSessionsForAgentAllUsers = `-- name: ListSessionsForAgentAllUsers :man SELECT id, user_id, name, created_at, updated_at, deleted_at, agent_id, source FROM session WHERE agent_id = $1 AND deleted_at IS NULL AND (source IS NULL OR source != 'agent') -ORDER BY created_at ASC +ORDER BY updated_at DESC, created_at DESC ` func (q *Queries) ListSessionsForAgentAllUsers(ctx context.Context, agentID *string) ([]Session, error) { diff --git a/go/core/internal/database/gen/tasks.sql.go b/go/core/internal/database/gen/tasks.sql.go index f5e8f8d2d..e91be6daa 100644 --- a/go/core/internal/database/gen/tasks.sql.go +++ b/go/core/internal/database/gen/tasks.sql.go @@ -85,12 +85,21 @@ func (q *Queries) TaskExists(ctx context.Context, id string) (bool, error) { } const upsertTask = `-- name: UpsertTask :exec +WITH upserted_task AS ( INSERT INTO task (id, data, session_id, created_at, updated_at) VALUES ($1, $2, $3, NOW(), NOW()) ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data, session_id = EXCLUDED.session_id, updated_at = NOW() +RETURNING session_id +) +UPDATE session +SET updated_at = NOW() +FROM upserted_task +WHERE upserted_task.session_id IS NOT NULL + AND session.id = upserted_task.session_id + AND session.deleted_at IS NULL ` type UpsertTaskParams struct { diff --git a/go/core/internal/database/queries/events.sql b/go/core/internal/database/queries/events.sql index ae6fd88dc..9f916a03d 100644 --- a/go/core/internal/database/queries/events.sql +++ b/go/core/internal/database/queries/events.sql @@ -1,6 +1,16 @@ -- name: InsertEvent :exec +WITH inserted_event AS ( INSERT INTO event (id, user_id, session_id, data, created_at, updated_at) -VALUES ($1, $2, $3, $4, NOW(), NOW()); +VALUES ($1, $2, $3, $4, NOW(), NOW()) +RETURNING user_id, session_id +) +UPDATE session +SET updated_at = NOW() +FROM inserted_event +WHERE inserted_event.session_id IS NOT NULL + AND session.id = inserted_event.session_id + AND session.user_id = inserted_event.user_id + AND session.deleted_at IS NULL; -- name: GetEvent :one SELECT * FROM event diff --git a/go/core/internal/database/queries/sessions.sql b/go/core/internal/database/queries/sessions.sql index a9d8c9eb2..98fdda572 100644 --- a/go/core/internal/database/queries/sessions.sql +++ b/go/core/internal/database/queries/sessions.sql @@ -6,19 +6,19 @@ LIMIT 1; -- name: ListSessions :many SELECT * FROM session WHERE user_id = $1 AND deleted_at IS NULL -ORDER BY created_at ASC; +ORDER BY updated_at DESC, created_at DESC; -- name: ListSessionsForAgent :many SELECT * FROM session WHERE agent_id = $1 AND user_id = $2 AND deleted_at IS NULL AND (source IS NULL OR source != 'agent') -ORDER BY created_at ASC; +ORDER BY updated_at DESC, created_at DESC; -- name: ListSessionsForAgentAllUsers :many SELECT * FROM session WHERE agent_id = $1 AND deleted_at IS NULL AND (source IS NULL OR source != 'agent') -ORDER BY created_at ASC; +ORDER BY updated_at DESC, created_at DESC; -- name: UpsertSession :exec INSERT INTO session (id, user_id, name, agent_id, source, created_at, updated_at) diff --git a/go/core/internal/database/queries/tasks.sql b/go/core/internal/database/queries/tasks.sql index ae72627c1..66105c6f3 100644 --- a/go/core/internal/database/queries/tasks.sql +++ b/go/core/internal/database/queries/tasks.sql @@ -14,12 +14,21 @@ WHERE session_id = $1 AND deleted_at IS NULL ORDER BY created_at ASC; -- name: UpsertTask :exec +WITH upserted_task AS ( INSERT INTO task (id, data, session_id, created_at, updated_at) VALUES ($1, $2, $3, NOW(), NOW()) ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data, session_id = EXCLUDED.session_id, - updated_at = NOW(); + updated_at = NOW() +RETURNING session_id +) +UPDATE session +SET updated_at = NOW() +FROM upserted_task +WHERE upserted_task.session_id IS NOT NULL + AND session.id = upserted_task.session_id + AND session.deleted_at IS NULL; -- name: SoftDeleteTask :exec UPDATE task SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL; diff --git a/go/core/internal/httpserver/handlers/sessions_test.go b/go/core/internal/httpserver/handlers/sessions_test.go index 517ea3682..02ee3243d 100644 --- a/go/core/internal/httpserver/handlers/sessions_test.go +++ b/go/core/internal/httpserver/handlers/sessions_test.go @@ -79,15 +79,28 @@ func TestSessionsHandler(t *testing.T) { return session } + setSessionActivity := func(t *testing.T, sessionID, userID string, createdAt, updatedAt time.Time) { + t.Helper() + _, err := sharedDB.Exec(context.Background(), ` + UPDATE session + SET created_at = $1, updated_at = $2 + WHERE id = $3 AND user_id = $4 + `, createdAt, updatedAt, sessionID, userID) + require.NoError(t, err) + } + t.Run("HandleListSessions", func(t *testing.T) { t.Run("Success", func(t *testing.T) { handler, dbClient, responseRecorder := setupHandler(t) userID := "test-user" + base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) // Create test sessions agentID := "1" session1 := createTestSession(t, dbClient, "session-1", userID, agentID) session2 := createTestSession(t, dbClient, "session-2", userID, agentID) + setSessionActivity(t, session1.ID, userID, base, base.Add(2*time.Hour)) + setSessionActivity(t, session2.ID, userID, base.Add(time.Hour), base.Add(time.Hour)) req := httptest.NewRequest("GET", "/api/sessions?user_id="+userID, nil) req = setUser(req, userID) @@ -472,6 +485,9 @@ func TestSessionsHandler(t *testing.T) { agent := createTestAgent(t, dbClient, agentRef) session1 := createTestSession(t, dbClient, "session-1", userID, agent.ID) session2 := createTestSession(t, dbClient, "session-2", userID, agent.ID) + base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + setSessionActivity(t, session1.ID, userID, base, base.Add(2*time.Hour)) + setSessionActivity(t, session2.ID, userID, base.Add(time.Hour), base.Add(time.Hour)) req := httptest.NewRequest("GET", "/api/agents/"+namespace+"/"+agentName+"/sessions", nil) req = mux.SetURLVars(req, map[string]string{"namespace": namespace, "name": agentName}) diff --git a/ui/src/components/sidebars/ChatItem.stories.tsx b/ui/src/components/sidebars/ChatItem.stories.tsx index 406405a2f..0b40936f4 100644 --- a/ui/src/components/sidebars/ChatItem.stories.tsx +++ b/ui/src/components/sidebars/ChatItem.stories.tsx @@ -62,7 +62,7 @@ export const ShortTitle: Story = { agentNamespace: "kagent", onDelete: async () => {}, sessionName: "Quick question", - createdAt: new Date().toISOString(), + activityAt: new Date().toISOString(), }, }; @@ -73,7 +73,7 @@ export const LongTitle: Story = { agentNamespace: "kagent", onDelete: async () => {}, sessionName: "Review https://github.com/Smartest-Fly/app/pull/1234 and provide feedback on the authentication implementation", - createdAt: new Date().toISOString(), + activityAt: new Date().toISOString(), }, }; @@ -84,7 +84,7 @@ export const LongTitleWithAgentName: Story = { agentNamespace: "kagent", onDelete: async () => {}, sessionName: "Review https://github.com/Smartest-Fly/app/pull/1234 and provide feedback on the authentication implementation", - createdAt: new Date().toISOString(), + activityAt: new Date().toISOString(), }, }; @@ -107,7 +107,7 @@ export const MultipleLongTitles: Story = { agentNamespace="kagent" onDelete={async () => {}} sessionName={title} - createdAt={new Date(Date.now() - i * 3600000).toISOString()} + activityAt={new Date(Date.now() - i * 3600000).toISOString()} /> ))} diff --git a/ui/src/components/sidebars/ChatItem.tsx b/ui/src/components/sidebars/ChatItem.tsx index 76b7ebdfe..cf2b3220e 100644 --- a/ui/src/components/sidebars/ChatItem.tsx +++ b/ui/src/components/sidebars/ChatItem.tsx @@ -22,12 +22,12 @@ interface ChatItemProps { agentNamespace?: string; sessionName?: string; onDownload?: (sessionId: string) => Promise; - createdAt?: string; + activityAt?: string; /** When true, omit delete (e.g. Sandbox single-session agents). */ hideDelete?: boolean; } -const ChatItem = ({ sessionId, agentName, agentNamespace, onDelete, sessionName, onDownload, createdAt, hideDelete }: ChatItemProps) => { +const ChatItem = ({ sessionId, agentName, agentNamespace, onDelete, sessionName, onDownload, activityAt, hideDelete }: ChatItemProps) => { const title = sessionName || "Untitled"; // Format timestamp based on how recent it is @@ -60,7 +60,7 @@ const ChatItem = ({ sessionId, agentName, agentNamespace, onDelete, sessionName, style={{ background: 'linear-gradient(to right, transparent, hsl(var(--sidebar-background)) 30%)', }} - >{formatTime(createdAt)} + >{formatTime(activityAt)} diff --git a/ui/src/components/sidebars/GroupedChats.stories.tsx b/ui/src/components/sidebars/GroupedChats.stories.tsx index 2c5e4927c..5df53ebbb 100644 --- a/ui/src/components/sidebars/GroupedChats.stories.tsx +++ b/ui/src/components/sidebars/GroupedChats.stories.tsx @@ -27,13 +27,13 @@ const meta: Meta = { export default meta; type Story = StoryObj; -const createSession = (id: string, name: string, daysAgo: number): Session => ({ +const createSession = (id: string, name: string, createdDaysAgo: number, updatedDaysAgo = createdDaysAgo): Session => ({ id, name, agent_id: 'kgent__NS__k8s', user_id: "user-1", - created_at: new Date(Date.now() - daysAgo * 24 * 3600000).toISOString(), - updated_at: new Date(Date.now() - daysAgo * 24 * 3600000).toISOString(), + created_at: new Date(Date.now() - createdDaysAgo * 24 * 3600000).toISOString(), + updated_at: new Date(Date.now() - updatedDaysAgo * 24 * 3600000).toISOString(), deleted_at: "", }); @@ -81,3 +81,15 @@ export const ManySessions: Story = { ], }, }; + +export const RecentlyUpdatedOlderSession: Story = { + args: { + agentName: "k8s", + agentNamespace: "kagent", + sessions: [ + createSession("session-old-active", "Created last week, active today", 7, 0), + createSession("session-new-inactive", "Created today, inactive", 0, 0.2), + createSession("session-yesterday", "Yesterday chat", 1), + ], + }, +}; diff --git a/ui/src/components/sidebars/GroupedChats.tsx b/ui/src/components/sidebars/GroupedChats.tsx index 6e63a72f0..095acd54d 100644 --- a/ui/src/components/sidebars/GroupedChats.tsx +++ b/ui/src/components/sidebars/GroupedChats.tsx @@ -29,36 +29,42 @@ export default function GroupedChats({ agentName, agentNamespace, sessions, hide }, [sessions]); const groupedChats = useMemo(() => { + type SessionWithActivity = { + session: Session; + activityTimestamp: number; + }; + const groups: { - today: Session[]; - yesterday: Session[]; - older: Session[]; + today: SessionWithActivity[]; + yesterday: SessionWithActivity[]; + older: SessionWithActivity[]; } = { today: [], yesterday: [], older: [], }; - // Process each session and group by date - localSessions.forEach(session => { - const date = new Date(session.created_at); + const sessionsWithActivity = localSessions.map(session => ({ + session, + activityTimestamp: Date.parse(session.updated_at || session.created_at), + })); + + // Process each session and group by last activity date + sessionsWithActivity.forEach(sessionWithActivity => { + const date = new Date(sessionWithActivity.activityTimestamp); if (isToday(date)) { - groups.today.push(session); + groups.today.push(sessionWithActivity); } else if (isYesterday(date)) { - groups.yesterday.push(session); + groups.yesterday.push(sessionWithActivity); } else { - groups.older.push(session); + groups.older.push(sessionWithActivity); } }); - const sortChats = (sessions: Session[]) => - sessions.sort((a, b) => { - const getLatestTimestamp = (session: Session) => { - return new Date(session.created_at).getTime(); - }; - - return getLatestTimestamp(b) - getLatestTimestamp(a); - }); + const sortChats = (sessions: SessionWithActivity[]) => + sessions + .sort((a, b) => b.activityTimestamp - a.activityTimestamp) + .map(({ session }) => session); return { today: sortChats(groups.today), diff --git a/ui/src/components/sidebars/SessionGroup.tsx b/ui/src/components/sidebars/SessionGroup.tsx index e997f3260..43f1c9826 100644 --- a/ui/src/components/sidebars/SessionGroup.tsx +++ b/ui/src/components/sidebars/SessionGroup.tsx @@ -30,7 +30,7 @@ const ChatGroup = ({ title, sessions, onDeleteSession, onDownloadSession, agentN {sessions.map((session) => ( - + ))}