diff --git a/docs/adr/0003-cursor-pagination-and-list-response-envelope.md b/docs/adr/0003-cursor-pagination-and-list-response-envelope.md new file mode 100644 index 00000000..6386cd31 --- /dev/null +++ b/docs/adr/0003-cursor-pagination-and-list-response-envelope.md @@ -0,0 +1,39 @@ +# ADR 0003 — Cursor pagination and list response envelope + +- **Status:** Accepted +- **Date:** 2026-08-23 + +## Context + +`GET /conversations` previously returned a flat, unbounded list of all conversations for a user. As conversation counts grow per user, loading the complete list in one request causes high latency, database scan overhead, and excessive payload sizes for clients. + +Additionally, simple offset pagination (`OFFSET N`) suffers from severe database performance degradation on large offsets and produces inconsistent results (skipped or duplicated rows) if conversations receive messages or are created while a user is paginating. + +## Decision + +1. **Keyset Cursor Pagination**: + - `GET /conversations` uses keyset pagination based on the composite ordering key `(COALESCE(last_message_at, created_at) DESC, session_id DESC)`. + - The active timestamp `COALESCE(last_message_at, created_at)` ensures empty conversations without messages sort predictably by their creation time alongside active threads. + +2. **Opaque Base64 Cursor Token**: + - Cursors are opaque server tokens containing base64-encoded JSON `{"t": "", "id": ""}`. + - The pagination token codec (`encode_cursor`, `decode_cursor`) and exception `InvalidCursorError` live in `agent_manager.domain.pagination` (pure Python domain value objects and utilities with zero framework dependencies). + - Malformed or invalid cursor tokens raise `InvalidCursorError`, which is mapped by `as_http_error()` to `HTTP 400 Bad Request` with payload `{ "error_type": "invalid_cursor", "message": "invalid pagination cursor" }`. + +3. **Domain Layer Bounds**: + - `PageRequest` value object encapsulates pagination parameters (`limit`, `cursor`). + - Default page size is 20 (`DEFAULT_PAGE_LIMIT`) and maximum page limit is 100 (`MAX_PAGE_LIMIT`), enforced at domain instantiation time in `PageRequest.__post_init__`. + +4. **Database Indexing**: + - Migration `0005_add_session_pagination_index.py` and `tables.py` add an expression index `idx_conversation_sessions_user_active_session` on `(user_id, COALESCE(last_message_at, created_at), session_id)` to enable fast index range seeks for keyset pagination. + +## Contract changes + +- **BREAKING CHANGE**: `GET /conversations` response shape changed from a flat list `[ConversationSummary, ...]` to a paginated envelope object `{ "items": [ConversationSummary, ...], "next_cursor": "..." | null }`. +- `GET /conversations` accepts optional query parameters `limit` (integer, 1..100) and `cursor` (opaque string). + +## Consequences + +- Clients fetch subsequent pages using `next_cursor` until `next_cursor` is `null`. +- Keyset range seeks eliminate `OFFSET` database performance degradation and prevent skipped/duplicated sessions when thread activity changes mid-page. +- Frontends deduplicate threads by `conversation_id` to handle live thread updates gracefully. diff --git a/docs/api.mdx b/docs/api.mdx index eb5bd5b3..0d177d2a 100644 --- a/docs/api.mdx +++ b/docs/api.mdx @@ -166,6 +166,36 @@ Response: Errors: `409` the supplied `session_id` belongs to another user. +### `GET /conversations` + +List the calling user's conversations, ordered by most recently active first, using keyset pagination. + +**Query Parameters:** +- `limit` *(integer, optional, default: 20, min: 1, max: 100)* — Maximum number of conversations to return per page. +- `cursor` *(string, optional)* — Opaque pagination cursor token obtained from `next_cursor` of the previous page. + +```bash +curl "http://localhost:8100/conversations?limit=20" \ + -H "Authorization: Bearer $TOKEN" +``` + +Response: + +```json +{ + "items": [ + { + "conversation_id": "0d5a…", + "title": "Order status inquiry", + "last_message_at": "2026-08-20T14:00:00+00:00" + } + ], + "next_cursor": "eyJ0IjoiMjAyNi0wOC0yMFQxNDowMDowMCswMDowMCIsImlkIjoic2Vzcy0xMiJ9" +} +``` + +When `next_cursor` is `null`, no further pages remain. Cursors are opaque server tokens and must not be constructed manually by clients. + ### `POST /conversations/{id}/messages` Send a message. Prior history is assembled automatically. diff --git a/src/agent_manager/api/errors.py b/src/agent_manager/api/errors.py index 5513e36f..b0984d29 100644 --- a/src/agent_manager/api/errors.py +++ b/src/agent_manager/api/errors.py @@ -22,12 +22,17 @@ ConversationMessageNotFound, ConversationNotFound, ConversationTokenBudgetExceeded, + InvalidCursorError, ) BUDGET_EXCEEDED_DETAIL = { "error_type": "context_limit_exceeded", "message": "This conversation has reached its context limit. Start a new chat to continue.", } +INVALID_CURSOR_DETAIL = { + "error_type": "invalid_cursor", + "message": "invalid pagination cursor", +} INTERNAL_ERROR_MESSAGE = "Internal server error" _HTTP_ERRORS: dict[type[Exception], tuple[int, Any]] = { @@ -38,6 +43,7 @@ ConversationMessageNotFound: (404, "message not found on active conversation branch"), ConversationBranchConflict: (409, "conversation branch changed; reload and try again"), ConversationLinkRefused: (403, "a visitor cannot adopt another visitor"), + InvalidCursorError: (400, INVALID_CURSOR_DETAIL), } diff --git a/src/agent_manager/api/routes/conversations.py b/src/agent_manager/api/routes/conversations.py index 13fb7e69..441ebaf7 100644 --- a/src/agent_manager/api/routes/conversations.py +++ b/src/agent_manager/api/routes/conversations.py @@ -7,7 +7,7 @@ from collections.abc import AsyncGenerator, AsyncIterator from typing import cast -from fastapi import APIRouter +from fastapi import APIRouter, Query from fastapi.responses import StreamingResponse from agent_engine.runtime.streaming import RunStreamEvent @@ -23,11 +23,13 @@ CreateConversationRequest, CreateConversationResponse, MessageOut, + PaginatedConversationsResponse, SendMessageRequest, SendMessageResponse, StreamEventOut, TokenBudgetResponse, ) +from agent_manager.domain import DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT, PageRequest router = APIRouter() logger = logging.getLogger(__name__) @@ -45,17 +47,25 @@ async def create_conversation( return CreateConversationResponse(conversation_id=session_id, session_id=session_id) -@router.get("/conversations", response_model=list[ConversationSummary]) -async def list_conversations(service: Service, caller: Caller) -> list[ConversationSummary]: - sessions = await service.list_conversations(caller) - return [ +@router.get("/conversations", response_model=PaginatedConversationsResponse) +async def list_conversations( + service: Service, + caller: Caller, + limit: int = Query(default=DEFAULT_PAGE_LIMIT, ge=1, le=MAX_PAGE_LIMIT), + cursor: str | None = Query(default=None), +) -> PaginatedConversationsResponse: + with as_http_error(): + page = PageRequest(limit=limit, cursor=cursor) + paginated = await service.list_conversations(caller, page=page) + items = [ ConversationSummary( conversation_id=session.session_id, title=session.title, last_message_at=session.last_message_at, ) - for session in sessions + for session in paginated.items ] + return PaginatedConversationsResponse(items=items, next_cursor=paginated.next_cursor) @router.get("/conversations/{conversation_id}/messages", response_model=list[MessageOut]) diff --git a/src/agent_manager/api/schemas.py b/src/agent_manager/api/schemas.py index dbde31b7..e5c987a3 100644 --- a/src/agent_manager/api/schemas.py +++ b/src/agent_manager/api/schemas.py @@ -47,6 +47,11 @@ class ConversationSummary(BaseModel): last_message_at: datetime | None = None +class PaginatedConversationsResponse(BaseModel): + items: list[ConversationSummary] + next_cursor: str | None = None + + class MessageOut(BaseModel): message_id: str run_id: str | None = None diff --git a/src/agent_manager/api/static/widget.js b/src/agent_manager/api/static/widget.js index c3d3cf23..e9614007 100644 --- a/src/agent_manager/api/static/widget.js +++ b/src/agent_manager/api/static/widget.js @@ -52163,15 +52163,21 @@ var AgentChatClient = class { const data = await response.json(); return String(data.conversation_id); } - async listConversations() { - const response = await this.request("/conversations"); + async listConversations(limit = 20, cursor) { + const params = new URLSearchParams({ limit: String(limit) }); + if (cursor) params.set("cursor", cursor); + const response = await this.request(`/conversations?${params.toString()}`); const data = await response.json(); - if (!Array.isArray(data)) return []; - return data.map((thread) => ({ + const rawItems = Array.isArray(data.items) ? data.items : []; + const items = rawItems.map((thread) => ({ conversation_id: String(thread.conversation_id), title: thread.title ?? null, last_message_at: thread.last_message_at ?? null })); + return { + items, + next_cursor: data.next_cursor ?? null + }; } async getMessages(conversationId) { const response = await this.request(`/conversations/${conversationId}/messages`); @@ -52635,20 +52641,6 @@ function randomId() { return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } -// src/agent_manager/api/static/widget/storage/conversationStorage.ts -function conversationStorageKey(endpoint) { - return `agent-chat:${endpoint}`; -} -function getStoredConversationId(endpoint, storage = localStorage) { - return storage.getItem(conversationStorageKey(endpoint)); -} -function setStoredConversationId(endpoint, conversationId, storage = localStorage) { - storage.setItem(conversationStorageKey(endpoint), conversationId); -} -function removeStoredConversationId(endpoint, storage = localStorage) { - storage.removeItem(conversationStorageKey(endpoint)); -} - // src/agent_manager/api/static/widget/react/shadcnAiElements.tsx var import_react8 = __toESM(require_react(), 1); @@ -53314,6 +53306,22 @@ function upsertTool(tools, next2) { // src/agent_manager/api/static/widget/react/useConversation.ts var import_react9 = __toESM(require_react(), 1); + +// src/agent_manager/api/static/widget/storage/conversationStorage.ts +function conversationStorageKey(endpoint) { + return `agent-chat:${endpoint}`; +} +function getStoredConversationId(endpoint, storage = localStorage) { + return storage.getItem(conversationStorageKey(endpoint)); +} +function setStoredConversationId(endpoint, conversationId, storage = localStorage) { + storage.setItem(conversationStorageKey(endpoint), conversationId); +} +function removeStoredConversationId(endpoint, storage = localStorage) { + storage.removeItem(conversationStorageKey(endpoint)); +} + +// src/agent_manager/api/static/widget/react/useConversation.ts var isUnusableConversation = (error) => error instanceof AgentChatHttpError && (error.status === 404 || error.status === 403); function useConversation(client, endpoint, onReplaced) { const startConversation = (0, import_react9.useCallback)(async () => { @@ -53392,7 +53400,10 @@ function useConversation(client, endpoint, onReplaced) { }, [client] ); - const listThreads = (0, import_react9.useCallback)(() => client.listConversations().catch(() => []), [client]); + const listThreads = (0, import_react9.useCallback)( + (limit, cursor) => client.listConversations(limit, cursor), + [client] + ); const switchTo = (0, import_react9.useCallback)( (conversationId) => setStoredConversationId(endpoint, conversationId), [endpoint] @@ -53435,6 +53446,8 @@ function useConversation(client, endpoint, onReplaced) { // src/agent_manager/api/static/widget/react/AgentChatApp.tsx var import_jsx_runtime4 = __toESM(require_jsx_runtime(), 1); +var THREADS_PAGE_SIZE = 20; +var SCROLL_THRESHOLD_PX = 40; var DEFAULT_GREETING = "How can I help you today?"; var GENERIC_ERROR = "Something went wrong. Please try again."; var COPIED_RESET_MS = 2e3; @@ -53480,7 +53493,13 @@ function AgentChatApp({ const canSubmit = !isExecutionActive && !budgetExceeded && !approvalBlocksComposer; const canStop = isExecutionActive; const [threads, setThreads] = (0, import_react10.useState)([]); + const [nextCursor, setNextCursor] = (0, import_react10.useState)(null); + const [loadingMoreThreads, setLoadingMoreThreads] = (0, import_react10.useState)(false); + const [threadsError, setThreadsError] = (0, import_react10.useState)(null); const [threadsOpen, setThreadsOpen] = (0, import_react10.useState)(false); + const hasMoreThreads = nextCursor !== null; + const isLoadingMoreRef = (0, import_react10.useRef)(false); + const threadsGenerationRef = (0, import_react10.useRef)(0); const launcherRef = (0, import_react10.useRef)(null); const inputRef = (0, import_react10.useRef)(null); const approvalRequestsRef = (0, import_react10.useRef)(/* @__PURE__ */ new Set()); @@ -53561,9 +53580,54 @@ function AgentChatApp({ launcherRef.current?.focus({ preventScroll: true }); }, [inline]); const openThreads = (0, import_react10.useCallback)(async () => { - setThreads(await conversation.listThreads()); + threadsGenerationRef.current += 1; + const currentGen = threadsGenerationRef.current; setThreadsOpen(true); + setLoadingMoreThreads(true); + setThreadsError(null); + isLoadingMoreRef.current = true; + try { + const res = await conversation.listThreads(THREADS_PAGE_SIZE, null); + if (threadsGenerationRef.current !== currentGen) return; + setThreads(res.items); + setNextCursor(res.next_cursor); + } catch (err) { + if (threadsGenerationRef.current !== currentGen) return; + const msg = err instanceof AgentChatHttpError ? err.message : GENERIC_ERROR; + setThreadsError(msg); + } finally { + if (threadsGenerationRef.current === currentGen) { + setLoadingMoreThreads(false); + isLoadingMoreRef.current = false; + } + } }, [conversation]); + const loadMoreThreads = (0, import_react10.useCallback)(async () => { + if (isLoadingMoreRef.current || !nextCursor) return; + const currentGen = threadsGenerationRef.current; + isLoadingMoreRef.current = true; + setLoadingMoreThreads(true); + setThreadsError(null); + try { + const res = await conversation.listThreads(THREADS_PAGE_SIZE, nextCursor); + if (threadsGenerationRef.current !== currentGen) return; + setThreads((prev) => { + const existingIds = new Set(prev.map((t) => t.conversation_id)); + const newItems = res.items.filter((t) => !existingIds.has(t.conversation_id)); + return [...prev, ...newItems]; + }); + setNextCursor(res.next_cursor); + } catch (err) { + if (threadsGenerationRef.current !== currentGen) return; + const msg = err instanceof AgentChatHttpError ? err.message : GENERIC_ERROR; + setThreadsError(msg); + } finally { + if (threadsGenerationRef.current === currentGen) { + setLoadingMoreThreads(false); + isLoadingMoreRef.current = false; + } + } + }, [conversation, nextCursor]); const openThread = (0, import_react10.useCallback)( async (conversationId) => { conversation.switchTo(conversationId); @@ -53910,8 +53974,13 @@ function AgentChatApp({ { open: threadsOpen, threads, - activeId: getStoredConversationId(config.endpoint), - onSelect: openThread, + activeId, + loadingMore: loadingMoreThreads, + error: threadsError, + hasMore: hasMoreThreads, + onLoadMore: () => void loadMoreThreads(), + onRetry: () => threads.length === 0 ? void openThreads() : void loadMoreThreads(), + onSelect: (cid) => void openThread(cid), onNew: startNewThread, onClose: () => setThreadsOpen(false) } @@ -54160,10 +54229,21 @@ function ThreadDrawer({ open, threads, activeId, + loadingMore, + error, + hasMore, + onLoadMore, + onRetry, onSelect, onNew, onClose }) { + const handleScroll = (e) => { + const { scrollTop, clientHeight, scrollHeight } = e.currentTarget; + if (scrollHeight - scrollTop - clientHeight < SCROLL_THRESHOLD_PX && hasMore && !loadingMore && !error) { + onLoadMore(); + } + }; return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: `thread-drawer${open ? " open" : ""}`, inert: !open, children: [ /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "thread-drawer-head", children: [ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("span", { children: "Chats" }), @@ -54173,7 +54253,7 @@ function ThreadDrawer({ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(SquarePen, { "aria-hidden": true }), "New chat" ] }), - /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "thread-list", children: [ + /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "thread-list", onScroll: handleScroll, children: [ threads.map((thread) => /* @__PURE__ */ (0, import_jsx_runtime4.jsx)( "button", { @@ -54185,7 +54265,13 @@ function ThreadDrawer({ }, thread.conversation_id )), - threads.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: "thread-empty", children: "No conversations yet" }) : null + threads.length === 0 && !loadingMore && !error ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: "thread-empty", children: "No conversations yet" }) : null, + error ? /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "thread-empty thread-error", children: [ + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { children: error }), + /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { className: "thread-retry-btn", onClick: onRetry, type: "button", children: "Retry" }) + ] }) : null, + loadingMore ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { className: "thread-empty", children: "Loading..." }) : null, + hasMore && !loadingMore && !error ? /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("button", { className: "thread-load-more-btn", onClick: onLoadMore, type: "button", children: "Load more" }) : null ] }) ] }); } diff --git a/src/agent_manager/api/static/widget/api/AgentChatClient.ts b/src/agent_manager/api/static/widget/api/AgentChatClient.ts index 4505b205..7c5348d4 100644 --- a/src/agent_manager/api/static/widget/api/AgentChatClient.ts +++ b/src/agent_manager/api/static/widget/api/AgentChatClient.ts @@ -2,6 +2,7 @@ import type { TokenSource } from "../auth/tokenSource"; import type { ApprovalDecision, ChatMessage, + PaginatedThreads, TokenBudget, SendMessageResponse, StreamEvent, @@ -67,16 +68,23 @@ export class AgentChatClient { return String(data.conversation_id); } - async listConversations(): Promise { - const response = await this.request("/conversations"); + async listConversations(limit = 20, cursor?: string | null): Promise { + const params = new URLSearchParams({ limit: String(limit) }); + if (cursor) params.set("cursor", cursor); + const response = await this.request(`/conversations?${params.toString()}`); const data = await response.json(); - if (!Array.isArray(data)) return []; - return data.map((thread) => ({ + const rawItems: Array<{ conversation_id: string; title?: string | null; last_message_at?: string | null }> = + Array.isArray(data.items) ? data.items : []; + const items: ThreadSummary[] = rawItems.map((thread) => ({ conversation_id: String(thread.conversation_id), title: thread.title ?? null, last_message_at: thread.last_message_at ?? null, })); + return { + items, + next_cursor: (data.next_cursor as string | null) ?? null, + }; } async getMessages(conversationId: string): Promise { diff --git a/src/agent_manager/api/static/widget/react/AgentChatApp.tsx b/src/agent_manager/api/static/widget/react/AgentChatApp.tsx index 47334b19..b3fc2166 100644 --- a/src/agent_manager/api/static/widget/react/AgentChatApp.tsx +++ b/src/agent_manager/api/static/widget/react/AgentChatApp.tsx @@ -43,6 +43,9 @@ import { import { reduceStreamEvent } from "./streamReducer"; import { useConversation } from "./useConversation"; +const THREADS_PAGE_SIZE = 20; +const SCROLL_THRESHOLD_PX = 40; + const DEFAULT_GREETING = "How can I help you today?"; const GENERIC_ERROR = "Something went wrong. Please try again."; const COPIED_RESET_MS = 2000; @@ -114,7 +117,13 @@ export function AgentChatApp({ const canStop = isExecutionActive; const [threads, setThreads] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + const [loadingMoreThreads, setLoadingMoreThreads] = useState(false); + const [threadsError, setThreadsError] = useState(null); const [threadsOpen, setThreadsOpen] = useState(false); + const hasMoreThreads = nextCursor !== null; + const isLoadingMoreRef = useRef(false); + const threadsGenerationRef = useRef(0); const launcherRef = useRef(null); const inputRef = useRef(null); const approvalRequestsRef = useRef(new Set()); @@ -213,10 +222,59 @@ export function AgentChatApp({ }, [inline]); const openThreads = useCallback(async () => { - setThreads(await conversation.listThreads()); + threadsGenerationRef.current += 1; + const currentGen = threadsGenerationRef.current; setThreadsOpen(true); + setLoadingMoreThreads(true); + setThreadsError(null); + isLoadingMoreRef.current = true; + try { + const res = await conversation.listThreads(THREADS_PAGE_SIZE, null); + if (threadsGenerationRef.current !== currentGen) return; + setThreads(res.items); + setNextCursor(res.next_cursor); + } catch (err) { + if (threadsGenerationRef.current !== currentGen) return; + const msg = err instanceof AgentChatHttpError ? err.message : GENERIC_ERROR; + setThreadsError(msg); + } finally { + if (threadsGenerationRef.current === currentGen) { + setLoadingMoreThreads(false); + isLoadingMoreRef.current = false; + } + } }, [conversation]); + const loadMoreThreads = useCallback(async () => { + if (isLoadingMoreRef.current || !nextCursor) return; + const currentGen = threadsGenerationRef.current; + isLoadingMoreRef.current = true; + setLoadingMoreThreads(true); + setThreadsError(null); + try { + const res = await conversation.listThreads(THREADS_PAGE_SIZE, nextCursor); + if (threadsGenerationRef.current !== currentGen) return; + setThreads((prev) => { + // Keyset pagination sorts by (last_message_at, session_id). Since last_message_at + // is mutable, a thread updated while scrolling could appear across page boundaries; + // deduplication prevents duplicate items if order mutates mid-scroll. + const existingIds = new Set(prev.map((t) => t.conversation_id)); + const newItems = res.items.filter((t) => !existingIds.has(t.conversation_id)); + return [...prev, ...newItems]; + }); + setNextCursor(res.next_cursor); + } catch (err) { + if (threadsGenerationRef.current !== currentGen) return; + const msg = err instanceof AgentChatHttpError ? err.message : GENERIC_ERROR; + setThreadsError(msg); + } finally { + if (threadsGenerationRef.current === currentGen) { + setLoadingMoreThreads(false); + isLoadingMoreRef.current = false; + } + } + }, [conversation, nextCursor]); + const openThread = useCallback( async (conversationId: string) => { conversation.switchTo(conversationId); @@ -228,7 +286,6 @@ export function AgentChatApp({ inputRef.current?.focus({ preventScroll: true }); }, [conversation, entriesById, loadThread, refreshUsage], - ); const startNewThread = useCallback(() => { @@ -597,8 +654,13 @@ export function AgentChatApp({ void loadMoreThreads()} + onRetry={() => (threads.length === 0 ? void openThreads() : void loadMoreThreads())} + onSelect={(cid) => void openThread(cid)} onNew={startNewThread} onClose={() => setThreadsOpen(false)} /> @@ -929,6 +991,11 @@ function ThreadDrawer({ open, threads, activeId, + loadingMore, + error, + hasMore, + onLoadMore, + onRetry, onSelect, onNew, onClose, @@ -936,10 +1003,22 @@ function ThreadDrawer({ open: boolean; threads: ThreadSummary[]; activeId: string | null; + loadingMore: boolean; + error: string | null; + hasMore: boolean; + onLoadMore: () => void; + onRetry: () => void; onSelect: (conversationId: string) => void; onNew: () => void; onClose: () => void; }) { + const handleScroll = (e: React.UIEvent) => { + const { scrollTop, clientHeight, scrollHeight } = e.currentTarget; + if (scrollHeight - scrollTop - clientHeight < SCROLL_THRESHOLD_PX && hasMore && !loadingMore && !error) { + onLoadMore(); + } + }; + return (
@@ -952,7 +1031,7 @@ function ThreadDrawer({ New chat -
+
{threads.map((thread) => ( +
+ ) : null} + {loadingMore ?

Loading...

: null} + {hasMore && !loadingMore && !error ? ( + + ) : null}
); diff --git a/src/agent_manager/api/static/widget/react/useConversation.ts b/src/agent_manager/api/static/widget/react/useConversation.ts index d508bdbd..02849442 100644 --- a/src/agent_manager/api/static/widget/react/useConversation.ts +++ b/src/agent_manager/api/static/widget/react/useConversation.ts @@ -9,6 +9,7 @@ import { import type { ApprovalDecision, ChatMessage, + PaginatedThreads, TokenBudget, SendMessageResponse, StreamEvent, @@ -52,7 +53,7 @@ export interface Conversation { cancelApproval(conversationId: string, runId: string, approvalId: string): Promise; loadHistory(conversationId: string): Promise; loadUsage(conversationId: string): Promise; - listThreads(): Promise; + listThreads(limit?: number, cursor?: string | null): Promise; switchTo(conversationId: string): void; startNew(): void; } @@ -183,7 +184,10 @@ export function useConversation( [client], ); - const listThreads = useCallback(() => client.listConversations().catch(() => []), [client]); + const listThreads = useCallback( + (limit?: number, cursor?: string | null) => client.listConversations(limit, cursor), + [client], + ); const switchTo = useCallback( (conversationId: string) => setStoredConversationId(endpoint, conversationId), diff --git a/src/agent_manager/api/static/widget/types.ts b/src/agent_manager/api/static/widget/types.ts index 6a76c82d..fd1cb5fb 100644 --- a/src/agent_manager/api/static/widget/types.ts +++ b/src/agent_manager/api/static/widget/types.ts @@ -33,6 +33,11 @@ export interface ThreadSummary { last_message_at: string | null; } +export interface PaginatedThreads { + items: ThreadSummary[]; + next_cursor: string | null; +} + export interface ChatMessage { message_id: string; run_id?: string | null; diff --git a/src/agent_manager/application/__init__.py b/src/agent_manager/application/__init__.py index fee71641..a9cf7f11 100644 --- a/src/agent_manager/application/__init__.py +++ b/src/agent_manager/application/__init__.py @@ -9,6 +9,7 @@ ConversationMessageNotFound, ConversationNotFound, ConversationTokenBudgetExceeded, + InvalidCursorError, ) from agent_manager.application.prepared_conversation_turn import PreparedConversationTurn @@ -21,5 +22,6 @@ "ConversationNotFound", "ConversationService", "ConversationTokenBudgetExceeded", + "InvalidCursorError", "PreparedConversationTurn", ] diff --git a/src/agent_manager/application/conversation_service.py b/src/agent_manager/application/conversation_service.py index 3deb04aa..24b71126 100644 --- a/src/agent_manager/application/conversation_service.py +++ b/src/agent_manager/application/conversation_service.py @@ -42,6 +42,8 @@ from agent_manager.domain import ( ConversationMessage, ConversationSession, + Page, + PageRequest, Principal, Repository, Role, @@ -136,9 +138,9 @@ async def usage(self, conversation_id: str, principal: Principal) -> TokenBudget return TokenBudgetUsage.from_totals(used, self._max_tokens) async def list_conversations( - self, principal: Principal, *, limit: int = 50 - ) -> list[ConversationSession]: - return await self._repository.list_sessions(principal.user_id, limit=limit) + self, principal: Principal, page: PageRequest | None = None + ) -> Page[ConversationSession]: + return await self._repository.list_sessions(principal.user_id, page=page) async def send( self, diff --git a/src/agent_manager/application/errors.py b/src/agent_manager/application/errors.py index 2b6294ff..eaccf47a 100644 --- a/src/agent_manager/application/errors.py +++ b/src/agent_manager/application/errors.py @@ -1,5 +1,18 @@ """Application-layer failures exposed by conversation use cases.""" +from agent_manager.domain.pagination import InvalidCursorError + +__all__ = [ + "ConversationAccessDenied", + "ConversationAlreadyExists", + "ConversationBranchConflict", + "ConversationLinkRefused", + "ConversationMessageNotFound", + "ConversationNotFound", + "ConversationTokenBudgetExceeded", + "InvalidCursorError", +] + class ConversationNotFound(Exception): """An operation targeted a conversation id that does not exist.""" diff --git a/src/agent_manager/domain/__init__.py b/src/agent_manager/domain/__init__.py index d5286e4c..8a42eca1 100644 --- a/src/agent_manager/domain/__init__.py +++ b/src/agent_manager/domain/__init__.py @@ -2,31 +2,51 @@ from agent_manager.domain.identity import IdentityNamespace, Principal from agent_manager.domain.models import ( + DEFAULT_PAGE_LIMIT, + MAX_PAGE_LIMIT, BudgetSeverity, ConversationContext, ConversationMessage, ConversationSession, ConversationSnapshot, Message, + Page, + PageRequest, + PaginatedSessions, Role, TokenBudgetUsage, User, thread_title, ) +from agent_manager.domain.pagination import ( + InvalidCursorError, + decode_cursor, + encode_cursor, + ensure_utc, +) from agent_manager.domain.repository import Repository __all__ = [ + "DEFAULT_PAGE_LIMIT", + "MAX_PAGE_LIMIT", "BudgetSeverity", "ConversationContext", "ConversationMessage", "ConversationSession", "ConversationSnapshot", "IdentityNamespace", + "InvalidCursorError", "Message", + "Page", + "PageRequest", + "PaginatedSessions", "Principal", "Repository", "Role", "TokenBudgetUsage", "User", + "decode_cursor", + "encode_cursor", + "ensure_utc", "thread_title", ] diff --git a/src/agent_manager/domain/models.py b/src/agent_manager/domain/models.py index 8990a505..490262c9 100644 --- a/src/agent_manager/domain/models.py +++ b/src/agent_manager/domain/models.py @@ -2,10 +2,13 @@ from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass, field from datetime import datetime from enum import StrEnum -from typing import Any +from typing import Any, Generic, TypeVar + +T = TypeVar("T") BUDGET_WARNING_PERCENT = 65.0 BUDGET_CRITICAL_PERCENT = 85.0 @@ -56,6 +59,31 @@ class ConversationSession: expires_at: datetime | None = None +DEFAULT_PAGE_LIMIT = 20 +MAX_PAGE_LIMIT = 100 + + +@dataclass(frozen=True) +class PageRequest: + limit: int = DEFAULT_PAGE_LIMIT + cursor: str | None = None + + def __post_init__(self) -> None: + bounded_limit = max(1, min(self.limit, MAX_PAGE_LIMIT)) + object.__setattr__(self, "limit", bounded_limit) + if self.cursor is not None and not self.cursor.strip(): + object.__setattr__(self, "cursor", None) + + +@dataclass(frozen=True) +class Page(Generic[T]): + items: Sequence[T] + next_cursor: str | None = None + + +PaginatedSessions = Page[ConversationSession] + + @dataclass(frozen=True) class ConversationMessage: message_id: str diff --git a/src/agent_manager/domain/pagination.py b/src/agent_manager/domain/pagination.py new file mode 100644 index 00000000..ea4ff976 --- /dev/null +++ b/src/agent_manager/domain/pagination.py @@ -0,0 +1,60 @@ +"""Pagination models, token codec, and error types.""" + +from __future__ import annotations + +import base64 +import binascii +import json +from datetime import UTC, datetime + + +class InvalidCursorError(Exception): + """Raised when a pagination cursor token is malformed or unparseable.""" + + +def ensure_utc(value: datetime | None) -> datetime | None: + """Ensure a datetime is timezone-aware UTC.""" + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + + +def encode_cursor(active_at: datetime, session_id: str) -> str: + """Encode an active timestamp and session ID into an opaque base64 cursor token.""" + utc_dt = ensure_utc(active_at) + if utc_dt is None: + raise ValueError("active_at timestamp cannot be None") + payload = { + "t": utc_dt.isoformat(), + "id": session_id, + } + raw_bytes = json.dumps(payload).encode("utf-8") + return base64.urlsafe_b64encode(raw_bytes).decode("ascii") + + +def decode_cursor(cursor_str: str) -> tuple[datetime, str]: + """Decode a cursor token into (active_at_utc, session_id).""" + if not cursor_str or not cursor_str.strip(): + raise InvalidCursorError("Pagination cursor token cannot be empty") + try: + raw_bytes = base64.urlsafe_b64decode(cursor_str.encode("ascii")) + data = json.loads(raw_bytes.decode("utf-8")) + if not isinstance(data, dict) or "id" not in data or "t" not in data or data["t"] is None: + raise ValueError("Invalid cursor payload structure") + parsed_dt = datetime.fromisoformat(data["t"]) + active_at = ensure_utc(parsed_dt) + if active_at is None: + raise ValueError("Failed to parse active_at timestamp") + session_id = str(data["id"]) + return active_at, session_id + except ( + binascii.Error, + UnicodeDecodeError, + json.JSONDecodeError, + ValueError, + KeyError, + TypeError, + ) as exc: + raise InvalidCursorError("Invalid pagination cursor token") from exc diff --git a/src/agent_manager/domain/repository.py b/src/agent_manager/domain/repository.py index ab4c960a..02e5816a 100644 --- a/src/agent_manager/domain/repository.py +++ b/src/agent_manager/domain/repository.py @@ -12,6 +12,8 @@ ConversationSession, ConversationSnapshot, Message, + Page, + PageRequest, Role, User, ) @@ -66,8 +68,10 @@ async def create_session( async def get_session(self, session_id: str) -> ConversationSession | None: ... @abstractmethod - async def list_sessions(self, user_id: str, *, limit: int = 50) -> list[ConversationSession]: - """A user's sessions, most-recently-active first.""" + async def list_sessions( + self, user_id: str, page: PageRequest | None = None + ) -> Page[ConversationSession]: + """A user's sessions, most-recently-active first, with cursor pagination.""" @abstractmethod async def rename_session(self, session_id: str, title: str) -> None: ... diff --git a/src/agent_manager/infrastructure/persistence/memory_repository.py b/src/agent_manager/infrastructure/persistence/memory_repository.py index fd61a81f..fcf08ad2 100644 --- a/src/agent_manager/infrastructure/persistence/memory_repository.py +++ b/src/agent_manager/infrastructure/persistence/memory_repository.py @@ -7,6 +7,7 @@ from dataclasses import replace from datetime import UTC, datetime from typing import Any +from uuid import uuid4 from agent_manager.domain import ( ConversationContext, @@ -14,14 +15,37 @@ ConversationSession, ConversationSnapshot, Message, + Page, + PageRequest, Repository, Role, User, ) +from agent_manager.domain.pagination import ( + decode_cursor, + encode_cursor, + ensure_utc, +) _EPOCH = datetime(1970, 1, 1, tzinfo=UTC) +def _effective_t(s: ConversationSession) -> datetime: + dt = s.last_message_at or s.created_at or _EPOCH + res = ensure_utc(dt) + return res if res is not None else _EPOCH + + +def _is_after_cursor(s: ConversationSession, cursor_t: datetime, cursor_id: str) -> bool: + target_t = ensure_utc(cursor_t) or _EPOCH + eff_t = _effective_t(s) + if eff_t < target_t: + return True + if eff_t == target_t: + return (s.session_id or "") < cursor_id + return False + + class MemoryRepository(Repository): def __init__(self) -> None: self._users: dict[str, User] = {} @@ -38,23 +62,23 @@ async def upsert_user( display_name: str | None = None, metadata: dict[str, Any] | None = None, ) -> User: - now = datetime.now(UTC) existing = self._users.get(user_id) + now = datetime.now(UTC) user = User( user_id=user_id, external_user_id=external_user_id if external_user_id is not None - else existing.external_user_id - if existing - else None, - username=username if username is not None else existing.username if existing else None, + else (existing.external_user_id if existing else None), + username=username + if username is not None + else (existing.username if existing else None), display_name=display_name if display_name is not None - else existing.display_name - if existing - else None, + else (existing.display_name if existing else None), linked_to_user_id=existing.linked_to_user_id if existing else None, - metadata=dict(metadata or (existing.metadata if existing else {})), + metadata=dict( + metadata if metadata is not None else (existing.metadata if existing else {}) + ), created_at=existing.created_at if existing else now, updated_at=now, ) @@ -65,14 +89,30 @@ async def get_user(self, user_id: str) -> User | None: return self._users.get(user_id) async def link_anonymous_user(self, anonymous_user_id: str, user_id: str) -> int: - visitor = self._users.get(anonymous_user_id) - if visitor is None or visitor.linked_to_user_id is not None: + if anonymous_user_id == user_id: + return 0 + + target = self._users.get(user_id) + if target and target.linked_to_user_id: + return 0 + + anon = self._users.get(anonymous_user_id) + if anon and anon.linked_to_user_id: return 0 - self._users[anonymous_user_id] = replace(visitor, linked_to_user_id=user_id) - moved = [s for s in self._sessions.values() if s.user_id == anonymous_user_id] - for session in moved: - self._sessions[session.session_id] = replace(session, user_id=user_id) - return len(moved) + + moved = 0 + now = datetime.now(UTC) + for sid, session in list(self._sessions.items()): + if session.user_id == anonymous_user_id: + self._sessions[sid] = replace(session, user_id=user_id, updated_at=now) + moved += 1 + + if anon: + self._users[anonymous_user_id] = replace( + anon, linked_to_user_id=user_id, updated_at=now + ) + + return moved async def create_session( self, @@ -85,11 +125,11 @@ async def create_session( metadata: dict[str, Any] | None = None, expires_at: datetime | None = None, ) -> ConversationSession: - sid = session_id or uuid.uuid4().hex + sid = session_id or uuid4().hex + if sid in self._sessions: + return self._sessions[sid] + now = datetime.now(UTC) - existing = self._sessions.get(sid) - if existing is not None: - return existing session = ConversationSession( session_id=sid, user_id=user_id, @@ -109,10 +149,33 @@ async def create_session( async def get_session(self, session_id: str) -> ConversationSession | None: return self._sessions.get(session_id) - async def list_sessions(self, user_id: str, *, limit: int = 50) -> list[ConversationSession]: + async def list_sessions( + self, user_id: str, page: PageRequest | None = None + ) -> Page[ConversationSession]: + page = page or PageRequest() + limit = page.limit + cursor = page.cursor sessions = [s for s in self._sessions.values() if s.user_id == user_id] - sessions.sort(key=lambda s: s.last_message_at or s.created_at or _EPOCH, reverse=True) - return sessions[:limit] + sessions.sort( + key=lambda s: (_effective_t(s), s.session_id or ""), + reverse=True, + ) + + if cursor is not None: + cursor_t, cursor_id = decode_cursor(cursor) + sessions = [s for s in sessions if _is_after_cursor(s, cursor_t, cursor_id)] + + has_more = len(sessions) > limit + result_sessions = sessions[:limit] if has_more else sessions + next_cursor = ( + encode_cursor( + _effective_t(result_sessions[-1]), + result_sessions[-1].session_id, + ) + if has_more and result_sessions + else None + ) + return Page(items=result_sessions, next_cursor=next_cursor) async def rename_session(self, session_id: str, title: str) -> None: session = self._sessions.get(session_id) diff --git a/src/agent_manager/infrastructure/persistence/migrations/versions/0005_add_session_pagination_index.py b/src/agent_manager/infrastructure/persistence/migrations/versions/0005_add_session_pagination_index.py new file mode 100644 index 00000000..16200355 --- /dev/null +++ b/src/agent_manager/infrastructure/persistence/migrations/versions/0005_add_session_pagination_index.py @@ -0,0 +1,31 @@ +"""add session pagination composite index + +Revision ID: 0005 +Revises: 0004 +Create Date: 2026-08-22 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision: str = "0005" +down_revision: str | None = "0004" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_index( + "idx_conversation_sessions_user_active_session", + "conversation_sessions", + ["user_id", sa.text("COALESCE(last_message_at, created_at)"), "session_id"], + ) + + +def downgrade() -> None: + op.drop_index( + "idx_conversation_sessions_user_active_session", + table_name="conversation_sessions", + ) diff --git a/src/agent_manager/infrastructure/persistence/pagination.py b/src/agent_manager/infrastructure/persistence/pagination.py new file mode 100644 index 00000000..87228b35 --- /dev/null +++ b/src/agent_manager/infrastructure/persistence/pagination.py @@ -0,0 +1,18 @@ +"""Infrastructure alias re-exporting pagination utilities from domain.""" + +from agent_manager.domain.pagination import ( + InvalidCursorError, + decode_cursor, + encode_cursor, + ensure_utc, +) + +# Backward-compatibility private alias +_utc = ensure_utc + +__all__ = [ + "InvalidCursorError", + "decode_cursor", + "encode_cursor", + "ensure_utc", +] diff --git a/src/agent_manager/infrastructure/persistence/sql_repository.py b/src/agent_manager/infrastructure/persistence/sql_repository.py index 8a6d8c5f..30b3560e 100644 --- a/src/agent_manager/infrastructure/persistence/sql_repository.py +++ b/src/agent_manager/infrastructure/persistence/sql_repository.py @@ -11,7 +11,7 @@ from datetime import UTC, datetime, timedelta from typing import Any -from sqlalchemy import delete, literal, update +from sqlalchemy import and_, delete, func, literal, or_, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import async_sessionmaker from sqlalchemy.orm import aliased @@ -24,10 +24,17 @@ ConversationSession, ConversationSnapshot, Message, + Page, + PageRequest, Repository, Role, User, ) +from agent_manager.domain.pagination import ( + decode_cursor, + encode_cursor, + ensure_utc, +) from agent_manager.infrastructure.persistence.tables import ( ConversationMessageRow, ConversationSessionRow, @@ -151,16 +158,58 @@ async def get_session(self, session_id: str) -> ConversationSession | None: row = await session.get(ConversationSessionRow, session_id) return _session(row) if row else None - async def list_sessions(self, user_id: str, *, limit: int = 50) -> list[ConversationSession]: + async def list_sessions( + self, user_id: str, page: PageRequest | None = None + ) -> Page[ConversationSession]: + page = page or PageRequest() + limit = page.limit + cursor = page.cursor + sort_key = func.coalesce( + ConversationSessionRow.last_message_at, ConversationSessionRow.created_at + ) + conditions: list[Any] = [ConversationSessionRow.user_id == user_id] + + if cursor is not None: + cursor_t, cursor_id = decode_cursor(cursor) + conditions.append( + or_( + sort_key < cursor_t, + and_( + sort_key == cursor_t, + col(ConversationSessionRow.session_id) < cursor_id, + ), + ) + ) + stmt = ( select(ConversationSessionRow) - .where(ConversationSessionRow.user_id == user_id) - .order_by(col(ConversationSessionRow.last_message_at).desc()) - .limit(limit) + .where(*conditions) + .order_by( + sort_key.desc(), + col(ConversationSessionRow.session_id).desc(), + ) + .limit(limit + 1) ) async with self._sessions() as session: - rows = (await session.exec(stmt)).all() - return [_session(row) for row in rows] + rows = list((await session.exec(stmt)).all()) + + has_more = len(rows) > limit + if has_more: + rows = rows[:limit] + + next_cursor = ( + encode_cursor( + ensure_utc(rows[-1].last_message_at or rows[-1].created_at), # type: ignore[arg-type] + rows[-1].session_id, + ) + if has_more and rows + else None + ) + + return Page( + items=[_session(row) for row in rows], + next_cursor=next_cursor, + ) async def rename_session(self, session_id: str, title: str) -> None: async with self._sessions() as session: @@ -530,8 +579,8 @@ def _user(row: ConversationUserRow) -> User: display_name=row.display_name, linked_to_user_id=row.linked_to_user_id, metadata=dict(row.metadata_json or {}), - created_at=_utc(row.created_at), - updated_at=_utc(row.updated_at), + created_at=ensure_utc(row.created_at), + updated_at=ensure_utc(row.updated_at), ) @@ -544,10 +593,10 @@ def _session(row: ConversationSessionRow) -> ConversationSession: title=row.title, head_message_id=row.head_message_id, metadata=dict(row.metadata_json or {}), - created_at=_utc(row.created_at), - updated_at=_utc(row.updated_at), - last_message_at=_utc(row.last_message_at), - expires_at=_utc(row.expires_at), + created_at=ensure_utc(row.created_at), + updated_at=ensure_utc(row.updated_at), + last_message_at=ensure_utc(row.last_message_at), + expires_at=ensure_utc(row.expires_at), ) @@ -599,7 +648,7 @@ def _message(row: ConversationMessageRow) -> ConversationMessage: status=row.status, error_type=row.error_type, metadata=dict(row.metadata_json or {}), - created_at=_utc(row.created_at) or row.created_at, + created_at=ensure_utc(row.created_at) or row.created_at, ) @@ -615,7 +664,7 @@ def _message_json(row: ConversationMessageRow) -> dict[str, Any]: "tool_name": row.tool_name, "provider": row.provider, "status": row.status, - "created_at": (_utc(row.created_at) or row.created_at).isoformat(), + "created_at": (ensure_utc(row.created_at) or row.created_at).isoformat(), "metadata": dict(row.metadata_json or {}), } @@ -627,10 +676,10 @@ def _snapshot(row: ConversationSnapshotRow) -> ConversationSnapshot: conversation_json=dict(row.conversation_json or {}), message_count=row.message_count, last_message_id=row.last_message_id, - last_message_at=_utc(row.last_message_at), + last_message_at=ensure_utc(row.last_message_at), model_context_tokens=row.model_context_tokens, - updated_at=_utc(row.updated_at) or row.updated_at, - expires_at=_utc(row.expires_at), + updated_at=ensure_utc(row.updated_at) or row.updated_at, + expires_at=ensure_utc(row.expires_at), ) @@ -651,11 +700,3 @@ def _bound_messages( if total >= max_chars: break return list(reversed(kept)) - - -def _utc(value: datetime | None) -> datetime | None: - if value is None: - return None - if value.tzinfo is None: - return value.replace(tzinfo=UTC) - return value.astimezone(UTC) diff --git a/src/agent_manager/infrastructure/persistence/tables.py b/src/agent_manager/infrastructure/persistence/tables.py index 258735da..a6aa8d9b 100644 --- a/src/agent_manager/infrastructure/persistence/tables.py +++ b/src/agent_manager/infrastructure/persistence/tables.py @@ -14,7 +14,7 @@ from datetime import datetime -from sqlalchemy import JSON, Column, DateTime, Float, Index, Integer, Text +from sqlalchemy import JSON, Column, DateTime, Float, Index, Integer, Text, text from sqlmodel import Field, SQLModel @@ -33,6 +33,14 @@ class ConversationUserRow(SQLModel, table=True): class ConversationSessionRow(SQLModel, table=True): __tablename__ = "conversation_sessions" + __table_args__ = ( + Index( + "idx_conversation_sessions_user_active_session", + "user_id", + text("COALESCE(last_message_at, created_at)"), + "session_id", + ), + ) session_id: str = Field(primary_key=True, max_length=64) user_id: str | None = Field(default=None, foreign_key="conversation_users.user_id", index=True) diff --git a/tests/agent_manager/test_api.py b/tests/agent_manager/test_api.py index 3db3ad79..89ff9bee 100644 --- a/tests/agent_manager/test_api.py +++ b/tests/agent_manager/test_api.py @@ -207,12 +207,12 @@ def test_list_conversations_returns_titled_threads_scoped_to_user(client: TestCl b = client.post("/conversations", headers=u1).json()["conversation_id"] client.post(f"/conversations/{b}/messages", json={"message": "second thread"}, headers=u1) - threads = client.get("/conversations", headers=u1).json() + threads = client.get("/conversations", headers=u1).json()["items"] assert {t["conversation_id"]: t["title"] for t in threads} == { a: "first thread", b: "second thread", } - assert client.get("/conversations", headers=bearer("u2")).json() == [] + assert client.get("/conversations", headers=bearer("u2")).json()["items"] == [] def test_another_caller_cannot_touch_a_conversation_it_does_not_own(client: TestClient) -> None: @@ -244,7 +244,10 @@ def test_create_cannot_claim_a_conversation_id_owned_by_another_caller( assert taken.status_code == 409 assert client.get("/conversations/sess-1/messages", headers=bob).status_code == 403 - assert client.get("/conversations", headers=alice).json()[0]["conversation_id"] == "sess-1" + assert ( + client.get("/conversations", headers=alice).json()["items"][0]["conversation_id"] + == "sess-1" + ) @pytest.fixture @@ -297,7 +300,7 @@ def test_the_host_session_cookie_authenticates_a_same_origin_deployment() -> Non cid = created.json()["conversation_id"] dana.post(f"/conversations/{cid}/messages", json={"message": "hi"}) - assert [t["conversation_id"] for t in dana.get("/conversations").json()] == [cid] + assert [t["conversation_id"] for t in dana.get("/conversations").json()["items"]] == [cid] assert TestClient(app).get(f"/conversations/{cid}/messages").status_code == 401 @@ -321,9 +324,9 @@ def test_a_visitor_pass_does_not_shadow_the_host_session_cookie() -> None: # The conversation belongs to Dana, not to the visitor she used to be. as_dana = TestClient(app, cookies=session_cookie(id="u_8412")) - assert [t["conversation_id"] for t in as_dana.get("/conversations").json()] == [cid] + assert [t["conversation_id"] for t in as_dana.get("/conversations").json()["items"]] == [cid] still_a_visitor = {"Authorization": f"Bearer {visitor_pass}"} - assert TestClient(app).get("/conversations", headers=still_a_visitor).json() == [] + assert TestClient(app).get("/conversations", headers=still_a_visitor).json()["items"] == [] def test_a_host_bearer_token_outranks_the_session_cookie() -> None: @@ -345,9 +348,13 @@ def test_a_host_bearer_token_outranks_the_session_cookie() -> None: cid = caller.post("/conversations").json()["conversation_id"] assert [ - t["conversation_id"] for t in TestClient(app).get("/conversations", headers=as_noam).json() + t["conversation_id"] + for t in TestClient(app).get("/conversations", headers=as_noam).json()["items"] ] == [cid] - assert TestClient(app, cookies=session_cookie(id="u_asaf")).get("/conversations").json() == [] + assert ( + TestClient(app, cookies=session_cookie(id="u_asaf")).get("/conversations").json()["items"] + == [] + ) def test_a_visitor_pass_is_an_identity_of_its_own(unauthenticated: TestClient) -> None: @@ -363,7 +370,7 @@ def test_a_visitor_pass_is_an_identity_of_its_own(unauthenticated: TestClient) - assert client.get(f"/conversations/{cid}/messages", headers=visitor).status_code == 200 assert client.get(f"/conversations/{cid}/messages", headers=other_visitor).status_code == 403 - assert client.get("/conversations", headers=other_visitor).json() == [] + assert client.get("/conversations", headers=other_visitor).json()["items"] == [] def test_signing_in_adopts_the_conversations_a_visitor_already_started() -> None: @@ -379,9 +386,9 @@ def test_signing_in_adopts_the_conversations_a_visitor_already_started() -> None linked = client.post("/auth/link", json={"anonymous_token": pass_token}, headers=alice) assert linked.json() == {"conversations_moved": 1} - assert [t["conversation_id"] for t in client.get("/conversations", headers=alice).json()] == [ - cid - ] + assert [ + t["conversation_id"] for t in client.get("/conversations", headers=alice).json()["items"] + ] == [cid] assert client.get(f"/conversations/{cid}/messages", headers=alice).status_code == 200 assert client.get(f"/conversations/{cid}/messages", headers=visitor).status_code == 403 diff --git a/tests/agent_manager/test_pagination.py b/tests/agent_manager/test_pagination.py new file mode 100644 index 00000000..b60fe848 --- /dev/null +++ b/tests/agent_manager/test_pagination.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from pathlib import Path +from urllib.parse import quote + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import SQLModel + +import agent_manager.infrastructure.persistence.tables # noqa: F401 +from agent_manager.application import ConversationService +from agent_manager.domain import InvalidCursorError, PageRequest +from agent_manager.infrastructure.persistence.database import create_db_engine, session_factory +from agent_manager.infrastructure.persistence.memory_repository import MemoryRepository +from agent_manager.infrastructure.persistence.pagination import ( + decode_cursor, + encode_cursor, +) +from agent_manager.infrastructure.persistence.sql_repository import SqlRepository +from tests.agent_manager.conftest import RecordingEngine, bearer, build_test_app + + +@pytest.fixture +def client() -> TestClient: + app = build_test_app(ConversationService(RecordingEngine(), MemoryRepository())) + return TestClient(app, headers=bearer("default-user")) + + +def test_cursor_encode_decode_round_trip() -> None: + now = datetime.now(UTC) + cursor = encode_cursor(now, "sess-123") + decoded_t, decoded_id = decode_cursor(cursor) + + assert decoded_t == now + assert decoded_id == "sess-123" + + +def test_invalid_cursor_raises_invalid_cursor_error() -> None: + with pytest.raises(InvalidCursorError): + decode_cursor("not-a-valid-cursor!") + + +def test_empty_cursor_in_page_request_normalizes_to_none() -> None: + req = PageRequest(limit=20, cursor="") + assert req.cursor is None + + req_spaces = PageRequest(limit=20, cursor=" ") + assert req_spaces.cursor is None + + +def test_limit_bounding_in_page_request() -> None: + req_high = PageRequest(limit=10_000_000) + assert req_high.limit == 100 + + req_low = PageRequest(limit=-5) + assert req_low.limit == 1 + + +def test_a_malformed_cursor_is_rejected_as_client_error(client: TestClient) -> None: + response = client.get("/conversations?cursor=garbage", headers=bearer("u1")) + assert response.status_code == 400 + detail = response.json().get("detail", {}) + assert detail.get("error_type") == "invalid_cursor" + + +def test_empty_cursor_query_param_returns_first_page(client: TestClient) -> None: + u1 = bearer("user-empty-cursor") + client.post("/conversations", headers=u1) + response = client.get("/conversations?cursor=", headers=u1) + assert response.status_code == 200 + assert len(response.json()["items"]) == 1 + + +@pytest.mark.asyncio +async def test_sql_repository_pagination_and_ordering(tmp_path: Path) -> None: + db_url = f"sqlite+aiosqlite:///{tmp_path / 'test_pag.db'}" + engine = create_db_engine(db_url) + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + sessions = session_factory(engine) + + repo = SqlRepository(sessions) + user_id = "user-pag-1" + await repo.upsert_user(user_id) + + base_time = datetime(2026, 8, 20, 12, 0, 0, tzinfo=UTC) + + await repo.create_session("sess-1", user_id=user_id, title="Session 1") + await repo.create_session("sess-2", user_id=user_id, title="Session 2") + await repo.create_session("sess-3", user_id=user_id, title="Session 3") + await repo.create_session("sess-4", user_id=user_id, title="Session 4") + await repo.create_session("sess-5", user_id=user_id, title="Session 5") + + async with sessions() as session: + from agent_manager.infrastructure.persistence.tables import ConversationSessionRow + + for i in range(1, 6): + r = await session.get(ConversationSessionRow, f"sess-{i}") + if r: + r.created_at = base_time + + r1 = await session.get(ConversationSessionRow, "sess-1") + assert r1 is not None + r1.last_message_at = base_time + timedelta(hours=2) + + r2 = await session.get(ConversationSessionRow, "sess-2") + assert r2 is not None + r2.last_message_at = base_time + timedelta(hours=1) + + r3 = await session.get(ConversationSessionRow, "sess-3") + assert r3 is not None + r3.last_message_at = base_time + timedelta(hours=1) + + await session.commit() + + # Page 1: limit 2 + p1 = await repo.list_sessions(user_id, page=PageRequest(limit=2)) + assert [s.session_id for s in p1.items] == ["sess-1", "sess-3"] + assert p1.next_cursor is not None + + # Page 2: limit 2 + p2 = await repo.list_sessions(user_id, page=PageRequest(limit=2, cursor=p1.next_cursor)) + assert [s.session_id for s in p2.items] == ["sess-2", "sess-5"] + assert p2.next_cursor is not None + + # Page 3: limit 2 + p3 = await repo.list_sessions(user_id, page=PageRequest(limit=2, cursor=p2.next_cursor)) + assert [s.session_id for s in p3.items] == ["sess-4"] + assert p3.next_cursor is None + + await engine.dispose() + + +def test_api_conversations_pagination_endpoint(client: TestClient) -> None: + u1 = bearer("user-api-pag") + c1 = client.post("/conversations", headers=u1).json()["conversation_id"] + c2 = client.post("/conversations", headers=u1).json()["conversation_id"] + c3 = client.post("/conversations", headers=u1).json()["conversation_id"] + + res1 = client.get("/conversations?limit=2", headers=u1).json() + assert len(res1["items"]) == 2 + assert res1["next_cursor"] is not None + + cursor_q = quote(res1["next_cursor"]) + res2 = client.get(f"/conversations?limit=2&cursor={cursor_q}", headers=u1).json() + assert len(res2["items"]) == 1 + assert res2["next_cursor"] is None + + fetched_ids = [item["conversation_id"] for item in res1["items"] + res2["items"]] + assert fetched_ids == [c3, c2, c1] diff --git a/tests/agent_manager/test_repository_contract.py b/tests/agent_manager/test_repository_contract.py index f541d96f..011a6139 100644 --- a/tests/agent_manager/test_repository_contract.py +++ b/tests/agent_manager/test_repository_contract.py @@ -13,7 +13,13 @@ from sqlmodel import SQLModel, select import agent_manager.infrastructure.persistence.tables # noqa: F401 (register tables) -from agent_manager.domain import ConversationMessage, Repository, Role +from agent_manager.domain import ( + ConversationMessage, + InvalidCursorError, + PageRequest, + Repository, + Role, +) from agent_manager.infrastructure.persistence.database import create_db_engine, session_factory from agent_manager.infrastructure.persistence.memory_repository import MemoryRepository from agent_manager.infrastructure.persistence.sql_repository import SqlRepository @@ -56,7 +62,7 @@ async def test_create_session_never_reassigns_an_existing_owner(repo: Repository stored = await repo.get_session("shared-id") assert stored is not None assert stored.user_id == "alice" - assert await repo.list_sessions("bob") == [] + assert (await repo.list_sessions("bob")).items == [] async def test_create_session_writes_nothing_when_the_id_is_taken(repo: Repository) -> None: @@ -107,7 +113,7 @@ async def test_appending_a_message_never_claims_the_conversation(repo: Repositor unowned = await repo.get_session("unowned") assert owned is not None and owned.user_id == "alice" assert unowned is not None and unowned.user_id is None - assert await repo.list_sessions("bob") == [] + assert (await repo.list_sessions("bob")).items == [] async def test_messages_in_insertion_order(repo: Repository) -> None: @@ -468,8 +474,8 @@ async def test_linking_a_visitor_moves_their_sessions_once(repo: Repository) -> assert await repo.link_anonymous_user("anon:v1", "ext:alice") == 1 moved = await repo.get_session("pre-login") assert moved is not None and moved.user_id == "ext:alice" - assert [s.session_id for s in await repo.list_sessions("ext:alice")] == ["pre-login"] - assert await repo.list_sessions("anon:v1") == [] + assert [s.session_id for s in (await repo.list_sessions("ext:alice")).items] == ["pre-login"] + assert (await repo.list_sessions("anon:v1")).items == [] visitor = await repo.get_user("anon:v1") assert visitor is not None and visitor.linked_to_user_id == "ext:alice" @@ -477,9 +483,105 @@ async def test_linking_a_visitor_moves_their_sessions_once(repo: Repository) -> # Spent: a replayed pass moves nothing, whoever presents it. await repo.upsert_user("ext:bob") assert await repo.link_anonymous_user("anon:v1", "ext:bob") == 0 - assert await repo.list_sessions("ext:bob") == [] + assert (await repo.list_sessions("ext:bob")).items == [] async def test_linking_an_unknown_visitor_is_a_no_op(repo: Repository) -> None: await repo.upsert_user("ext:alice") assert await repo.link_anonymous_user("anon:never-seen", "ext:alice") == 0 + + +async def test_pagination_contract(repo: Repository) -> None: + """Comprehensive contract tests for repository pagination.""" + user_id = "paginated_user" + await repo.upsert_user(user_id) + + base_time = datetime(2026, 8, 20, 12, 0, 0, tzinfo=UTC) + s1 = await repo.create_session("s1", user_id=user_id) + s2 = await repo.create_session("s2", user_id=user_id) + await repo.create_session("s3", user_id=user_id) + await repo.create_session("s4", user_id=user_id) + await repo.create_session("s5", user_id=user_id) + + # If repo is SqlRepository, set created_at explicitly so created_at ordering is deterministic + if isinstance(repo, SqlRepository): + async with repo._sessions() as session: + from agent_manager.infrastructure.persistence.tables import ConversationSessionRow + + r1 = await session.get(ConversationSessionRow, "s1") + if r1: + r1.created_at = base_time + r2 = await session.get(ConversationSessionRow, "s2") + if r2: + r2.created_at = base_time + timedelta(minutes=10) + r3 = await session.get(ConversationSessionRow, "s3") + if r3: + r3.created_at = base_time + r4 = await session.get(ConversationSessionRow, "s4") + if r4: + r4.created_at = base_time + r5 = await session.get(ConversationSessionRow, "s5") + if r5: + r5.created_at = base_time + await session.commit() + elif isinstance(repo, MemoryRepository): + from dataclasses import replace + + repo._sessions["s1"] = replace(s1, created_at=base_time) + repo._sessions["s2"] = replace(s2, created_at=base_time + timedelta(minutes=10)) + + # Append messages to s3, s4, s5 (s4 and s5 share the same last_message_at timestamp) + await repo.append_message( + ConversationMessage( + message_id="m3", + session_id="s3", + role=Role.USER, + content="m3", + created_at=base_time + timedelta(hours=1), + ) + ) + await repo.append_message( + ConversationMessage( + message_id="m4", + session_id="s4", + role=Role.USER, + content="m4", + created_at=base_time + timedelta(hours=2), + ) + ) + await repo.append_message( + ConversationMessage( + message_id="m5", + session_id="s5", + role=Role.USER, + content="m5", + created_at=base_time + timedelta(hours=2), + ) + ) + + # Page 1: limit 2 -> ["s5", "s4"] + page1 = await repo.list_sessions(user_id, page=PageRequest(limit=2)) + assert [s.session_id for s in page1.items] == ["s5", "s4"] + assert page1.next_cursor is not None + + # Page 2: limit 2 -> ["s3", "s2"] + page2 = await repo.list_sessions(user_id, page=PageRequest(limit=2, cursor=page1.next_cursor)) + assert [s.session_id for s in page2.items] == ["s3", "s2"] + assert page2.next_cursor is not None + + # Page 3: limit 2 -> ["s1"] + page3 = await repo.list_sessions(user_id, page=PageRequest(limit=2, cursor=page2.next_cursor)) + assert [s.session_id for s in page3.items] == ["s1"] + assert page3.next_cursor is None + + all_ids = [s.session_id for s in [*page1.items, *page2.items, *page3.items]] + assert all_ids == ["s5", "s4", "s3", "s2", "s1"] + + # Page boundary landing exactly on limit + page_exact = await repo.list_sessions(user_id, page=PageRequest(limit=5)) + assert len(page_exact.items) == 5 + assert page_exact.next_cursor is None + + # Malformed cursor raises InvalidCursorError + with pytest.raises(InvalidCursorError): + await repo.list_sessions(user_id, page=PageRequest(cursor="invalid_garbage_token")) diff --git a/tests/agent_manager/test_service.py b/tests/agent_manager/test_service.py index a86bf316..073b6aa4 100644 --- a/tests/agent_manager/test_service.py +++ b/tests/agent_manager/test_service.py @@ -352,8 +352,8 @@ async def test_reads_of_an_owned_conversation_refuse_other_callers() -> None: with pytest.raises(ConversationAccessDenied): await service.usage(cid, caller) - assert await service.list_conversations(BOB) == [] - assert await service.list_conversations(VISITOR) == [] + assert (await service.list_conversations(BOB)).items == [] + assert (await service.list_conversations(VISITOR)).items == [] async def test_create_refuses_a_session_id_owned_by_someone_else() -> None: @@ -440,8 +440,8 @@ async def test_signing_in_moves_a_visitors_conversations_onto_their_account() -> moved = await service.link_anonymous(VISITOR, ALICE) assert moved == 1 - assert [s.session_id for s in await service.list_conversations(ALICE)] == ["pre-login"] - assert await service.list_conversations(VISITOR) == [] + assert [s.session_id for s in (await service.list_conversations(ALICE)).items] == ["pre-login"] + assert (await service.list_conversations(VISITOR)).items == [] assert [m.content for m in await service.history(before_login, ALICE)] == [ "how much does it cost?", "answer:how much does it cost?", @@ -456,8 +456,8 @@ async def test_a_visitor_pass_can_only_be_adopted_once() -> None: assert await service.link_anonymous(VISITOR, ALICE) == 1 assert await service.link_anonymous(VISITOR, BOB) == 0 - assert [s.session_id for s in await service.list_conversations(ALICE)] == ["pre-login"] - assert await service.list_conversations(BOB) == [] + assert [s.session_id for s in (await service.list_conversations(ALICE)).items] == ["pre-login"] + assert (await service.list_conversations(BOB)).items == [] async def test_a_visitor_cannot_adopt_another_visitor() -> None: @@ -475,7 +475,7 @@ async def test_adopting_merges_into_conversations_the_account_already_had() -> N await service.link_anonymous(VISITOR, ALICE) - assert {s.session_id for s in await service.list_conversations(ALICE)} == { + assert {s.session_id for s in (await service.list_conversations(ALICE)).items} == { "signed-in", "pre-login", } diff --git a/tests/e2e/playground.spec.ts b/tests/e2e/playground.spec.ts index 810da2f3..44a401a3 100644 --- a/tests/e2e/playground.spec.ts +++ b/tests/e2e/playground.spec.ts @@ -15,14 +15,18 @@ async function mockConversationApi(page: Page) { contentType: "application/json", body: method === "GET" - ? JSON.stringify([]) + ? JSON.stringify({ items: [], next_cursor: null }) : JSON.stringify({ conversation_id: "conv-playground", session_id: "conv-playground" }), }); }); await page.route(/\/conversations\?/, async (route) => { calls.push(`GET ${new URL(route.request().url()).pathname}`); - await route.fulfill({ status: 200, contentType: "application/json", body: "[]" }); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ items: [], next_cursor: null }), + }); }); await page.route("**/conversations/*/messages", async (route: Route) => { diff --git a/tests/e2e/widget.spec.ts b/tests/e2e/widget.spec.ts index 316c9719..55e2cf37 100644 --- a/tests/e2e/widget.spec.ts +++ b/tests/e2e/widget.spec.ts @@ -31,7 +31,7 @@ async function mockConversationApi( await route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify(options.threads ?? []), + body: JSON.stringify({ items: options.threads ?? [], next_cursor: null }), }); }); @@ -44,7 +44,7 @@ async function mockConversationApi( contentType: "application/json", body: method === "GET" - ? JSON.stringify(options.threads ?? []) + ? JSON.stringify({ items: options.threads ?? [], next_cursor: null }) : JSON.stringify({ conversation_id: "conv-smoke", session_id: "conv-smoke" }), }); }); @@ -147,7 +147,7 @@ async function mockApprovalApi( await page.route("**/conversations", async (route) => { const body = route.request().method() === "GET" - ? [] + ? { items: [], next_cursor: null } : { conversation_id: "conv-approval", session_id: "conv-approval" }; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) }); }); @@ -1409,3 +1409,55 @@ test("auto-mount does not duplicate an authored element and attributes control t await expect.poll(() => shadowText(page, ".messages")).toContain("The attribute greeting wins."); await expect.poll(() => shadowClassContains(page, ".panel", "open")).toBe(true); }); + +test("thread drawer paginates and appends next pages on scroll", async ({ page }) => { + let callCount = 0; + await pinVisitorPass(page); + + await page.route(/\/conversations\?/, async (route) => { + callCount += 1; + const url = new URL(route.request().url()); + const cursor = url.searchParams.get("cursor"); + if (!cursor) { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items: [ + { conversation_id: "thread-1", title: "Thread One", last_message_at: "2026-06-28T00:00:00Z" }, + { conversation_id: "thread-2", title: "Thread Two", last_message_at: "2026-06-27T00:00:00Z" }, + ], + next_cursor: "page-2-token", + }), + }); + } else { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items: [ + { conversation_id: "thread-3", title: "Thread Three", last_message_at: "2026-06-26T00:00:00Z" }, + ], + next_cursor: null, + }), + }); + } + }); + + await page.goto("/widget-demo.html"); + await shadowClick(page, ".launcher"); + await shadowClick(page, '[aria-label="Conversations"]'); + + await expect.poll(() => shadowText(page, ".thread-drawer")).toContain("Thread One"); + await expect.poll(() => shadowText(page, ".thread-drawer")).toContain("Thread Two"); + + await (await widget(page)).evaluate((el) => { + const drawer = el.shadowRoot?.querySelector(".thread-list"); + if (drawer) { + drawer.scrollTop = drawer.scrollHeight; + drawer.dispatchEvent(new Event("scroll")); + } + }); + + await expect.poll(() => shadowText(page, ".thread-drawer")).toContain("Thread Three"); +});