diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/__tests__/useChatSession.test.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/__tests__/useChatSession.test.ts new file mode 100644 index 0000000000..a6d8c5e896 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/__tests__/useChatSession.test.ts @@ -0,0 +1,122 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useChatSession } from "../useChatSession"; + +const mockUseGetV2GetSession = vi.fn(); + +vi.mock("@/app/api/__generated__/endpoints/chat/chat", () => ({ + useGetV2GetSession: (...args: unknown[]) => mockUseGetV2GetSession(...args), + usePostV2CreateSession: () => ({ mutateAsync: vi.fn(), isPending: false }), + getGetV2GetSessionQueryKey: (id: string) => ["session", id], + getGetV2ListSessionsQueryKey: () => ["sessions"], +})); + +vi.mock("@tanstack/react-query", () => ({ + useQueryClient: () => ({ + invalidateQueries: vi.fn(), + setQueryData: vi.fn(), + }), +})); + +vi.mock("nuqs", () => ({ + parseAsString: { withDefault: (v: unknown) => v }, + useQueryState: () => ["sess-1", vi.fn()], +})); + +vi.mock("../helpers/convertChatSessionToUiMessages", () => ({ + convertChatSessionMessagesToUiMessages: vi.fn(() => ({ + messages: [], + historicalDurations: new Map(), + })), +})); + +vi.mock("../helpers", () => ({ + resolveSessionDryRun: vi.fn(() => false), +})); + +vi.mock("@sentry/nextjs", () => ({ + captureException: vi.fn(), +})); + +function makeQueryResult(data: object | null) { + return { + data: data ? { status: 200, data } : undefined, + isLoading: false, + isError: false, + isFetching: false, + refetch: vi.fn(), + }; +} + +describe("useChatSession — newestSequence and forwardPaginated", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns null / false when no session data", () => { + mockUseGetV2GetSession.mockReturnValue(makeQueryResult(null)); + const { result } = renderHook(() => useChatSession()); + expect(result.current.newestSequence).toBeNull(); + expect(result.current.forwardPaginated).toBe(false); + }); + + it("returns newestSequence from session data", () => { + mockUseGetV2GetSession.mockReturnValue( + makeQueryResult({ + messages: [], + has_more_messages: true, + oldest_sequence: 0, + newest_sequence: 99, + forward_paginated: false, + active_stream: null, + }), + ); + const { result } = renderHook(() => useChatSession()); + expect(result.current.newestSequence).toBe(99); + }); + + it("returns null for newestSequence when field is missing", () => { + mockUseGetV2GetSession.mockReturnValue( + makeQueryResult({ + messages: [], + has_more_messages: false, + oldest_sequence: 0, + newest_sequence: null, + forward_paginated: false, + active_stream: null, + }), + ); + const { result } = renderHook(() => useChatSession()); + expect(result.current.newestSequence).toBeNull(); + }); + + it("returns forwardPaginated=true when session is forward-paginated", () => { + mockUseGetV2GetSession.mockReturnValue( + makeQueryResult({ + messages: [], + has_more_messages: true, + oldest_sequence: 0, + newest_sequence: 49, + forward_paginated: true, + active_stream: null, + }), + ); + const { result } = renderHook(() => useChatSession()); + expect(result.current.forwardPaginated).toBe(true); + }); + + it("returns forwardPaginated=false when session is backward-paginated", () => { + mockUseGetV2GetSession.mockReturnValue( + makeQueryResult({ + messages: [], + has_more_messages: true, + oldest_sequence: 50, + newest_sequence: 99, + forward_paginated: false, + active_stream: null, + }), + ); + const { result } = renderHook(() => useChatSession()); + expect(result.current.forwardPaginated).toBe(false); + }); +}); diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/__tests__/ChatMessagesContainer.test.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/__tests__/ChatMessagesContainer.test.tsx new file mode 100644 index 0000000000..36fd8f6c61 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/__tests__/ChatMessagesContainer.test.tsx @@ -0,0 +1,173 @@ +import { render, screen, cleanup } from "@/tests/integrations/test-utils"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ChatMessagesContainer } from "../ChatMessagesContainer"; + +const mockScrollEl = { + scrollHeight: 100, + scrollTop: 0, + clientHeight: 500, +}; + +vi.mock("use-stick-to-bottom", () => ({ + useStickToBottomContext: () => ({ scrollRef: { current: mockScrollEl } }), + Conversation: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + ConversationContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + ConversationScrollButton: () => null, +})); + +vi.mock("@/components/ai-elements/conversation", () => ({ + Conversation: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + ConversationContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + ConversationScrollButton: () => null, +})); + +vi.mock("@/components/ai-elements/message", () => ({ + Message: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + MessageContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + MessageActions: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("../components/AssistantMessageActions", () => ({ + AssistantMessageActions: () => null, +})); +vi.mock("../components/CopyButton", () => ({ CopyButton: () => null })); +vi.mock("../components/CollapsedToolGroup", () => ({ + CollapsedToolGroup: () => null, +})); +vi.mock("../components/MessageAttachments", () => ({ + MessageAttachments: () => null, +})); +vi.mock("../components/MessagePartRenderer", () => ({ + MessagePartRenderer: () => null, +})); +vi.mock("../components/ReasoningCollapse", () => ({ + ReasoningCollapse: () => null, +})); +vi.mock("../components/ThinkingIndicator", () => ({ + ThinkingIndicator: () => null, +})); +vi.mock("../../JobStatsBar/TurnStatsBar", () => ({ + TurnStatsBar: () => null, +})); +vi.mock("../../JobStatsBar/useElapsedTimer", () => ({ + useElapsedTimer: () => ({ elapsedSeconds: 0 }), +})); +vi.mock("../../CopilotPendingReviews/CopilotPendingReviews", () => ({ + CopilotPendingReviews: () => null, +})); +vi.mock("../helpers", () => ({ + buildRenderSegments: () => [], + getTurnMessages: () => [], + parseSpecialMarkers: () => ({ markerType: null }), + splitReasoningAndResponse: (parts: unknown[]) => ({ + reasoningParts: [], + responseParts: parts, + }), +})); + +type ObserverCallback = (entries: { isIntersecting: boolean }[]) => void; +class MockIntersectionObserver { + static lastCallback: ObserverCallback | null = null; + private callback: ObserverCallback; + constructor(cb: ObserverCallback) { + this.callback = cb; + MockIntersectionObserver.lastCallback = cb; + } + observe() {} + disconnect() {} + unobserve() {} + takeRecords() { + return []; + } + root = null; + rootMargin = ""; + thresholds = []; +} + +const BASE_PROPS = { + messages: [], + status: "ready" as const, + error: undefined, + isLoading: false, + sessionID: "sess-1", + hasMoreMessages: true, + isLoadingMore: false, + onLoadMore: vi.fn(), + onRetry: vi.fn(), +}; + +describe("ChatMessagesContainer", () => { + beforeEach(() => { + mockScrollEl.scrollHeight = 100; + mockScrollEl.scrollTop = 0; + mockScrollEl.clientHeight = 500; + MockIntersectionObserver.lastCallback = null; + vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("renders top sentinel when forwardPaginated is false (backward pagination)", () => { + render(); + expect( + screen.getByRole("button", { name: /load older messages/i }), + ).toBeDefined(); + }); + + it("renders top sentinel when forwardPaginated is undefined (default, backward)", () => { + render(); + expect( + screen.getByRole("button", { name: /load older messages/i }), + ).toBeDefined(); + }); + + it("renders bottom sentinel when forwardPaginated is true (forward pagination)", () => { + render(); + expect( + screen.getByRole("button", { name: /load older messages/i }), + ).toBeDefined(); + }); + + it("hides sentinel when hasMoreMessages is false", () => { + render( + , + ); + expect( + screen.queryByRole("button", { name: /load older messages/i }), + ).toBeNull(); + }); + + it("hides sentinel when onLoadMore is not provided", () => { + render( + , + ); + expect( + screen.queryByRole("button", { name: /load older messages/i }), + ).toBeNull(); + }); +});