From ff010026e464d71facf67d6e17edbbeb6135f54c Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:58:47 +0200 Subject: [PATCH 1/5] refactor: Serve the Marketplace Chat Through a Facade-Owned Provider ChatProvider builds a pane's chat contract and serves it to the facade, so a host no longer imports useChatHelpers to fill ChatContext itself. The marketplace is its first host; its specs stub the helper module the provider imports. --- .../components/Agents/MarketplaceContext.tsx | 17 ++---- .../Agents/tests/MarketplaceContext.spec.tsx | 12 ++-- .../hooks/Chat/__tests__/provider.spec.tsx | 58 +++++++++++++++++++ client/src/hooks/Chat/provider.tsx | 23 ++++++++ .../src/routes/__tests__/Marketplace.spec.tsx | 5 +- 5 files changed, 97 insertions(+), 18 deletions(-) create mode 100644 client/src/hooks/Chat/__tests__/provider.spec.tsx create mode 100644 client/src/hooks/Chat/provider.tsx diff --git a/client/src/components/Agents/MarketplaceContext.tsx b/client/src/components/Agents/MarketplaceContext.tsx index d6338df5e0e..3a46255889a 100644 --- a/client/src/components/Agents/MarketplaceContext.tsx +++ b/client/src/components/Agents/MarketplaceContext.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { ChatContext } from '~/Providers'; -import { useChatHelpers } from '~/hooks'; +import { ChatProvider } from '~/hooks/Chat/provider'; /** * Minimal marketplace provider that provides only what SidePanel actually needs @@ -38,12 +37,8 @@ export function useMarketplaceHost(): MarketplaceHost { return host; } -export const MarketplaceProvider: React.FC = ({ children, host }) => { - const chatHelpers = useChatHelpers(0, 'new'); - - return ( - - {children} - - ); -}; +export const MarketplaceProvider: React.FC = ({ children, host }) => ( + + {children} + +); diff --git a/client/src/components/Agents/tests/MarketplaceContext.spec.tsx b/client/src/components/Agents/tests/MarketplaceContext.spec.tsx index 8822797f4d8..4dc15cccc3f 100644 --- a/client/src/components/Agents/tests/MarketplaceContext.spec.tsx +++ b/client/src/components/Agents/tests/MarketplaceContext.spec.tsx @@ -9,8 +9,11 @@ import { useChatContext } from '~/Providers'; const mockResetNewConversation = jest.fn(); -jest.mock('~/hooks', () => ({ - useChatHelpers: jest.fn(), +const mockUseChatHelpers = jest.fn(); + +jest.mock('~/hooks/Chat/useChatHelpers', () => ({ + __esModule: true, + default: (...args: unknown[]) => mockUseChatHelpers(...args), })); const chatHelpers = { @@ -51,15 +54,14 @@ const renderProvider = (children: React.ReactNode = ) => { describe('MarketplaceProvider', () => { beforeEach(() => { jest.clearAllMocks(); - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { useChatHelpers } = require('~/hooks'); - (useChatHelpers as jest.Mock).mockReturnValue(chatHelpers); + mockUseChatHelpers.mockReturnValue(chatHelpers); }); it('hands the marketplace the chat context its panels read', () => { renderProvider(); expect(screen.getByTestId('conversation-id')).toHaveTextContent('marketplace'); + expect(mockUseChatHelpers).toHaveBeenCalledWith(0, 'new'); }); it('passes the host reset straight through to the marketplace that asks for it', async () => { diff --git a/client/src/hooks/Chat/__tests__/provider.spec.tsx b/client/src/hooks/Chat/__tests__/provider.spec.tsx new file mode 100644 index 00000000000..5dbb7a9b6d3 --- /dev/null +++ b/client/src/hooks/Chat/__tests__/provider.spec.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ChatContract } from '../contract'; +import { useChatActions } from '../facade'; +import { ChatProvider } from '../provider'; + +const mockUseChatHelpers = jest.fn(); + +jest.mock('../useChatHelpers', () => ({ + __esModule: true, + default: (...args: unknown[]) => mockUseChatHelpers(...args), +})); + +const contract = { + conversation: { conversationId: 'convo-1' }, + messagesKey: 'convo-1', + getMessages: () => [], + latestMessageId: undefined, + isSubmitting: false, + ask: jest.fn(), + regenerate: jest.fn(), + stopGenerating: jest.fn(() => Promise.resolve()), +} as unknown as ChatContract; + +const renderUnder = (props: { index?: number; conversationId?: string }) => { + const queryClient = new QueryClient(); + return renderHook(() => useChatActions(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); +}; + +describe('ChatProvider', () => { + beforeEach(() => { + mockUseChatHelpers.mockReset(); + mockUseChatHelpers.mockReturnValue(contract); + }); + + it('serves the pane contract it builds to the facade below it', async () => { + const { result } = renderUnder({ index: 1, conversationId: 'convo-1' }); + + expect(mockUseChatHelpers).toHaveBeenCalledWith(1, 'convo-1'); + expect(result.current.id).toBe('convo-1'); + expect(result.current.status).toBe('ready'); + await result.current.stop(); + expect(contract.stopGenerating).toHaveBeenCalledTimes(1); + }); + + it('builds the root pane by default', () => { + renderUnder({}); + + expect(mockUseChatHelpers).toHaveBeenCalledWith(0, undefined); + }); +}); diff --git a/client/src/hooks/Chat/provider.tsx b/client/src/hooks/Chat/provider.tsx new file mode 100644 index 00000000000..e5974de4304 --- /dev/null +++ b/client/src/hooks/Chat/provider.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from 'react'; +import { ChatContext } from '~/Providers/ChatContext'; +import useChatHelpers from './useChatHelpers'; + +/** + * Builds a pane's chat contract and serves it to `useChat`, `useChatActions` and + * `useChatContext` below. A host renders this instead of calling `useChatHelpers` itself, so + * components reach the chat only through the facade and its context. + */ +export function ChatProvider({ + index = 0, + conversationId, + children, +}: { + /** The pane: `0` is the root pane, `1` the added (multi-convo) pane. */ + index?: number; + /** The route's conversation id, which can run ahead of the pane's conversation. */ + conversationId?: string; + children: ReactNode; +}) { + const chat = useChatHelpers(index, conversationId); + return {children}; +} diff --git a/client/src/routes/__tests__/Marketplace.spec.tsx b/client/src/routes/__tests__/Marketplace.spec.tsx index 8b7587951ac..43032ee8f2e 100644 --- a/client/src/routes/__tests__/Marketplace.spec.tsx +++ b/client/src/routes/__tests__/Marketplace.spec.tsx @@ -9,8 +9,9 @@ import MarketplaceRoute from '../Marketplace'; const mockClearAllConversations = jest.fn(); const mockClearMessagesCache = jest.fn(); -jest.mock('~/hooks', () => ({ - useChatHelpers: jest.fn(() => ({})), +jest.mock('~/hooks/Chat/useChatHelpers', () => ({ + __esModule: true, + default: jest.fn(() => ({})), })); jest.mock('~/utils/messages', () => ({ From b65d2919a63717d0f828d73b0a529c571d325040 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:59:47 +0200 Subject: [PATCH 2/5] test: Start a Chat From a Marketplace Agent Card Opens an agent's marketplace card, starts a chat from it, and expects the new chat to send its turn to that agent and show the reply. --- .../scenarios/marketplace-start-chat.spec.ts | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 e2e/specs/mock/scenarios/marketplace-start-chat.spec.ts diff --git a/e2e/specs/mock/scenarios/marketplace-start-chat.spec.ts b/e2e/specs/mock/scenarios/marketplace-start-chat.spec.ts new file mode 100644 index 00000000000..dff739d9cff --- /dev/null +++ b/e2e/specs/mock/scenarios/marketplace-start-chat.spec.ts @@ -0,0 +1,83 @@ +import { expect, test } from '@playwright/test'; +import type { Page } from '@playwright/test'; +import { withMongo } from '../db'; +import { uniqueAgentName } from '../agents.helpers'; +import { + getAccessToken, + messagesView, + replyPrompt, + replyText, + requestJson, + sendMessage, +} from '../helpers'; + +type AgentResponse = { id: string }; + +async function createAgent(page: Page, name: string): Promise { + const token = await getAccessToken(page); + return requestJson(page, { + path: '/api/agents', + token, + method: 'POST', + body: { + name, + description: 'Agent used by the marketplace start-chat scenario.', + instructions: 'Respond deterministically for the marketplace start-chat scenario.', + provider: 'Mock Provider A', + model: 'mock-model-a', + model_parameters: {}, + }, + }); +} + +async function cleanupAgent(agentId: string): Promise { + await withMongo(async (db) => { + const agent = await db + .collection('agents') + .findOne({ id: agentId }, { projection: { _id: 1 } }); + if (agent) { + await db.collection('aclentries').deleteMany({ resourceId: agent._id }); + } + await db.collection('agents').deleteMany({ id: agentId }); + }); +} + +test.describe('marketplace start chat', () => { + /* `getAccessToken` refreshes through a relative URL from the page, so the tab has to be + on the app before the test asks for a token. */ + test.beforeEach(async ({ page }) => { + await page.goto('/agents/all', { timeout: 30_000 }); + }); + + test('@scenario:a-chat-started-from-the-marketplace-answers-with-that-agent starting a chat from an agent card opens a new chat that the agent answers', async ({ + page, + }) => { + test.setTimeout(120_000); + const name = uniqueAgentName('E2E Marketplace Start'); + const agent = await createAgent(page, name); + + try { + await page.goto(`/agents/all?q=${encodeURIComponent(name)}`, { timeout: 10_000 }); + const heading = page.getByRole('heading', { name, exact: true }); + await expect(heading).toBeVisible({ timeout: 30_000 }); + await heading.click(); + + await page.getByRole('button', { name: 'Start Chat' }).click(); + /** The chat route applies `agent_id` and then drops it from the URL, so the turn's + * request body is what proves which agent the chat started with. */ + await expect(page).toHaveURL(/\/c\/new/, { timeout: 15_000 }); + + const label = `marketplace-start-${Date.now()}`; + const response = await sendMessage(page, replyPrompt(label)); + expect(response.ok()).toBeTruthy(); + await expect(messagesView(page).getByText(replyText(label))).toBeVisible({ + timeout: 30_000, + }); + expect(response.request().postDataJSON()).toEqual( + expect.objectContaining({ agent_id: agent.id }), + ); + } finally { + await cleanupAgent(agent.id); + } + }); +}); From 00941aa21ddbc4cc67aac2dde14086ad63754540 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:13:49 +0200 Subject: [PATCH 3/5] test: Open the Marketplace Card Through Its Button The card heading sits under the card's click layer, so the scenario clicks the button that takes its accessible name from the heading. --- e2e/specs/mock/scenarios/marketplace-start-chat.spec.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/e2e/specs/mock/scenarios/marketplace-start-chat.spec.ts b/e2e/specs/mock/scenarios/marketplace-start-chat.spec.ts index dff739d9cff..ae356ac80ff 100644 --- a/e2e/specs/mock/scenarios/marketplace-start-chat.spec.ts +++ b/e2e/specs/mock/scenarios/marketplace-start-chat.spec.ts @@ -58,9 +58,12 @@ test.describe('marketplace start chat', () => { try { await page.goto(`/agents/all?q=${encodeURIComponent(name)}`, { timeout: 10_000 }); - const heading = page.getByRole('heading', { name, exact: true }); - await expect(heading).toBeVisible({ timeout: 30_000 }); - await heading.click(); + await expect(page.getByRole('heading', { name, exact: true })).toBeVisible({ + timeout: 30_000, + }); + /** The card's heading sits under its click layer; the button that opens the dialog + * takes its accessible name from that heading. */ + await page.getByRole('button', { name, exact: true }).click(); await page.getByRole('button', { name: 'Start Chat' }).click(); /** The chat route applies `agent_id` and then drops it from the URL, so the turn's From 38bb4ee51dfee9fdaa280f28d5b8d1e844faa333 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:12:11 +0200 Subject: [PATCH 4/5] test: Wait for the Marketplace Agent Before Sending The chat route applies agent_id on a short query-param poll and then drops it from the URL, so the scenario waits for the parameter to go before it sends the turn. --- e2e/specs/mock/scenarios/marketplace-start-chat.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/e2e/specs/mock/scenarios/marketplace-start-chat.spec.ts b/e2e/specs/mock/scenarios/marketplace-start-chat.spec.ts index ae356ac80ff..2de560f3c73 100644 --- a/e2e/specs/mock/scenarios/marketplace-start-chat.spec.ts +++ b/e2e/specs/mock/scenarios/marketplace-start-chat.spec.ts @@ -66,9 +66,11 @@ test.describe('marketplace start chat', () => { await page.getByRole('button', { name, exact: true }).click(); await page.getByRole('button', { name: 'Start Chat' }).click(); - /** The chat route applies `agent_id` and then drops it from the URL, so the turn's - * request body is what proves which agent the chat started with. */ + /** The chat route applies `agent_id` on its query-param poll and then drops it from the + * URL, so waiting for it to go is waiting for the agent to be selected. The turn's request + * body is what proves which agent the chat started with. */ await expect(page).toHaveURL(/\/c\/new/, { timeout: 15_000 }); + await expect(page).not.toHaveURL(/agent_id=/, { timeout: 15_000 }); const label = `marketplace-start-${Date.now()}`; const response = await sendMessage(page, replyPrompt(label)); From 0848b18c3a9e00371b0a14db05b734ea1a3f6551 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:38:19 +0200 Subject: [PATCH 5/5] test: Type the useChatHelpers Mocks With the Hook's Parameters The provider and marketplace specs pass index and paramId through explicitly instead of an unknown[] spread, and the provider spec builds a complete typed chat contract. --- .../Agents/tests/MarketplaceContext.spec.tsx | 2 +- .../hooks/Chat/__tests__/provider.spec.tsx | 38 ++++++++++++++++--- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/client/src/components/Agents/tests/MarketplaceContext.spec.tsx b/client/src/components/Agents/tests/MarketplaceContext.spec.tsx index 4dc15cccc3f..d124f8a01b9 100644 --- a/client/src/components/Agents/tests/MarketplaceContext.spec.tsx +++ b/client/src/components/Agents/tests/MarketplaceContext.spec.tsx @@ -13,7 +13,7 @@ const mockUseChatHelpers = jest.fn(); jest.mock('~/hooks/Chat/useChatHelpers', () => ({ __esModule: true, - default: (...args: unknown[]) => mockUseChatHelpers(...args), + default: (index?: number, paramId?: string) => mockUseChatHelpers(index, paramId), })); const chatHelpers = { diff --git a/client/src/hooks/Chat/__tests__/provider.spec.tsx b/client/src/hooks/Chat/__tests__/provider.spec.tsx index 5dbb7a9b6d3..0687205d6a6 100644 --- a/client/src/hooks/Chat/__tests__/provider.spec.tsx +++ b/client/src/hooks/Chat/__tests__/provider.spec.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { renderHook } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { TConversation } from 'librechat-data-provider'; import type { ChatContract } from '../contract'; import { useChatActions } from '../facade'; import { ChatProvider } from '../provider'; @@ -9,19 +10,44 @@ const mockUseChatHelpers = jest.fn(); jest.mock('../useChatHelpers', () => ({ __esModule: true, - default: (...args: unknown[]) => mockUseChatHelpers(...args), + default: (index?: number, paramId?: string) => mockUseChatHelpers(index, paramId), })); -const contract = { - conversation: { conversationId: 'convo-1' }, - messagesKey: 'convo-1', +const noop = () => undefined; + +const contract: ChatContract = { + index: 0, + conversation: { conversationId: 'convo-1' } as TConversation, + setConversation: noop, + newConversation: noop, + preset: null, + setPreset: noop, + optionSettings: {}, + setOptionSettings: noop, getMessages: () => [], + messagesKey: 'convo-1', + setMessages: noop, + setSiblingIdx: noop, latestMessageId: undefined, - isSubmitting: false, + latestMessageDepth: undefined, ask: jest.fn(), regenerate: jest.fn(), + isSubmitting: false, + setIsSubmitting: noop, + handleRegenerate: noop, + handleContinue: noop, stopGenerating: jest.fn(() => Promise.resolve()), -} as unknown as ChatContract; + handleStopGenerating: noop, + abortScroll: false, + setAbortScroll: noop, + files: new Map(), + setFiles: noop, + filesLoading: false, + setFilesLoading: noop, + showPopover: false, + setShowPopover: noop, + feedbackEnabled: false, +}; const renderUnder = (props: { index?: number; conversationId?: string }) => { const queryClient = new QueryClient();