Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions go/core/internal/database/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Comment on lines +262 to +266
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general tests like this shouldn't execute raw SQL. Rather we should use the new queries to prove the actual behavior

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.
Expand Down
10 changes: 10 additions & 0 deletions go/core/internal/database/gen/events.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions go/core/internal/database/gen/sessions.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions go/core/internal/database/gen/tasks.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion go/core/internal/database/queries/events.sql
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 3 additions & 3 deletions go/core/internal/database/queries/sessions.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 10 additions & 1 deletion go/core/internal/database/queries/tasks.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;
16 changes: 16 additions & 0 deletions go/core/internal/httpserver/handlers/sessions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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})
Expand Down
8 changes: 4 additions & 4 deletions ui/src/components/sidebars/ChatItem.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export const ShortTitle: Story = {
agentNamespace: "kagent",
onDelete: async () => {},
sessionName: "Quick question",
createdAt: new Date().toISOString(),
activityAt: new Date().toISOString(),
},
};

Expand All @@ -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(),
},
};

Expand All @@ -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(),
},
};

Expand All @@ -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()}

/>
))}
Expand Down
6 changes: 3 additions & 3 deletions ui/src/components/sidebars/ChatItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ interface ChatItemProps {
agentNamespace?: string;
sessionName?: string;
onDownload?: (sessionId: string) => Promise<void>;
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
Expand Down Expand Up @@ -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)}</span>
>{formatTime(activityAt)}</span>
</Link>
</SidebarMenuButton>
<DropdownMenu modal={false}>
Expand Down
18 changes: 15 additions & 3 deletions ui/src/components/sidebars/GroupedChats.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@ const meta: Meta<typeof GroupedChats> = {
export default meta;
type Story = StoryObj<typeof GroupedChats>;

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: "",
});

Expand Down Expand Up @@ -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),
],
},
};
Loading
Loading