From 6fcf201d369e0062ed4907b57ee7f5e991da2c58 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Fri, 17 Jul 2026 18:39:33 +0000 Subject: [PATCH 1/4] feat: implement cursor-based pagination for conversations --- apps/web/src/lib/agent-api.ts | 39 +++++++--- .../projects/$projectId/conversations.tsx | 72 +++++++++++++------ .../$projectId/conversations/index.tsx | 20 +++--- services/api/internal/domain/agent/cursor.go | 38 ++++++++++ .../api/internal/domain/agent/repository.go | 15 ++-- services/api/internal/domain/agent/service.go | 2 +- .../repository/postgres/agent_repository.go | 35 +++++---- .../internal/service/agent/agent_service.go | 13 ++-- .../service/agent/agent_service_test.go | 8 +-- .../http/handler/agent_handler_test.go | 4 +- .../http/handler/conversation_handler.go | 26 +++++-- ..._conversations_cursor_pagination_index.sql | 8 +++ 12 files changed, 201 insertions(+), 79 deletions(-) create mode 100644 services/api/internal/domain/agent/cursor.go create mode 100644 services/api/migrations/000025_add_conversations_cursor_pagination_index.sql diff --git a/apps/web/src/lib/agent-api.ts b/apps/web/src/lib/agent-api.ts index 8193d4d6..8c7eaef1 100644 --- a/apps/web/src/lib/agent-api.ts +++ b/apps/web/src/lib/agent-api.ts @@ -1,4 +1,4 @@ -import { queryOptions } from "@tanstack/react-query"; +import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; import { apiClient } from "./api-client"; import type { SuccessEnvelope } from "./api-error"; @@ -476,15 +476,25 @@ export async function deleteEnvVar( // ── Conversations ───────────────────────────────────────────────────────────── +export interface ConversationListResult { + items: AgentConversation[]; + page_size: number; + next_cursor: string | null; +} + +export const CONVERSATIONS_PAGE_SIZE = 20; + export async function listConversations( projectId: string, - agentId?: string, -): Promise { - const params = agentId ? { agent_id: agentId } : undefined; + options?: { agentId?: string; cursor?: string; pageSize?: number }, +): Promise { + const { agentId, cursor, pageSize = CONVERSATIONS_PAGE_SIZE } = options ?? {}; const { data } = await apiClient.instance.get< - SuccessEnvelope<{ items: AgentConversation[] }> - >(`/projects/${projectId}/conversations`, { params }); - return data.data.items; + SuccessEnvelope + >(`/projects/${projectId}/conversations`, { + params: { agent_id: agentId, cursor, page_size: pageSize }, + }); + return data.data; } export async function getConversation( @@ -641,14 +651,23 @@ export const acpBridgeStatusQueryOptions = ( // No refetchInterval: kept live via useProjectRealtime's socket-driven // invalidation of the ["projects", projectId, "conversations"] prefix on -// every "agent.*" event instead of polling. +// every "agent.*" event instead of polling. Cursor-paginated — each page +// carries the opaque cursor to resume after its last item, so fetchNextPage +// just forwards the backend's next_cursor until it comes back null. export const conversationsQueryOptions = ( projectId: string, agentId?: string, ) => - queryOptions({ + infiniteQueryOptions({ queryKey: ["projects", projectId, "conversations", { agentId }], - queryFn: () => listConversations(projectId, agentId), + queryFn: ({ pageParam }: { pageParam: string | undefined }) => + listConversations(projectId, { + agentId, + cursor: pageParam, + pageSize: CONVERSATIONS_PAGE_SIZE, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, }); export const conversationQueryOptions = ( diff --git a/apps/web/src/routes/_authenticated/projects/$projectId/conversations.tsx b/apps/web/src/routes/_authenticated/projects/$projectId/conversations.tsx index fb789345..a02fcaf9 100644 --- a/apps/web/src/routes/_authenticated/projects/$projectId/conversations.tsx +++ b/apps/web/src/routes/_authenticated/projects/$projectId/conversations.tsx @@ -1,4 +1,4 @@ -import { useQuery } from "@tanstack/react-query"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { createFileRoute, Link, @@ -6,6 +6,7 @@ import { useParams, } from "@tanstack/react-router"; import { Clock, MessageSquare, Zap } from "lucide-react"; +import { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; @@ -26,7 +27,7 @@ export const Route = createFileRoute( )({ loader: async ({ context: { queryClient }, params: { projectId } }) => { await Promise.all([ - queryClient.ensureQueryData(conversationsQueryOptions(projectId)), + queryClient.ensureInfiniteQueryData(conversationsQueryOptions(projectId)), queryClient.ensureQueryData(agentsQueryOptions(projectId)), ]); }, @@ -116,16 +117,35 @@ function ConversationsLayout() { useProjectRealtime(projectId); - const { data: conversations = [], isLoading } = useQuery( - conversationsQueryOptions(projectId), - ); + const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = + useInfiniteQuery(conversationsQueryOptions(projectId)); const { data: agents = [] } = useQuery(agentsQueryOptions(projectId)); const agentsById = new Map(agents.map((a) => [a.id, a])); - const sortedConversations = [...conversations].sort( - (a, b) => - new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), - ); + // Pages are fetched newest-first (offset-paginated, ordered by created_at + // DESC on the backend), so concatenating them in fetch order already + // yields the correct display order — no client-side re-sort needed. + const conversations = data?.pages.flatMap((page) => page.items) ?? []; + + const scrollContainerRef = useRef(null); + const loadMoreRef = useRef(null); + + useEffect(() => { + const container = scrollContainerRef.current; + const target = loadMoreRef.current; + if (!container || !target) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry?.isIntersecting && hasNextPage && !isFetchingNextPage) { + void fetchNextPage(); + } + }, + { root: container, rootMargin: "150px" }, + ); + observer.observe(target); + return () => observer.disconnect(); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); return (
@@ -135,13 +155,16 @@ function ConversationsLayout() { {t("conversationsPage.title")}
-
+
{isLoading ? ( Array.from({ length: 4 }).map((_, i) => ( // biome-ignore lint/suspicious/noArrayIndexKey: skeleton )) - ) : sortedConversations.length === 0 ? ( + ) : conversations.length === 0 ? (

@@ -152,15 +175,24 @@ function ConversationsLayout() {

) : ( - sortedConversations.map((conv) => ( - - )) + <> + {conversations.map((conv) => ( + + ))} + {hasNextPage && ( +
+ {isFetchingNextPage && ( + + )} +
+ )} + )}
diff --git a/apps/web/src/routes/_authenticated/projects/$projectId/conversations/index.tsx b/apps/web/src/routes/_authenticated/projects/$projectId/conversations/index.tsx index 211e67c9..69e65eb3 100644 --- a/apps/web/src/routes/_authenticated/projects/$projectId/conversations/index.tsx +++ b/apps/web/src/routes/_authenticated/projects/$projectId/conversations/index.tsx @@ -1,8 +1,9 @@ +import type { InfiniteData } from "@tanstack/react-query"; import { createFileRoute, redirect } from "@tanstack/react-router"; import { MessageSquare } from "lucide-react"; import { useTranslation } from "react-i18next"; import { - type AgentConversation, + type ConversationListResult, conversationsQueryOptions, } from "@/lib/agent-api"; @@ -12,7 +13,9 @@ export const Route = createFileRoute( // The parent `conversations` layout route's loader already populated the // conversations list in the query cache, so this can read it synchronously // instead of refetching — landing directly on the most recently created - // conversation instead of showing a bare "pick one" screen. + // conversation instead of showing a bare "pick one" screen. The list's + // first page is ordered newest-first, so its first item is the latest + // conversation without needing to scan every loaded page. // // `preload` is true when this loader runs speculatively (e.g. the sidebar // nav Link's hover-intent preload, since the router defaults to @@ -22,15 +25,12 @@ export const Route = createFileRoute( loader: ({ context: { queryClient }, params: { projectId }, preload }) => { if (preload) return; - const conversations = - queryClient.getQueryData( - conversationsQueryOptions(projectId).queryKey, - ) ?? []; - if (conversations.length === 0) return; - - const latest = conversations.reduce((a, b) => - new Date(a.created_at) > new Date(b.created_at) ? a : b, + const data = queryClient.getQueryData>( + conversationsQueryOptions(projectId).queryKey, ); + const latest = data?.pages[0]?.items[0]; + if (!latest) return; + throw redirect({ to: "/projects/$projectId/conversations/$conversationId", params: { projectId, conversationId: latest.id }, diff --git a/services/api/internal/domain/agent/cursor.go b/services/api/internal/domain/agent/cursor.go new file mode 100644 index 00000000..c50cd205 --- /dev/null +++ b/services/api/internal/domain/agent/cursor.go @@ -0,0 +1,38 @@ +package agentdom + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "time" +) + +// ConversationCursor holds the stable ordering fields for keyset-based +// pagination over the conversations list, which is always ordered by +// created_at DESC, id DESC. +type ConversationCursor struct { + CreatedAt time.Time `json:"ca"` + ID string `json:"id"` +} + +// EncodeConversationCursor builds an opaque base64 cursor from the last +// conversation on a page. +func EncodeConversationCursor(c *AgentConversation) string { + cur := ConversationCursor{CreatedAt: c.CreatedAt.UTC(), ID: c.ID.String()} + b, _ := json.Marshal(cur) + return base64.URLEncoding.EncodeToString(b) +} + +// DecodeConversationCursor parses a cursor token produced by EncodeConversationCursor. +func DecodeConversationCursor(s string) (*ConversationCursor, error) { + b, err := base64.URLEncoding.DecodeString(s) + if err != nil { + return nil, fmt.Errorf("decode cursor base64: %w", err) + } + var c ConversationCursor + if err := json.Unmarshal(b, &c); err != nil { + return nil, fmt.Errorf("decode cursor json: %w", err) + } + c.CreatedAt = c.CreatedAt.UTC() + return &c, nil +} diff --git a/services/api/internal/domain/agent/repository.go b/services/api/internal/domain/agent/repository.go index 43bb9fb9..24764683 100644 --- a/services/api/internal/domain/agent/repository.go +++ b/services/api/internal/domain/agent/repository.go @@ -67,7 +67,9 @@ type EnvVarRepository interface { // ConversationRepository defines storage for agent conversations. type ConversationRepository interface { - ListConversations(ctx context.Context, in ListConversationsFilter) ([]*AgentConversation, int64, error) + // ListConversations returns up to limit conversations matching the + // filter, ordered newest-first, plus whether more pages remain. + ListConversations(ctx context.Context, in ListConversationsFilter, limit int) (convs []*AgentConversation, hasMore bool, err error) FindConversationByID(ctx context.Context, id uuid.UUID) (*AgentConversation, error) // FindLatestConversationByChatSession returns the most recently created // conversation for a chat session, or (nil, nil) if the session has none @@ -94,10 +96,9 @@ type ChatSessionRepository interface { // ListConversationsFilter carries optional filters for listing conversations. type ListConversationsFilter struct { - AgentID *uuid.UUID - ProjectID *uuid.UUID - TaskID *uuid.UUID - Status *string - Limit int - Offset int + AgentID *uuid.UUID + ProjectID *uuid.UUID + TaskID *uuid.UUID + Status *string + CursorAfter *string // opaque base64 cursor; when set, resumes after this conversation } diff --git a/services/api/internal/domain/agent/service.go b/services/api/internal/domain/agent/service.go index d19a3c4b..7c5dd327 100644 --- a/services/api/internal/domain/agent/service.go +++ b/services/api/internal/domain/agent/service.go @@ -56,7 +56,7 @@ type EnvVarService interface { // ConversationService defines conversation management use cases. type ConversationService interface { - ListConversations(ctx context.Context, in ListConversationsFilter) ([]*AgentConversation, int64, error) + ListConversations(ctx context.Context, in ListConversationsFilter, limit int) (convs []*AgentConversation, hasMore bool, err error) GetConversation(ctx context.Context, projectID, conversationID uuid.UUID) (*AgentConversation, error) ListConversationEvents(ctx context.Context, conversationID uuid.UUID, offset, limit int) ([]*AgentConversationEvent, int64, error) // StopConversation interrupts (if running) and permanently tears down the diff --git a/services/api/internal/repository/postgres/agent_repository.go b/services/api/internal/repository/postgres/agent_repository.go index a91ff3fe..8307fa49 100644 --- a/services/api/internal/repository/postgres/agent_repository.go +++ b/services/api/internal/repository/postgres/agent_repository.go @@ -581,8 +581,10 @@ const conversationCols = `id, agent_id, project_id, trigger_type, task_id, comme repo_plugin_id, repo_clone_url, branch_name, pr_url, persistence_dir, started_at, finished_at, created_at, updated_at` -// ListConversations returns a paginated list of conversations matching the filter. -func (r *AgentRepository) ListConversations(ctx context.Context, in agentdom.ListConversationsFilter) ([]*agentdom.AgentConversation, int64, error) { +// ListConversations returns a keyset-paginated page of conversations matching +// the filter, ordered newest-first. It fetches one row beyond limit to detect +// whether more pages remain, without a separate COUNT query. +func (r *AgentRepository) ListConversations(ctx context.Context, in agentdom.ListConversationsFilter, limit int) ([]*agentdom.AgentConversation, bool, error) { // Build dynamic WHERE clause where := "WHERE 1=1" args := []interface{}{} @@ -608,26 +610,35 @@ func (r *AgentRepository) ListConversations(ctx context.Context, in agentdom.Lis args = append(args, *in.Status) idx++ } - - var total int64 - if err := r.db.GetContext(ctx, &total, `SELECT COUNT(*) FROM agent_conversations `+where, args...); err != nil { - return nil, 0, err + if in.CursorAfter != nil { + cur, err := agentdom.DecodeConversationCursor(*in.CursorAfter) + if err != nil { + return nil, false, fmt.Errorf("agent repo: invalid cursor: %w", err) + } + where += fmt.Sprintf(" AND (created_at, id) < ($%d, $%d)", idx, idx+1) + args = append(args, cur.CreatedAt, cur.ID) + idx += 2 } - orderArgs := make([]interface{}, len(args), len(args)+2) - copy(orderArgs, args) - orderArgs = append(orderArgs, in.Offset, in.Limit) + limitP := idx + args = append(args, limit+1) + var recs []agentConversationRecord if err := r.db.SelectContext(ctx, &recs, `SELECT `+conversationCols+` FROM agent_conversations `+where+ - fmt.Sprintf(` ORDER BY created_at DESC OFFSET $%d LIMIT $%d`, idx, idx+1), orderArgs...); err != nil { - return nil, 0, err + fmt.Sprintf(` ORDER BY created_at DESC, id DESC LIMIT $%d`, limitP), args...); err != nil { + return nil, false, err + } + + hasMore := len(recs) > limit + if hasMore { + recs = recs[:limit] } result := make([]*agentdom.AgentConversation, 0, len(recs)) for _, rec := range recs { result = append(result, conversationFromRecord(rec)) } - return result, total, nil + return result, hasMore, nil } // FindConversationByID returns a single conversation by its primary key. diff --git a/services/api/internal/service/agent/agent_service.go b/services/api/internal/service/agent/agent_service.go index 9b49b911..15bf3f6d 100644 --- a/services/api/internal/service/agent/agent_service.go +++ b/services/api/internal/service/agent/agent_service.go @@ -600,9 +600,9 @@ func (s *Service) DeleteEnvVar(ctx context.Context, agentID, envVarID uuid.UUID) // Conversations // ------------------------------------------------------------------------- -// ListConversations returns a paginated list of conversations matching the filter. -func (s *Service) ListConversations(ctx context.Context, in agentdom.ListConversationsFilter) ([]*agentdom.AgentConversation, int64, error) { - return s.repo.ListConversations(ctx, in) +// ListConversations returns a page of conversations matching the filter. +func (s *Service) ListConversations(ctx context.Context, in agentdom.ListConversationsFilter, limit int) ([]*agentdom.AgentConversation, bool, error) { + return s.repo.ListConversations(ctx, in, limit) } // GetConversation returns a single conversation after verifying project ownership. @@ -810,12 +810,9 @@ func (s *Service) SendChatMessage(ctx context.Context, projectID, sessionID, mem func (s *Service) ListChatMessages(ctx context.Context, sessionID uuid.UUID, offset, limit int) ([]*agentdom.AgentConversationEvent, int64, error) { // TODO: We'd need to aggregate events from all conversations in this session. // For now, return events from the most recent conversation with this session_id. - filter := agentdom.ListConversationsFilter{ - Limit: 1, - Offset: 0, - } + filter := agentdom.ListConversationsFilter{} _ = sessionID - convs, _, err := s.repo.ListConversations(ctx, filter) + convs, _, err := s.repo.ListConversations(ctx, filter, 1) if err != nil { return nil, 0, err } diff --git a/services/api/internal/service/agent/agent_service_test.go b/services/api/internal/service/agent/agent_service_test.go index ce97513f..78bd3f9c 100644 --- a/services/api/internal/service/agent/agent_service_test.go +++ b/services/api/internal/service/agent/agent_service_test.go @@ -37,7 +37,7 @@ type mockAgentRepo struct { createEnvVar func(ctx context.Context, v *agentdom.AgentEnvironmentVariable) error updateEnvVar func(ctx context.Context, v *agentdom.AgentEnvironmentVariable) error deleteEnvVar func(ctx context.Context, id uuid.UUID) error - listConversations func(ctx context.Context, filter agentdom.ListConversationsFilter) ([]*agentdom.AgentConversation, int64, error) + listConversations func(ctx context.Context, filter agentdom.ListConversationsFilter, limit int) ([]*agentdom.AgentConversation, bool, error) findConversationByID func(ctx context.Context, id uuid.UUID) (*agentdom.AgentConversation, error) findLatestConversationBySession func(ctx context.Context, chatSessionID uuid.UUID) (*agentdom.AgentConversation, error) createConversation func(ctx context.Context, conv *agentdom.AgentConversation) error @@ -234,11 +234,11 @@ func (m *mockAgentRepo) DeleteEnvVar(ctx context.Context, id uuid.UUID) error { return nil } -func (m *mockAgentRepo) ListConversations(ctx context.Context, filter agentdom.ListConversationsFilter) ([]*agentdom.AgentConversation, int64, error) { +func (m *mockAgentRepo) ListConversations(ctx context.Context, filter agentdom.ListConversationsFilter, limit int) ([]*agentdom.AgentConversation, bool, error) { if m.listConversations != nil { - return m.listConversations(ctx, filter) + return m.listConversations(ctx, filter, limit) } - return nil, 0, nil + return nil, false, nil } func (m *mockAgentRepo) FindConversationByID(ctx context.Context, id uuid.UUID) (*agentdom.AgentConversation, error) { diff --git a/services/api/internal/transport/http/handler/agent_handler_test.go b/services/api/internal/transport/http/handler/agent_handler_test.go index 38224767..376d969d 100644 --- a/services/api/internal/transport/http/handler/agent_handler_test.go +++ b/services/api/internal/transport/http/handler/agent_handler_test.go @@ -86,8 +86,8 @@ func (m *mockAgentSvc) UpdateEnvVar(_ context.Context, _, _ uuid.UUID, _ agentdo return nil, errors.New("not found") } func (m *mockAgentSvc) DeleteEnvVar(_ context.Context, _, _ uuid.UUID) error { return nil } -func (m *mockAgentSvc) ListConversations(_ context.Context, _ agentdom.ListConversationsFilter) ([]*agentdom.AgentConversation, int64, error) { - return nil, 0, nil +func (m *mockAgentSvc) ListConversations(_ context.Context, _ agentdom.ListConversationsFilter, _ int) ([]*agentdom.AgentConversation, bool, error) { + return nil, false, nil } func (m *mockAgentSvc) GetConversation(_ context.Context, _, _ uuid.UUID) (*agentdom.AgentConversation, error) { return nil, errors.New("not found") diff --git a/services/api/internal/transport/http/handler/conversation_handler.go b/services/api/internal/transport/http/handler/conversation_handler.go index a03933e5..6125d596 100644 --- a/services/api/internal/transport/http/handler/conversation_handler.go +++ b/services/api/internal/transport/http/handler/conversation_handler.go @@ -3,6 +3,7 @@ package handler import ( "encoding/json" "net/http" + "strconv" "github.com/google/uuid" @@ -30,11 +31,13 @@ func (h *ConversationHandler) ListConversations(w http.ResponseWriter, r *http.R return } - offset, limit := parseOffsetLimit(r) + pageSize, _ := strconv.Atoi(defaultQuery(r, "page_size", "20")) + if pageSize < 1 || pageSize > 200 { + pageSize = 20 + } + filter := agentdom.ListConversationsFilter{ ProjectID: &projectID, - Limit: limit, - Offset: offset, } if agentIDStr := r.URL.Query().Get("agent_id"); agentIDStr != "" { if id, err := uuid.Parse(agentIDStr); err == nil { @@ -44,8 +47,11 @@ func (h *ConversationHandler) ListConversations(w http.ResponseWriter, r *http.R if statusStr := r.URL.Query().Get("status"); statusStr != "" { filter.Status = &statusStr } + if cursorRaw := r.URL.Query().Get("cursor"); cursorRaw != "" { + filter.CursorAfter = &cursorRaw + } - convs, total, err := h.svc.ListConversations(r.Context(), filter) + convs, hasMore, err := h.svc.ListConversations(r.Context(), filter, pageSize) if err != nil { presenter.Error(w, r, err) return @@ -54,7 +60,17 @@ func (h *ConversationHandler) ListConversations(w http.ResponseWriter, r *http.R for _, conv := range convs { resp = append(resp, dto.ConversationFromEntity(conv)) } - presenter.OK(w, r, map[string]any{"items": resp, "total": total}) + + var nextCursor *string + if hasMore && len(convs) > 0 { + s := agentdom.EncodeConversationCursor(convs[len(convs)-1]) + nextCursor = &s + } + presenter.OK(w, r, map[string]any{ + "items": resp, + "page_size": pageSize, + "next_cursor": nextCursor, + }) } // GetConversation handles GET /projects/:projectId/conversations/:conversationId. diff --git a/services/api/migrations/000025_add_conversations_cursor_pagination_index.sql b/services/api/migrations/000025_add_conversations_cursor_pagination_index.sql new file mode 100644 index 00000000..a92253e5 --- /dev/null +++ b/services/api/migrations/000025_add_conversations_cursor_pagination_index.sql @@ -0,0 +1,8 @@ +-- Composite index for cursor-based keyset pagination on conversations. +-- The query pattern is: +-- WHERE project_id = ? AND (created_at, id) < (?, ?) +-- ORDER BY created_at DESC, id DESC LIMIT n +-- The leading project_id column filters down to a single project before +-- the keyset comparison, avoiding a full-table scan. +CREATE INDEX IF NOT EXISTS idx_agent_conversations_cursor_pagination + ON agent_conversations (project_id, created_at DESC, id DESC); From c743a8b57020a37fa6b847ab456d8be0d2b2fd14 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Fri, 17 Jul 2026 18:50:55 +0000 Subject: [PATCH 2/4] feat: add error handling for invalid pagination cursor in conversations --- services/api/internal/apierr/codes.go | 2 ++ services/api/internal/domain/agent/errors.go | 3 +++ services/api/internal/repository/postgres/agent_repository.go | 2 +- services/api/internal/transport/http/presenter/response.go | 2 ++ 4 files changed, 8 insertions(+), 1 deletion(-) diff --git a/services/api/internal/apierr/codes.go b/services/api/internal/apierr/codes.go index 94416a01..b7465cf3 100644 --- a/services/api/internal/apierr/codes.go +++ b/services/api/internal/apierr/codes.go @@ -288,6 +288,8 @@ const ( CodeAgentConversationAlreadyStopped Code = "AGENT_CONVERSATION_ALREADY_STOPPED" // CodeAgentConversationBusy indicates a chat reply was sent while the agent is still responding to the previous one. CodeAgentConversationBusy Code = "AGENT_CONVERSATION_BUSY" + // CodeAgentConversationInvalidCursor indicates a client-supplied pagination cursor failed to decode. + CodeAgentConversationInvalidCursor Code = "AGENT_CONVERSATION_INVALID_CURSOR" // CodeAgentChatSessionNotFound indicates the requested chat session does not exist. CodeAgentChatSessionNotFound Code = "AGENT_CHAT_SESSION_NOT_FOUND" // CodeAgentEnvVarNotFound indicates the requested environment variable does not exist. diff --git a/services/api/internal/domain/agent/errors.go b/services/api/internal/domain/agent/errors.go index f7cee96a..afa9920e 100644 --- a/services/api/internal/domain/agent/errors.go +++ b/services/api/internal/domain/agent/errors.go @@ -43,6 +43,9 @@ var ( // ErrConversationBusy is returned when a chat reply is sent while the // session's current conversation is still mid-turn (status "running"). ErrConversationBusy = errors.New("agent is still responding to the previous message") + // ErrConversationInvalidCursor is returned when a client-supplied + // pagination cursor fails to decode. + ErrConversationInvalidCursor = errors.New("invalid pagination cursor") ) // Chat session errors diff --git a/services/api/internal/repository/postgres/agent_repository.go b/services/api/internal/repository/postgres/agent_repository.go index 8307fa49..1d6c98c6 100644 --- a/services/api/internal/repository/postgres/agent_repository.go +++ b/services/api/internal/repository/postgres/agent_repository.go @@ -613,7 +613,7 @@ func (r *AgentRepository) ListConversations(ctx context.Context, in agentdom.Lis if in.CursorAfter != nil { cur, err := agentdom.DecodeConversationCursor(*in.CursorAfter) if err != nil { - return nil, false, fmt.Errorf("agent repo: invalid cursor: %w", err) + return nil, false, fmt.Errorf("%w: %s", agentdom.ErrConversationInvalidCursor, err) } where += fmt.Sprintf(" AND (created_at, id) < ($%d, $%d)", idx, idx+1) args = append(args, cur.CreatedAt, cur.ID) diff --git a/services/api/internal/transport/http/presenter/response.go b/services/api/internal/transport/http/presenter/response.go index cf47180a..2eec566f 100644 --- a/services/api/internal/transport/http/presenter/response.go +++ b/services/api/internal/transport/http/presenter/response.go @@ -296,6 +296,8 @@ func statusAndCodeFor(err error) (int, apierr.Code) { return http.StatusConflict, apierr.CodeAgentConversationAlreadyStopped case errors.Is(err, agentdom.ErrConversationBusy): return http.StatusConflict, apierr.CodeAgentConversationBusy + case errors.Is(err, agentdom.ErrConversationInvalidCursor): + return http.StatusBadRequest, apierr.CodeAgentConversationInvalidCursor case errors.Is(err, agentdom.ErrChatSessionNotFound): return http.StatusNotFound, apierr.CodeAgentChatSessionNotFound case errors.Is(err, agentdom.ErrEnvVarNotFound): From 671dce7ca84eae1dcf858d710d74e053d0d68a62 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Sat, 18 Jul 2026 05:08:02 +0000 Subject: [PATCH 3/4] fix: fix lint --- services/api/internal/transport/http/presenter/response.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/api/internal/transport/http/presenter/response.go b/services/api/internal/transport/http/presenter/response.go index 2eec566f..268bca89 100644 --- a/services/api/internal/transport/http/presenter/response.go +++ b/services/api/internal/transport/http/presenter/response.go @@ -528,7 +528,8 @@ func httpStatusForCode(code apierr.Code) int { apierr.CodeAgentACPCommandRequired, apierr.CodeAgentEnvVarKeyInvalid, apierr.CodeAgentEnvVarKeyReserved, - apierr.CodeAgentSkillNameReserved: + apierr.CodeAgentSkillNameReserved, + apierr.CodeAgentConversationInvalidCursor: return http.StatusBadRequest case apierr.CodeWorkflowNotFound, apierr.CodeWorkflowNodeNotFound, From 5c6fada6d3dd09737e373e0526c2c6714c30f21d Mon Sep 17 00:00:00 2001 From: pikann22 Date: Sat, 18 Jul 2026 05:21:18 +0000 Subject: [PATCH 4/4] test: add unit testing and e2e testing for conversation pagination --- .../api/internal/domain/agent/cursor_test.go | 87 ++++ .../service/agent/agent_service_test.go | 46 +++ .../http/handler/agent_handler_test.go | 12 +- .../http/handler/conversation_handler_test.go | 281 +++++++++++++ services/api/test/e2e/common_env_test.go | 1 + .../test/e2e/conversation_pagination_test.go | 375 ++++++++++++++++++ 6 files changed, 798 insertions(+), 4 deletions(-) create mode 100644 services/api/internal/domain/agent/cursor_test.go create mode 100644 services/api/internal/transport/http/handler/conversation_handler_test.go create mode 100644 services/api/test/e2e/conversation_pagination_test.go diff --git a/services/api/internal/domain/agent/cursor_test.go b/services/api/internal/domain/agent/cursor_test.go new file mode 100644 index 00000000..54b52ddc --- /dev/null +++ b/services/api/internal/domain/agent/cursor_test.go @@ -0,0 +1,87 @@ +package agentdom + +import ( + "testing" + "time" + + "github.com/google/uuid" +) + +func TestConversationCursor_RoundTrip(t *testing.T) { + id := uuid.New() + createdAt := time.Date(2026, 3, 15, 10, 30, 0, 123456000, time.UTC) + conv := &AgentConversation{ID: id, CreatedAt: createdAt} + + token := EncodeConversationCursor(conv) + if token == "" { + t.Fatal("expected non-empty cursor token") + } + + cur, err := DecodeConversationCursor(token) + if err != nil { + t.Fatalf("decode: unexpected error: %v", err) + } + if cur.ID != id.String() { + t.Errorf("expected ID %q, got %q", id.String(), cur.ID) + } + if !cur.CreatedAt.Equal(createdAt) { + t.Errorf("expected CreatedAt %v, got %v", createdAt, cur.CreatedAt) + } +} + +func TestEncodeConversationCursor_NormalizesToUTC(t *testing.T) { + loc := time.FixedZone("UTC+7", 7*60*60) + localTime := time.Date(2026, 3, 15, 17, 30, 0, 0, loc) + conv := &AgentConversation{ID: uuid.New(), CreatedAt: localTime} + + token := EncodeConversationCursor(conv) + cur, err := DecodeConversationCursor(token) + if err != nil { + t.Fatalf("decode: unexpected error: %v", err) + } + + if cur.CreatedAt.Location() != time.UTC { + t.Errorf("expected cursor CreatedAt in UTC, got location %v", cur.CreatedAt.Location()) + } + if !cur.CreatedAt.Equal(localTime) { + t.Errorf("expected same instant as %v, got %v", localTime, cur.CreatedAt) + } +} + +func TestDecodeConversationCursor_InvalidBase64(t *testing.T) { + _, err := DecodeConversationCursor("not-valid-base64!!!") + if err == nil { + t.Fatal("expected error for invalid base64 cursor, got nil") + } +} + +func TestDecodeConversationCursor_InvalidJSON(t *testing.T) { + // Valid URL-safe base64, but the decoded bytes are not valid JSON. + _, err := DecodeConversationCursor("bm90LWpzb24tcGF5bG9hZA==") + if err == nil { + t.Fatal("expected error for cursor whose payload isn't valid JSON, got nil") + } +} + +func TestDecodeConversationCursor_EmptyString(t *testing.T) { + // Empty input decodes to zero base64 bytes, which is not valid JSON — the + // handler only calls Decode when the cursor query param is non-empty, but + // the function itself must still fail closed rather than panic or return + // a misleadingly "valid" zero cursor. + _, err := DecodeConversationCursor("") + if err == nil { + t.Fatal("expected error for empty cursor string, got nil") + } +} + +func TestDecodeConversationCursor_NormalizesResultToUTC(t *testing.T) { + // A cursor whose embedded timestamp carries a non-UTC offset should still + // come back normalized to UTC, regardless of how it was produced. + cur, err := DecodeConversationCursor("eyJjYSI6ICIyMDI2LTAzLTE1VDE3OjMwOjAwKzA3OjAwIiwgImlkIjogImFiYyJ9") + if err != nil { + t.Fatalf("decode: unexpected error: %v", err) + } + if cur.CreatedAt.Location() != time.UTC { + t.Errorf("expected UTC location, got %v", cur.CreatedAt.Location()) + } +} diff --git a/services/api/internal/service/agent/agent_service_test.go b/services/api/internal/service/agent/agent_service_test.go index 78bd3f9c..7c1a175d 100644 --- a/services/api/internal/service/agent/agent_service_test.go +++ b/services/api/internal/service/agent/agent_service_test.go @@ -921,6 +921,52 @@ func TestGetConversation_WrongProject(t *testing.T) { assert.ErrorIs(t, err, agentdom.ErrConversationNotFound) } +func TestListConversations_Success(t *testing.T) { + projectID := uuid.New() + agentID := uuid.New() + convs := []*agentdom.AgentConversation{ + {ID: uuid.New(), ProjectID: projectID, Status: "running"}, + {ID: uuid.New(), ProjectID: projectID, Status: "queued"}, + } + + var gotFilter agentdom.ListConversationsFilter + var gotLimit int + repo := &mockAgentRepo{ + listConversations: func(_ context.Context, filter agentdom.ListConversationsFilter, limit int) ([]*agentdom.AgentConversation, bool, error) { + gotFilter = filter + gotLimit = limit + return convs, true, nil + }, + } + svc := New(repo, &mockProjectRepo{}, nil, &mockPluginRepo{}) + + cursor := "some-cursor" + filter := agentdom.ListConversationsFilter{ProjectID: &projectID, AgentID: &agentID, CursorAfter: &cursor} + result, hasMore, err := svc.ListConversations(context.Background(), filter, 20) + + assert.NoError(t, err) + assert.True(t, hasMore) + assert.Equal(t, convs, result) + assert.Equal(t, 20, gotLimit) + assert.Equal(t, &projectID, gotFilter.ProjectID) + assert.Equal(t, &agentID, gotFilter.AgentID) + assert.Equal(t, &cursor, gotFilter.CursorAfter) +} + +func TestListConversations_PropagatesRepoError(t *testing.T) { + repo := &mockAgentRepo{ + listConversations: func(_ context.Context, _ agentdom.ListConversationsFilter, _ int) ([]*agentdom.AgentConversation, bool, error) { + return nil, false, agentdom.ErrConversationInvalidCursor + }, + } + svc := New(repo, &mockProjectRepo{}, nil, &mockPluginRepo{}) + + _, hasMore, err := svc.ListConversations(context.Background(), agentdom.ListConversationsFilter{}, 20) + + assert.ErrorIs(t, err, agentdom.ErrConversationInvalidCursor) + assert.False(t, hasMore) +} + func TestSendConversationMessage_Success(t *testing.T) { projectID := uuid.New() conversationID := uuid.New() diff --git a/services/api/internal/transport/http/handler/agent_handler_test.go b/services/api/internal/transport/http/handler/agent_handler_test.go index 376d969d..575e218b 100644 --- a/services/api/internal/transport/http/handler/agent_handler_test.go +++ b/services/api/internal/transport/http/handler/agent_handler_test.go @@ -24,9 +24,10 @@ import ( // --------------------------------------------------------------------------- type mockAgentSvc struct { - getAgent func(ctx context.Context, projectID, agentID uuid.UUID) (*agentdom.Agent, error) - createAgent func(ctx context.Context, projectID uuid.UUID, in agentdom.CreateAgentInput) (*agentdom.Agent, error) - startChatSession func(ctx context.Context, projectID, agentID, memberID uuid.UUID, message string) (*agentdom.AgentChatSession, *agentdom.AgentConversation, error) + getAgent func(ctx context.Context, projectID, agentID uuid.UUID) (*agentdom.Agent, error) + createAgent func(ctx context.Context, projectID uuid.UUID, in agentdom.CreateAgentInput) (*agentdom.Agent, error) + startChatSession func(ctx context.Context, projectID, agentID, memberID uuid.UUID, message string) (*agentdom.AgentChatSession, *agentdom.AgentConversation, error) + listConversations func(ctx context.Context, filter agentdom.ListConversationsFilter, limit int) ([]*agentdom.AgentConversation, bool, error) } func (m *mockAgentSvc) ListAgents(_ context.Context, _ uuid.UUID) ([]*agentdom.Agent, error) { @@ -86,7 +87,10 @@ func (m *mockAgentSvc) UpdateEnvVar(_ context.Context, _, _ uuid.UUID, _ agentdo return nil, errors.New("not found") } func (m *mockAgentSvc) DeleteEnvVar(_ context.Context, _, _ uuid.UUID) error { return nil } -func (m *mockAgentSvc) ListConversations(_ context.Context, _ agentdom.ListConversationsFilter, _ int) ([]*agentdom.AgentConversation, bool, error) { +func (m *mockAgentSvc) ListConversations(ctx context.Context, filter agentdom.ListConversationsFilter, limit int) ([]*agentdom.AgentConversation, bool, error) { + if m.listConversations != nil { + return m.listConversations(ctx, filter, limit) + } return nil, false, nil } func (m *mockAgentSvc) GetConversation(_ context.Context, _, _ uuid.UUID) (*agentdom.AgentConversation, error) { diff --git a/services/api/internal/transport/http/handler/conversation_handler_test.go b/services/api/internal/transport/http/handler/conversation_handler_test.go new file mode 100644 index 00000000..354b270a --- /dev/null +++ b/services/api/internal/transport/http/handler/conversation_handler_test.go @@ -0,0 +1,281 @@ +package handler_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + agentdom "github.com/Paca-AI/api/internal/domain/agent" + "github.com/Paca-AI/api/internal/transport/http/handler" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +// fixedTime returns a stable timestamp for building test conversations — +// only its UTC-roundtrip stability through the cursor codec matters here. +func fixedTime() time.Time { + return time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) +} + +// --------------------------------------------------------------------------- +// Router helper +// --------------------------------------------------------------------------- + +func newConversationRouter(svc agentdom.Service) chi.Router { + h := handler.NewConversationHandler(svc) + r := chi.NewRouter() + r.Route("/projects/{projectId}/conversations", func(r chi.Router) { + r.Get("/", h.ListConversations) + }) + return r +} + +// listConversationsResp decodes the standard envelope + list-conversations +// data shape returned by GET /projects/:projectId/conversations. +type listConversationsResp struct { + Success bool `json:"success"` + Data struct { + Items []map[string]any `json:"items"` + PageSize int `json:"page_size"` + NextCursor *string `json:"next_cursor"` + } `json:"data"` + ErrorCode string `json:"error_code"` +} + +func doListConversations(t *testing.T, svc agentdom.Service, projectID, query string) (*httptest.ResponseRecorder, listConversationsResp) { + t.Helper() + r := newConversationRouter(svc) + url := "/projects/" + projectID + "/conversations/" + if query != "" { + url += "?" + query + } + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + var resp listConversationsResp + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response body %q: %v", rec.Body.String(), err) + } + return rec, resp +} + +// --------------------------------------------------------------------------- +// page_size handling +// --------------------------------------------------------------------------- + +func TestListConversations_DefaultPageSize(t *testing.T) { + svc := &mockAgentSvc{ + listConversations: func(_ context.Context, _ agentdom.ListConversationsFilter, _ int) ([]*agentdom.AgentConversation, bool, error) { + return nil, false, nil + }, + } + rec, resp := doListConversations(t, svc, uuid.New().String(), "") + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + if resp.Data.PageSize != 20 { + t.Errorf("expected default page_size=20, got %d", resp.Data.PageSize) + } +} + +func TestListConversations_PageSizeClamping(t *testing.T) { + cases := []struct { + name string + query string + wantLimit int + }{ + {"zero_clamped_to_default", "page_size=0", 20}, + {"negative_clamped_to_default", "page_size=-5", 20}, + {"over_max_clamped_to_default", "page_size=201", 20}, + {"valid_custom_size_kept", "page_size=50", 50}, + {"max_boundary_kept", "page_size=200", 200}, + {"min_boundary_kept", "page_size=1", 1}, + {"non_numeric_clamped_to_default", "page_size=abc", 20}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var gotLimit int + svc := &mockAgentSvc{ + listConversations: func(_ context.Context, _ agentdom.ListConversationsFilter, limit int) ([]*agentdom.AgentConversation, bool, error) { + gotLimit = limit + return nil, false, nil + }, + } + _, resp := doListConversations(t, svc, uuid.New().String(), tc.query) + if gotLimit != tc.wantLimit { + t.Errorf("expected service called with limit=%d, got %d", tc.wantLimit, gotLimit) + } + if resp.Data.PageSize != tc.wantLimit { + t.Errorf("expected response page_size=%d, got %d", tc.wantLimit, resp.Data.PageSize) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Filter wiring +// --------------------------------------------------------------------------- + +func TestListConversations_FiltersPassedToService(t *testing.T) { + projectID := uuid.New() + agentID := uuid.New() + + var gotFilter agentdom.ListConversationsFilter + svc := &mockAgentSvc{ + listConversations: func(_ context.Context, filter agentdom.ListConversationsFilter, _ int) ([]*agentdom.AgentConversation, bool, error) { + gotFilter = filter + return nil, false, nil + }, + } + _, _ = doListConversations(t, svc, projectID.String(), + "agent_id="+agentID.String()+"&status=running&cursor=some-opaque-cursor") + + if gotFilter.ProjectID == nil || *gotFilter.ProjectID != projectID { + t.Errorf("expected ProjectID filter %v, got %v", projectID, gotFilter.ProjectID) + } + if gotFilter.AgentID == nil || *gotFilter.AgentID != agentID { + t.Errorf("expected AgentID filter %v, got %v", agentID, gotFilter.AgentID) + } + if gotFilter.Status == nil || *gotFilter.Status != "running" { + t.Errorf("expected Status filter %q, got %v", "running", gotFilter.Status) + } + if gotFilter.CursorAfter == nil || *gotFilter.CursorAfter != "some-opaque-cursor" { + t.Errorf("expected CursorAfter filter %q, got %v", "some-opaque-cursor", gotFilter.CursorAfter) + } +} + +func TestListConversations_InvalidAgentIDIgnored(t *testing.T) { + // A malformed agent_id query param should be silently dropped rather than + // erroring the request — only a valid UUID sets the filter. + var gotFilter agentdom.ListConversationsFilter + svc := &mockAgentSvc{ + listConversations: func(_ context.Context, filter agentdom.ListConversationsFilter, _ int) ([]*agentdom.AgentConversation, bool, error) { + gotFilter = filter + return nil, false, nil + }, + } + rec, _ := doListConversations(t, svc, uuid.New().String(), "agent_id=not-a-uuid") + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + if gotFilter.AgentID != nil { + t.Errorf("expected AgentID filter to remain nil for malformed input, got %v", *gotFilter.AgentID) + } +} + +func TestListConversations_NoCursorParam_FilterCursorNil(t *testing.T) { + var gotFilter agentdom.ListConversationsFilter + svc := &mockAgentSvc{ + listConversations: func(_ context.Context, filter agentdom.ListConversationsFilter, _ int) ([]*agentdom.AgentConversation, bool, error) { + gotFilter = filter + return nil, false, nil + }, + } + _, _ = doListConversations(t, svc, uuid.New().String(), "") + if gotFilter.CursorAfter != nil { + t.Errorf("expected nil CursorAfter when no cursor query param is set, got %q", *gotFilter.CursorAfter) + } +} + +// --------------------------------------------------------------------------- +// next_cursor response wiring +// --------------------------------------------------------------------------- + +func TestListConversations_NextCursorPresentWhenHasMore(t *testing.T) { + last := &agentdom.AgentConversation{ID: uuid.New(), CreatedAt: fixedTime()} + svc := &mockAgentSvc{ + listConversations: func(_ context.Context, _ agentdom.ListConversationsFilter, _ int) ([]*agentdom.AgentConversation, bool, error) { + return []*agentdom.AgentConversation{{ID: uuid.New(), CreatedAt: fixedTime()}, last}, true, nil + }, + } + _, resp := doListConversations(t, svc, uuid.New().String(), "") + if resp.Data.NextCursor == nil || *resp.Data.NextCursor == "" { + t.Fatal("expected non-empty next_cursor when hasMore=true") + } + wantCursor := agentdom.EncodeConversationCursor(last) + if *resp.Data.NextCursor != wantCursor { + t.Errorf("expected next_cursor encoded from the last item, got %q want %q", *resp.Data.NextCursor, wantCursor) + } +} + +func TestListConversations_NextCursorAbsentWhenNoMore(t *testing.T) { + svc := &mockAgentSvc{ + listConversations: func(_ context.Context, _ agentdom.ListConversationsFilter, _ int) ([]*agentdom.AgentConversation, bool, error) { + return []*agentdom.AgentConversation{{ID: uuid.New(), CreatedAt: fixedTime()}}, false, nil + }, + } + _, resp := doListConversations(t, svc, uuid.New().String(), "") + if resp.Data.NextCursor != nil { + t.Errorf("expected nil next_cursor when hasMore=false, got %q", *resp.Data.NextCursor) + } +} + +func TestListConversations_NextCursorAbsentWhenEmptyPage(t *testing.T) { + // hasMore=true with zero items shouldn't happen in practice, but the + // handler must not panic indexing into an empty slice. + svc := &mockAgentSvc{ + listConversations: func(_ context.Context, _ agentdom.ListConversationsFilter, _ int) ([]*agentdom.AgentConversation, bool, error) { + return nil, true, nil + }, + } + rec, resp := doListConversations(t, svc, uuid.New().String(), "") + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + if resp.Data.NextCursor != nil { + t.Errorf("expected nil next_cursor for an empty page, got %q", *resp.Data.NextCursor) + } +} + +func TestListConversations_ItemsMarshaledInOrder(t *testing.T) { + id1, id2 := uuid.New(), uuid.New() + svc := &mockAgentSvc{ + listConversations: func(_ context.Context, _ agentdom.ListConversationsFilter, _ int) ([]*agentdom.AgentConversation, bool, error) { + return []*agentdom.AgentConversation{ + {ID: id1, Status: "running", CreatedAt: fixedTime()}, + {ID: id2, Status: "queued", CreatedAt: fixedTime()}, + }, false, nil + }, + } + _, resp := doListConversations(t, svc, uuid.New().String(), "") + if len(resp.Data.Items) != 2 { + t.Fatalf("expected 2 items, got %d", len(resp.Data.Items)) + } + if resp.Data.Items[0]["id"] != id1.String() || resp.Data.Items[1]["id"] != id2.String() { + t.Errorf("expected items in service-returned order [%s, %s], got %v", id1, id2, resp.Data.Items) + } +} + +// --------------------------------------------------------------------------- +// Error propagation +// --------------------------------------------------------------------------- + +func TestListConversations_InvalidCursorReturnsBadRequest(t *testing.T) { + svc := &mockAgentSvc{ + listConversations: func(_ context.Context, _ agentdom.ListConversationsFilter, _ int) ([]*agentdom.AgentConversation, bool, error) { + return nil, false, agentdom.ErrConversationInvalidCursor + }, + } + rec, resp := doListConversations(t, svc, uuid.New().String(), "cursor=not-a-valid-cursor") + if rec.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid cursor, got %d", rec.Code) + } + if resp.Success { + t.Error("expected success=false in error envelope") + } + if resp.ErrorCode == "" { + t.Error("expected non-empty error_code in error envelope") + } +} + +func TestListConversations_InvalidProjectIDReturnsBadRequest(t *testing.T) { + svc := &mockAgentSvc{} + rec, _ := doListConversations(t, svc, "not-a-uuid", "") + if rec.Code != http.StatusBadRequest { + t.Errorf("expected 400 for malformed project id, got %d", rec.Code) + } +} diff --git a/services/api/test/e2e/common_env_test.go b/services/api/test/e2e/common_env_test.go index 0358ef92..d927e080 100644 --- a/services/api/test/e2e/common_env_test.go +++ b/services/api/test/e2e/common_env_test.go @@ -269,6 +269,7 @@ func newE2EEnv(t *testing.T) *e2eEnv { APIKey: handler.NewAPIKeyHandler(apiKeyService), Workflow: handler.NewWorkflowHandler(workflowService), Agent: handler.NewAgentHandler(agentService, "", "", "").WithMemberRepo(projectRepo), + Conversation: handler.NewConversationHandler(agentService), Log: log, }) diff --git a/services/api/test/e2e/conversation_pagination_test.go b/services/api/test/e2e/conversation_pagination_test.go new file mode 100644 index 00000000..961b7636 --- /dev/null +++ b/services/api/test/e2e/conversation_pagination_test.go @@ -0,0 +1,375 @@ +package e2e_test + +import ( + "fmt" + "net/http" + "net/url" + "testing" + "time" + + agentdom "github.com/Paca-AI/api/internal/domain/agent" + "github.com/google/uuid" +) + +// --------------------------------------------------------------------------- +// Fixture helpers +// --------------------------------------------------------------------------- + +// seedConversationFixture creates a logged-in project-admin user, a project, +// and an LLM agent in that project, returning everything a test needs to +// seed conversations directly via the repo and list them over HTTP. +func seedConversationFixture(t *testing.T, env *e2eEnv) (client *http.Client, token, projID string, agentID, memberID uuid.UUID) { + t.Helper() + username := "conv-pag-user-" + uuid.NewString() + seedTaskMemberUser(t, env, username, "convpagpass1") + client, token = taskMemberLogin(t, env, username, "convpagpass1") + // The project creator is granted the project's built-in "Admin" role, + // which includes agents.read/write — see seedACPUser in + // acp_agent_management_test.go for the same reasoning. + projID = createProjectForTasksViaAPI(t, env, client, token) + editorRoleID := projectRoleIDByName(t, env, client, token, projID, "Editor") + + status, resp := createAgentRequest(t, env, client, token, projID, + llmAgentBody(editorRoleID, "conv-pag-agent-"+uuid.NewString(), nil)) + if status != http.StatusCreated { + t.Fatalf("create agent: expected 201, got %d: %+v", status, resp) + } + data, ok := resp.Data.(map[string]any) + if !ok { + t.Fatalf("create agent: expected data object, got %T", resp.Data) + } + agentIDStr, _ := data["id"].(string) + agentID, err := uuid.Parse(agentIDStr) + if err != nil { + t.Fatalf("parse created agent id %q: %v", agentIDStr, err) + } + + user, err := env.userRepo.FindByUsername(env.ctx, username) + if err != nil { + t.Fatalf("find seeded user %q: %v", username, err) + } + member, err := env.projectRepo.FindMember(env.ctx, uuid.MustParse(projID), user.ID) + if err != nil { + t.Fatalf("find project member for user %q: %v", username, err) + } + + return client, token, projID, agentID, member.ID +} + +// createConversationAt inserts a conversation directly via the repository — +// there is no lightweight HTTP path to create one, since a real conversation +// spins up a container through the agent-trigger flow — and returns its ID. +// createdAt is set explicitly so tests can control keyset-pagination order +// deterministically instead of relying on wall-clock timing. +func createConversationAt(t *testing.T, env *e2eEnv, projID string, agentID, memberID uuid.UUID, status string, createdAt time.Time) string { + t.Helper() + conv := &agentdom.AgentConversation{ + ID: uuid.New(), + AgentID: agentID, + ProjectID: uuid.MustParse(projID), + TriggerType: "chat_message", + TriggeredByMemberID: &memberID, + Status: status, + CreatedAt: createdAt, + UpdatedAt: createdAt, + } + if err := env.agentRepo.CreateConversation(env.ctx, conv); err != nil { + t.Fatalf("create conversation: %v", err) + } + return conv.ID.String() +} + +// listConversationsPage issues GET /projects/:id/conversations with the given +// query params and asserts a 200 response, returning the decoded data map. +func listConversationsPage(t *testing.T, env *e2eEnv, client *http.Client, token, projID string, q url.Values) map[string]any { + t.Helper() + reqURL := fmt.Sprintf("%s/api/v1/projects/%s/conversations", env.base, projID) + if len(q) > 0 { + reqURL += "?" + q.Encode() + } + req := mustRequest(env.ctx, t, http.MethodGet, reqURL, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp := mustDo(t, client, req) + defer func() { _ = resp.Body.Close() }() + assertStatus(t, resp, http.StatusOK) + var env2 envelope + decodeJSON(t, resp, &env2) + return assertDataMap(t, env2) +} + +// --------------------------------------------------------------------------- +// TestE2EListConversationPagination_CursorBased +// --------------------------------------------------------------------------- + +func TestE2EListConversationPagination_CursorBased(t *testing.T) { + env := newE2EEnv(t) + client, token, projID, agentID, memberID := seedConversationFixture(t, env) + + base := time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC) + var allConvIDs []string + for i := 0; i < 5; i++ { + id := createConversationAt(t, env, projID, agentID, memberID, "finished", base.Add(time.Duration(i)*time.Minute)) + allConvIDs = append(allConvIDs, id) + } + + t.Run("first_page_returns_page_size_items_with_next_cursor", func(t *testing.T) { + data := listConversationsPage(t, env, client, token, projID, url.Values{"page_size": {"3"}}) + items, _ := data["items"].([]any) + if len(items) != 3 { + t.Errorf("expected 3 items on first page, got %d", len(items)) + } + if nextCursorStr(data) == "" { + t.Error("expected next_cursor to be set when more conversations exist beyond first page") + } + if ps, _ := data["page_size"].(float64); ps != 3 { + t.Errorf("expected page_size=3 echoed back, got %v", ps) + } + }) + + t.Run("first_page_is_newest_first", func(t *testing.T) { + data := listConversationsPage(t, env, client, token, projID, url.Values{"page_size": {"3"}}) + ids := itemIDs(data) + // Conversations are ordered created_at DESC, so the newest 3 (indices + // 4,3,2 of allConvIDs) must come back first, in that order. + want := []string{allConvIDs[4], allConvIDs[3], allConvIDs[2]} + for i, w := range want { + if ids[i] != w { + t.Errorf("position %d: expected %s, got %s (full: %v)", i, w, ids[i], ids) + } + } + }) + + t.Run("second_page_via_cursor_has_remaining_items_and_no_cursor", func(t *testing.T) { + firstPage := listConversationsPage(t, env, client, token, projID, url.Values{"page_size": {"3"}}) + cursor := nextCursorStr(firstPage) + if cursor == "" { + t.Fatal("expected non-empty next_cursor from first page") + } + + secondPage := listConversationsPage(t, env, client, token, projID, url.Values{ + "page_size": {"3"}, + "cursor": {cursor}, + }) + items, _ := secondPage["items"].([]any) + if len(items) != 2 { + t.Errorf("expected 2 remaining items on second page (5 total, 3 on first), got %d", len(items)) + } + if nextCursorStr(secondPage) != "" { + t.Error("expected next_cursor to be absent on the last page") + } + ids := itemIDs(secondPage) + want := []string{allConvIDs[1], allConvIDs[0]} + for i, w := range want { + if ids[i] != w { + t.Errorf("second page position %d: expected %s, got %s (full: %v)", i, w, ids[i], ids) + } + } + }) + + t.Run("no_next_cursor_when_all_conversations_fit_in_one_page", func(t *testing.T) { + data := listConversationsPage(t, env, client, token, projID, url.Values{"page_size": {"10"}}) + items, _ := data["items"].([]any) + if len(items) != 5 { + t.Errorf("expected 5 items when page_size exceeds conversation count, got %d", len(items)) + } + if nextCursorStr(data) != "" { + t.Error("expected no next_cursor when all conversations fit in one page") + } + }) + + t.Run("full_traversal_returns_all_conversations_without_duplicates", func(t *testing.T) { + seen := make(map[string]int) + cursor := "" + for { + q := url.Values{"page_size": {"2"}} + if cursor != "" { + q.Set("cursor", cursor) + } + data := listConversationsPage(t, env, client, token, projID, q) + for _, id := range itemIDs(data) { + seen[id]++ + } + cursor = nextCursorStr(data) + if cursor == "" { + break + } + } + if len(seen) != 5 { + t.Errorf("expected 5 unique conversations after full traversal, got %d", len(seen)) + } + for _, id := range allConvIDs { + if seen[id] == 0 { + t.Errorf("conversation %q was not returned during full traversal", id) + } + if seen[id] > 1 { + t.Errorf("conversation %q was returned %d times (duplicate)", id, seen[id]) + } + } + }) + + t.Run("page_size_zero_is_clamped_to_default", func(t *testing.T) { + data := listConversationsPage(t, env, client, token, projID, url.Values{"page_size": {"0"}}) + if ps, _ := data["page_size"].(float64); ps != 20 { + t.Errorf("expected page_size=20 when 0 requested (out-of-range clamped), got %v", ps) + } + }) + + t.Run("page_size_over_max_is_clamped_to_default", func(t *testing.T) { + data := listConversationsPage(t, env, client, token, projID, url.Values{"page_size": {"201"}}) + if ps, _ := data["page_size"].(float64); ps != 20 { + t.Errorf("expected page_size=20 when 201 requested (over max, clamped), got %v", ps) + } + }) + + t.Run("invalid_cursor_returns_error", func(t *testing.T) { + q := url.Values{"cursor": {"not-a-valid-base64-cursor!!"}} + reqURL := fmt.Sprintf("%s/api/v1/projects/%s/conversations?%s", env.base, projID, q.Encode()) + req := mustRequest(env.ctx, t, http.MethodGet, reqURL, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp := mustDo(t, client, req) + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode == http.StatusOK { + t.Errorf("expected a non-200 error response for invalid cursor, got 200") + } + assertErrorCode(t, resp, "AGENT_CONVERSATION_INVALID_CURSOR") + }) +} + +// --------------------------------------------------------------------------- +// TestE2EListConversationPagination_StatusFilter +// Verifies cursor pagination works correctly when combined with the status +// filter — only conversations matching the requested status are paginated. +// --------------------------------------------------------------------------- + +func TestE2EListConversationPagination_StatusFilter(t *testing.T) { + env := newE2EEnv(t) + client, token, projID, agentID, memberID := seedConversationFixture(t, env) + + base := time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC) + var runningIDs []string + for i := 0; i < 4; i++ { + id := createConversationAt(t, env, projID, agentID, memberID, "running", base.Add(time.Duration(i)*time.Minute)) + runningIDs = append(runningIDs, id) + } + // Noise: conversations in a different status must not appear in filtered results. + for i := 0; i < 2; i++ { + createConversationAt(t, env, projID, agentID, memberID, "finished", base.Add(time.Duration(10+i)*time.Minute)) + } + + t.Run("first_page_has_cursor_when_more_exist", func(t *testing.T) { + data := listConversationsPage(t, env, client, token, projID, url.Values{ + "status": {"running"}, + "page_size": {"2"}, + }) + items, _ := data["items"].([]any) + if len(items) != 2 { + t.Errorf("expected 2 running conversations on first page, got %d", len(items)) + } + if nextCursorStr(data) == "" { + t.Error("expected next_cursor when more running conversations exist beyond first page") + } + }) + + t.Run("full_traversal_returns_only_matching_status_without_duplicates", func(t *testing.T) { + seen := make(map[string]int) + cursor := "" + for { + q := url.Values{"status": {"running"}, "page_size": {"2"}} + if cursor != "" { + q.Set("cursor", cursor) + } + data := listConversationsPage(t, env, client, token, projID, q) + for _, id := range itemIDs(data) { + seen[id]++ + } + cursor = nextCursorStr(data) + if cursor == "" { + break + } + } + if len(seen) != 4 { + t.Errorf("expected 4 running conversations from full traversal, got %d", len(seen)) + } + for _, id := range runningIDs { + if seen[id] != 1 { + t.Errorf("running conversation %q appeared %d times (expected exactly once)", id, seen[id]) + } + } + }) +} + +// --------------------------------------------------------------------------- +// TestE2EListConversationPagination_AgentIDFilter +// Verifies cursor pagination works correctly when combined with the +// agent_id filter — only conversations from the requested agent appear. +// --------------------------------------------------------------------------- + +func TestE2EListConversationPagination_AgentIDFilter(t *testing.T) { + env := newE2EEnv(t) + client, token, projID, agentA, memberID := seedConversationFixture(t, env) + + editorRoleID := projectRoleIDByName(t, env, client, token, projID, "Editor") + status, resp := createAgentRequest(t, env, client, token, projID, + llmAgentBody(editorRoleID, "conv-pag-agent-b-"+uuid.NewString(), nil)) + if status != http.StatusCreated { + t.Fatalf("create second agent: expected 201, got %d: %+v", status, resp) + } + data, _ := resp.Data.(map[string]any) + agentBIDStr, _ := data["id"].(string) + agentB, err := uuid.Parse(agentBIDStr) + if err != nil { + t.Fatalf("parse second agent id: %v", err) + } + + base := time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC) + var agentAConvIDs []string + for i := 0; i < 3; i++ { + id := createConversationAt(t, env, projID, agentA, memberID, "finished", base.Add(time.Duration(i)*time.Minute)) + agentAConvIDs = append(agentAConvIDs, id) + } + for i := 0; i < 3; i++ { + createConversationAt(t, env, projID, agentB, memberID, "finished", base.Add(time.Duration(10+i)*time.Minute)) + } + + data2 := listConversationsPage(t, env, client, token, projID, url.Values{ + "agent_id": {agentA.String()}, + "page_size": {"10"}, + }) + ids := itemIDs(data2) + if len(ids) != 3 { + t.Fatalf("expected 3 conversations for agent A, got %d: %v", len(ids), ids) + } + for _, id := range agentAConvIDs { + found := false + for _, got := range ids { + if got == id { + found = true + break + } + } + if !found { + t.Errorf("expected agent A conversation %q in filtered results", id) + } + } +} + +// --------------------------------------------------------------------------- +// TestE2EListConversationPagination_EmptyProject +// --------------------------------------------------------------------------- + +func TestE2EListConversationPagination_EmptyProject(t *testing.T) { + env := newE2EEnv(t) + username := "conv-empty-user-" + uuid.NewString() + seedTaskMemberUser(t, env, username, "convemptypass1") + client, token := taskMemberLogin(t, env, username, "convemptypass1") + projID := createProjectForTasksViaAPI(t, env, client, token) + + data := listConversationsPage(t, env, client, token, projID, nil) + items, _ := data["items"].([]any) + if len(items) != 0 { + t.Errorf("expected 0 items for a project with no conversations, got %d", len(items)) + } + if nextCursorStr(data) != "" { + t.Error("expected no next_cursor for an empty result set") + } +}