diff --git a/api/server/controllers/agents/__tests__/callbacks.spec.js b/api/server/controllers/agents/__tests__/callbacks.spec.js index 0a2a3a1bbf..351ce08edf 100644 --- a/api/server/controllers/agents/__tests__/callbacks.spec.js +++ b/api/server/controllers/agents/__tests__/callbacks.spec.js @@ -8,6 +8,7 @@ jest.mock('nanoid', () => ({ jest.mock('@librechat/api', () => ({ sendEvent: jest.fn(), writeAttachmentEvent: jest.fn(), + createOwnedToolEndHandler: jest.fn((callback) => ({ handle: callback })), GenerationJobManager: { emitChunk: jest.fn(), }, @@ -498,6 +499,42 @@ describe('createToolEndCallback', () => { ]); }); + it.each(['createToolEndCallback', 'createResponsesToolEndCallback'])( + '%s preserves memory payloads and their execution owner', + async (factoryName) => { + const toolEndCallback = require('../callbacks')[factoryName]({ req, res, artifactPromises }); + await toolEndCallback( + { + output: { + tool_call_id: 'call_0', + artifact: { + [Tools.memory]: { + key: 'project', + type: 'update', + value: 'owned', + }, + }, + }, + }, + { + run_id: 'run456', + thread_id: 'thread789', + agent_id: 'agent-a', + stepId: 'step-memory-1', + }, + ); + + const [attachment] = await Promise.all(artifactPromises); + expect(attachment).toMatchObject({ + type: Tools.memory, + toolCallId: 'call_0', + agentId: 'agent-a', + stepId: 'step-memory-1', + [Tools.memory]: { key: 'project', type: 'update', value: 'owned' }, + }); + }, + ); + describe('ui_resources artifact handling', () => { it('should process ui_resources artifact and return attachment when headers not sent', async () => { const toolEndCallback = createToolEndCallback({ req, res, artifactPromises }); diff --git a/api/server/controllers/agents/__tests__/openai.spec.js b/api/server/controllers/agents/__tests__/openai.spec.js index 90fdabe516..8c8960b2d5 100644 --- a/api/server/controllers/agents/__tests__/openai.spec.js +++ b/api/server/controllers/agents/__tests__/openai.spec.js @@ -194,6 +194,9 @@ jest.mock('@librechat/api', () => ({ * export or the call throws before the assertions run. */ stripActivityLabelParts: jest.fn((payload) => payload), writeSSE: jest.fn(), + createOwnedToolEndHandler: jest.fn( + (...args) => new (require('@librechat/agents').ToolEndHandler)(...args), + ), createRun: jest.fn().mockResolvedValue({ processStream: mockProcessStream, }), diff --git a/api/server/controllers/agents/__tests__/responses.unit.spec.js b/api/server/controllers/agents/__tests__/responses.unit.spec.js index 584b4c8e56..5ea0d8628a 100644 --- a/api/server/controllers/agents/__tests__/responses.unit.spec.js +++ b/api/server/controllers/agents/__tests__/responses.unit.spec.js @@ -203,6 +203,9 @@ jest.mock('@librechat/api', () => ({ * before SDK formatting; the mock must expose it like any other used * export or the call throws before the assertions run. */ stripActivityLabelParts: jest.fn((payload) => payload), + createOwnedToolEndHandler: jest.fn( + (...args) => new (require('@librechat/agents').ToolEndHandler)(...args), + ), collectReachableAgents: (roots) => { const agents = []; const pending = [...roots]; diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index 505018fe71..6856dc5018 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -13,7 +13,6 @@ const { const { GraphEvents, GraphNodeKeys, - ToolEndHandler, createContentAggregator, summarizeEvent, } = require('@librechat/agents'); @@ -23,6 +22,7 @@ const { GenerationJobManager, writeAttachmentEvent, createToolExecuteHandler, + createOwnedToolEndHandler, createBackgroundCodeResultHandler: createCodeHarvestHandler, HOST_FILE_AUTHORING_ARTIFACT_KEY, isCodeSessionToolName, @@ -42,6 +42,15 @@ function isCodeArtifactToolOutput(output) { return isCodeSessionToolName(output.name) || isHostFileAuthoringArtifact(output.artifact); } +function getAttachmentOwnership(metadata) { + const agentId = metadata?.agent_id ?? metadata?.agentId; + const stepId = metadata?.stepId; + return { + ...(typeof agentId === 'string' && agentId.length > 0 ? { agentId } : {}), + ...(typeof stepId === 'string' && stepId.length > 0 ? { stepId } : {}), + }; +} + function addStatefulWorkspaceChange(attachment, artifact, executionProfile) { if (!attachment || executionProfile !== 'stateful' || !isHostFileAuthoringArtifact(artifact)) { return attachment; @@ -506,7 +515,7 @@ function getDefaultHandlers({ collectedThoughtSignatures, emitTokenUsage, ), - [GraphEvents.TOOL_END]: new ToolEndHandler(toolEndCallback, logger), + [GraphEvents.TOOL_END]: createOwnedToolEndHandler(toolEndCallback, logger), [GraphEvents.ON_RUN_STEP]: { /** * Handle ON_RUN_STEP event. @@ -1004,9 +1013,9 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null, jo (async () => { const attachment = { type: Tools.web_search, + ...getAttachmentOwnership(metadata), messageId: metadata.run_id, toolCallId: output.tool_call_id, - agentId: metadata.agent_id, conversationId: metadata.thread_id, [Tools.web_search]: { ...output.artifact[Tools.web_search] }, }; @@ -1027,6 +1036,7 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null, jo (async () => { const attachment = { type: Tools.memory, + ...getAttachmentOwnership(metadata), messageId: metadata.run_id, toolCallId: output.tool_call_id, conversationId: metadata.thread_id, @@ -1368,6 +1378,7 @@ function createResponsesToolEndCallback({ req, res, tracker, artifactPromises }) const attachment = { type: Tools.web_search, toolCallId: output.tool_call_id, + ...getAttachmentOwnership(metadata), [Tools.web_search]: { ...output.artifact[Tools.web_search] }, }; // For Responses API, always emit attachment during streaming @@ -1382,6 +1393,26 @@ function createResponsesToolEndCallback({ req, res, tracker, artifactPromises }) ); } + if (output.artifact[Tools.memory]) { + artifactPromises.push( + (async () => { + const attachment = { + type: Tools.memory, + toolCallId: output.tool_call_id, + ...getAttachmentOwnership(metadata), + [Tools.memory]: output.artifact[Tools.memory], + }; + if (res.headersSent && !res.writableEnded) { + writeResponsesAttachment(res, tracker, attachment, metadata); + } + return attachment; + })().catch((error) => { + logger.error('Error processing memory artifact content:', error); + return null; + }), + ); + } + if (output.artifact.content) { /** @type {FormattedContent[]} */ const content = output.artifact.content; diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index b57cce7493..de275905ad 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -1,6 +1,6 @@ const { nanoid } = require('nanoid'); const { logger } = require('@librechat/data-schemas'); -const { Callback, ToolEndHandler, formatAgentMessages } = require('@librechat/agents'); +const { Callback, formatAgentMessages } = require('@librechat/agents'); const { EModelEndpoint, ResourceType, @@ -56,6 +56,7 @@ const { getUserFacingProviderError, getRemoteAgentPermissions, createToolExecuteHandler, + createOwnedToolEndHandler, buildNonStreamingResponse, createOpenAIStreamTracker, resolveAgentScopedSkillIds, @@ -1001,7 +1002,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { }, on_run_step_completed: createHandler(), // Use proper ToolEndHandler for processing artifacts (images, file citations, code output) - on_tool_end: new ToolEndHandler(toolEndCallback, logger), + on_tool_end: createOwnedToolEndHandler(toolEndCallback, logger), on_chain_stream: createHandler(), on_chain_end: createHandler(), on_agent_update: createHandler(), diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index 4903abf569..c3632f89c6 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -1,7 +1,7 @@ const { nanoid } = require('nanoid'); const { v4: uuidv4 } = require('uuid'); const { logger } = require('@librechat/data-schemas'); -const { Callback, ToolEndHandler, formatAgentMessages } = require('@librechat/agents'); +const { Callback, formatAgentMessages } = require('@librechat/agents'); const { EModelEndpoint, ResourceType, @@ -53,6 +53,7 @@ const { getSafeErrorMetadata, getUserFacingProviderError, createToolExecuteHandler, + createOwnedToolEndHandler, resolveRecursionLimit, getRemoteAgentPermissions, resolveAgentScopedSkillIds, @@ -1155,7 +1156,7 @@ const executeResponse = async (envelope, { req, res }) => { } }, }, - on_tool_end: new ToolEndHandler(toolEndCallback, logger), + on_tool_end: createOwnedToolEndHandler(toolEndCallback, logger), on_run_step_completed: { handle: () => {} }, on_chain_stream: { handle: () => {} }, on_chain_end: { handle: () => {} }, @@ -1368,7 +1369,7 @@ const executeResponse = async (envelope, { req, res }) => { } }, }, - on_tool_end: new ToolEndHandler(toolEndCallback, logger), + on_tool_end: createOwnedToolEndHandler(toolEndCallback, logger), on_run_step_completed: { handle: () => {} }, on_chain_stream: { handle: () => {} }, on_chain_end: { handle: () => {} }, diff --git a/api/server/services/Tools/search.js b/api/server/services/Tools/search.js index 12091db173..e0ded1268b 100644 --- a/api/server/services/Tools/search.js +++ b/api/server/services/Tools/search.js @@ -35,6 +35,7 @@ function createOnSearchResults(res, streamId = null, jobCreatedAt) { searchResultData: undefined, toolCallId: undefined, agentId: undefined, + stepId: undefined, attachmentName: undefined, messageId: undefined, conversationId: undefined, @@ -84,7 +85,8 @@ function createOnSearchResults(res, streamId = null, jobCreatedAt) { } context.toolCallId = runnableConfig.toolCall.id; - context.agentId = runnableConfig.metadata.agent_id; + context.agentId = runnableConfig.metadata.agent_id ?? runnableConfig.metadata.agentId; + context.stepId = runnableConfig.toolCall?.stepId; context.messageId = runnableConfig.metadata.run_id; context.conversationId = runnableConfig.metadata.thread_id; context.attachmentName = `${runnableConfig.toolCall.name}_${context.toolCallId}_${nanoid()}`; @@ -135,6 +137,7 @@ function buildAttachment(context) { messageId: context.messageId, toolCallId: context.toolCallId, agentId: context.agentId, + stepId: context.stepId, conversationId: context.conversationId, name: context.attachmentName, type: Tools.web_search, diff --git a/api/server/services/Tools/search.spec.js b/api/server/services/Tools/search.spec.js index beb6bfaa05..adb864ab7b 100644 --- a/api/server/services/Tools/search.spec.js +++ b/api/server/services/Tools/search.spec.js @@ -28,6 +28,7 @@ describe('createOnSearchResults', () => { toolCall: { id: 'tool-call-1', name: 'web_search', + stepId: 'step-search-1', turn: 0, }, }; @@ -53,6 +54,7 @@ describe('createOnSearchResults', () => { data: expect.objectContaining({ messageId: 'response-1', toolCallId: 'tool-call-1', + stepId: 'step-search-1', conversationId: 'conversation-1', }), }, diff --git a/client/src/components/Chat/ChatView.tsx b/client/src/components/Chat/ChatView.tsx index c3d58aa221..c627748356 100644 --- a/client/src/components/Chat/ChatView.tsx +++ b/client/src/components/Chat/ChatView.tsx @@ -17,6 +17,7 @@ import { import { ChatContext, AddedChatContext, ChatFormProvider, useFileMapContext } from '~/Providers'; import ConversationStarters from './Input/ConversationStarters'; import { useGetMessagesByConvoId } from '~/data-provider'; +import { AskAnswerHostProvider } from './ask/state'; import MessagesView from './Messages/MessagesView'; import Presentation from './Presentation'; import ChatForm from './Input/ChatForm'; @@ -41,6 +42,7 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje const localize = useLocalize(); const rootSubmission = useRecoilValue(store.submissionByIndex(index)); const isSubmitting = useRecoilValue(store.isSubmittingFamily(index)); + const saveDrafts = useRecoilValue(store.saveDrafts); const centerFormOnLanding = useRecoilValue(store.centerFormOnLanding); const methods = useForm({ @@ -129,63 +131,65 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje const isSubagentThreadReadOnly = activeSubagentThread != null; return ( - - - - -
-

{pageHeading}

-
- <> -
- {content} - {/* Named + opaque so a view transition (the ask_user_question - popover ⇄ chat-card morph) paints the whole composer band - over the travelling card instead of letting it show - through below the composer. The background matches the - page, so normal rendering is unchanged. */} + + + + + +
+

{pageHeading}

+
+ <>
- {isLandingPage && } - {isSubagentThreadReadOnly ? ( -
- {localize('com_ui_subagent_thread_read_only')} -
- ) : ( - - )} - {!isLandingPage &&
} + {content} + {/* Named + opaque so a view transition (the ask_user_question + popover ⇄ chat-card morph) paints the whole composer band + over the travelling card instead of letting it show + through below the composer. The background matches the + page, so normal rendering is unchanged. */} +
+ {isLandingPage && } + {isSubagentThreadReadOnly ? ( +
+ {localize('com_ui_subagent_thread_read_only')} +
+ ) : ( + + )} + {!isLandingPage &&
} +
-
- {isLandingPage &&
} - -
- - - - + {isLandingPage &&
} + +
+
+
+
+
+ ); } diff --git a/client/src/components/Chat/Messages/Content/ApprovalContext.tsx b/client/src/components/Chat/Messages/Content/ApprovalContext.tsx index 6c0e2ee8ed..eb4ce6bf13 100644 --- a/client/src/components/Chat/Messages/Content/ApprovalContext.tsx +++ b/client/src/components/Chat/Messages/Content/ApprovalContext.tsx @@ -1,49 +1,32 @@ import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'; +import { useAtom } from 'jotai'; +import { useRecoilValue } from 'recoil'; import { Constants } from 'librechat-data-provider'; -import { atom, useRecoilState, useRecoilValue } from 'recoil'; import type { Agents } from 'librechat-data-provider'; +import type { AskAnswerStatus } from '~/components/Chat/ask/state'; import { useSubmitToolApprovalMutation, useSubmitAskAnswerMutation, type ResumeAgentFields, } from '~/data-provider'; +import { askSubmitStatusAtom } from '~/components/Chat/ask/state'; import { resolveAskUserQuestionPart } from '~/utils/approval'; import { ChatContext } from '~/Providers/ChatContext'; import { useGetEphemeralAgent } from '~/store/agents'; import store from '~/store'; -/** Per-action submission lifecycle, surfaced to the cards so they can disable - * controls and explain a terminal outcome. */ -type ActionStatus = 'idle' | 'submitting' | 'submitted' | 'expired' | 'error'; - -/** - * Ask-answer submit status keyed by `actionId`. This lives in Recoil, NOT the - * {@link ApprovalContext} React state, because the PRIMARY answer surface is - * the composer in `ChatForm` — which renders OUTSIDE `ApprovalProvider` (that - * only wraps message content). A React context read there degrades to the - * inert {@link FALLBACK}, so an in-flight lock / expired status tracked in the - * context would never engage for the composer. A global atom is visible to - * both the composer (`useAskAnswerMode`) and the message-content card - * (`AskUserQuestion`), so a fast double-submit is actually blocked and a - * terminal (expired/error) state surfaces on either surface. - */ -const askSubmitStatusAtom = atom>({ - key: 'askAnswerSubmitStatus', - default: {}, -}); - /** Shared read/write for the ask-answer submit status. */ export function useAskSubmitStatus(): { - getAskStatus: (actionId: string) => ActionStatus; - setAskStatus: (actionId: string, status: ActionStatus) => void; + getAskStatus: (actionId: string) => AskAnswerStatus; + setAskStatus: (actionId: string, status: AskAnswerStatus) => void; } { - const [statusMap, setStatusMap] = useRecoilState(askSubmitStatusAtom); + const [statusMap, setStatusMap] = useAtom(askSubmitStatusAtom); const getAskStatus = useCallback( - (actionId: string): ActionStatus => statusMap[actionId] ?? 'idle', + (actionId: string): AskAnswerStatus => statusMap[actionId] ?? 'idle', [statusMap], ); const setAskStatus = useCallback( - (actionId: string, status: ActionStatus) => + (actionId: string, status: AskAnswerStatus) => setStatusMap((prev) => ({ ...prev, [actionId]: status })), [setStatusMap], ); @@ -81,9 +64,9 @@ interface ApprovalContextValue { /** True once every registered tool_call in the action has a decision. */ isReady: (actionId: string) => boolean; /** Lifecycle status for an action (so cards can disable / show messages). */ - getStatus: (actionId: string) => ActionStatus; + getStatus: (actionId: string) => AskAnswerStatus; /** Set an action's submission status (driven by the cards' submit via `useResumeSubmit`). */ - setStatus: (actionId: string, status: ActionStatus) => void; + setStatus: (actionId: string, status: AskAnswerStatus) => void; /** Restore a free-form question answer after transient phase-slice remounts. */ getAskAnswerDraft: (actionId: string) => string; /** Retain a free-form question answer for this response message's lifetime. */ @@ -145,7 +128,7 @@ export default function ApprovalProvider({ children }: { children: React.ReactNo const askAnswerDraftsRef = useRef(new Map()); const [version, bump] = useState(0); const rerender = useCallback(() => bump((v) => v + 1), []); - const [statusByAction, setStatusByAction] = useState>({}); + const [statusByAction, setStatusByAction] = useState>({}); const registerToolCall = useCallback( (actionId: string, toolCallId: string) => { @@ -238,11 +221,11 @@ export default function ApprovalProvider({ children }: { children: React.ReactNo }, []); const getStatus = useCallback( - (actionId: string): ActionStatus => statusByAction[actionId] ?? 'idle', + (actionId: string): AskAnswerStatus => statusByAction[actionId] ?? 'idle', [statusByAction], ); - const setStatus = useCallback((actionId: string, status: ActionStatus) => { + const setStatus = useCallback((actionId: string, status: AskAnswerStatus) => { setStatusByAction((prev) => ({ ...prev, [actionId]: status })); }, []); @@ -319,7 +302,7 @@ export function useResumeSubmit() { * synchronous action-id guard alongside the rendered submission status. */ const submittingToolActionIdsRef = useRef(new Set()); const submittingAskActionIdsRef = useRef(new Set()); - /** Ask status lives in Recoil so it works from the composer (outside the + /** Ask status lives in Jotai so it works from the composer (outside the * provider); tool-approval status stays on the context. */ const { setAskStatus } = useAskSubmitStatus(); const activeGenerationCreatedAt = useRecoilValue( diff --git a/client/src/components/Chat/Messages/Content/MemoryInfo.tsx b/client/src/components/Chat/Messages/Content/MemoryInfo.tsx index 003b6c3b0b..c81d02f895 100644 --- a/client/src/components/Chat/Messages/Content/MemoryInfo.tsx +++ b/client/src/components/Chat/Messages/Content/MemoryInfo.tsx @@ -78,9 +78,9 @@ export default function MemoryInfo({ memoryArtifacts }: { memoryArtifacts: Memor {errorMessages.length > 0 && (
-

+

{localize('com_ui_memory_storage_full')} -

+

{errorMessages.map((errorMessage) => ( diff --git a/client/src/components/Chat/Messages/Content/Parts/MemoryCall.tsx b/client/src/components/Chat/Messages/Content/Parts/MemoryCall.tsx index 8c41160315..9b67b57e81 100644 --- a/client/src/components/Chat/Messages/Content/Parts/MemoryCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/MemoryCall.tsx @@ -1,12 +1,13 @@ import { useMemo } from 'react'; import { Brain } from 'lucide-react'; import { Tools } from 'librechat-data-provider'; -import type { PartMetadata, TAttachment } from 'librechat-data-provider'; +import type { PartMetadata, TAttachment, MemoryArtifact } from 'librechat-data-provider'; import ProgressText from '~/components/Chat/Messages/Content/ProgressText'; import { toolPanelSpacingClassName } from '../disclosure'; import useToolCallState from './useToolCallState'; import { AttachmentGroup } from './Attachment'; import parseJsonField from './parseJsonField'; +import MemoryInfo from '../MemoryInfo'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; @@ -50,12 +51,17 @@ export default function MemoryCall({ const memoryKey = useMemo(() => parseJsonField(args, 'key'), [args]); const memoryValue = useMemo(() => parseJsonField(args, 'value'), [args]); const hasPanel = !!memoryKey || !!memoryValue; - const memoryFailed = useMemo( - () => - isMemoryFailureOutput(toolName, output) || - (attachments?.some((attachment) => attachment?.[Tools.memory]?.type === 'error') ?? false), - [attachments, toolName, output], - ); + const memoryErrors = useMemo(() => { + const errors: MemoryArtifact[] = []; + for (const attachment of attachments ?? []) { + const artifact = attachment[Tools.memory]; + if (artifact?.type === 'error') { + errors.push(artifact); + } + } + return errors; + }, [attachments]); + const memoryFailed = isMemoryFailureOutput(toolName, output) || memoryErrors.length > 0; const { showCode, toggleCode, expandStyle, expandRef, phase } = useToolCallState({ initialProgress, @@ -106,9 +112,12 @@ export default function MemoryCall({ )} > {phase === 'failed' ? ( -
-                  {output}
-                
+ <> +
+                    {output}
+                  
+ + ) : ( <> {memoryKey && ( diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/MemoryCall.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/MemoryCall.test.tsx index fc125e671a..7293aba696 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/MemoryCall.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/MemoryCall.test.tsx @@ -1,11 +1,13 @@ import React from 'react'; +import { Tools } from 'librechat-data-provider'; import { render, screen } from '@testing-library/react'; +import type { TAttachment } from 'librechat-data-provider'; import MemoryCall from '../MemoryCall'; jest.mock('~/hooks', () => ({ useLocalize: () => - (key: string): string => { + (key: string, params?: { tokens?: number }): string => { const translations: Record = { com_ui_memory_saving: 'Saving memory', com_ui_memory_saved: 'Saved memory', @@ -15,6 +17,7 @@ jest.mock('~/hooks', () => ({ com_ui_memory: 'Memory', com_ui_cancelled: 'Cancelled', com_ui_tool_failed: 'failed', + com_ui_memory_would_exceed: `Cannot save: remove ${params?.tokens} tokens to make space.`, }; return translations[key] ?? key; }, @@ -167,4 +170,33 @@ describe('MemoryCall', () => { expect(screen.getByText(/Memory set for key/)).toBeInTheDocument(); expect(screen.queryByText('requested value')).not.toBeInTheDocument(); }); + + it('shows the actionable overage from a linked memory error artifact', () => { + const attachment: TAttachment = { + type: Tools.memory, + toolCallId: 'call_0', + messageId: 'message-1', + [Tools.memory]: { + type: 'error', + key: 'preferences', + value: JSON.stringify({ errorType: 'would_exceed', tokenCount: 47 }), + }, + }; + render( + , + ); + + expect(screen.getByRole('alert')).toHaveTextContent( + 'Cannot save: remove 47 tokens to make space.', + ); + expect(screen.queryByText('requested value')).not.toBeInTheDocument(); + }); }); diff --git a/client/src/components/Chat/Messages/Content/WebSearch.tsx b/client/src/components/Chat/Messages/Content/WebSearch.tsx index f882b9e778..f462563805 100644 --- a/client/src/components/Chat/Messages/Content/WebSearch.tsx +++ b/client/src/components/Chat/Messages/Content/WebSearch.tsx @@ -169,7 +169,7 @@ export default function WebSearch({ }, [attachments]); const allSources = useMemo((): ValidSource[] => { - if (attachments) { + if (attachments != null) { const turnMap: Record = {}; for (const att of attachments) { if (att.type === Tools.web_search && att[Tools.web_search]) { @@ -178,9 +178,7 @@ export default function WebSearch({ turnMap[key] = data; } } - if (Object.keys(turnMap).length > 0) { - return collectSources(turnMap); - } + return collectSources(turnMap); } if (searchResults?.[ownTurn]) { return collectSources({ [ownTurn]: searchResults[ownTurn] }); @@ -218,12 +216,8 @@ export default function WebSearch({ if (complete && !finalizing) { return []; } - const result = searchResults?.[ownTurn]; - if (!result) { - return []; - } - return [...(result.organic || []), ...(result.topStories || [])]; - }, [searchResults, complete, finalizing, ownTurn]); + return allSources; + }, [allSources, complete, finalizing]); const showSources = streamingSources.length > 0; /** Stable phase text: the live region must not re-announce the growing diff --git a/client/src/components/Chat/Messages/Content/__tests__/ApprovalContext.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ApprovalContext.test.tsx index fae45878a2..bbfee64788 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/ApprovalContext.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/ApprovalContext.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { RecoilRoot } from 'recoil'; import { act, renderHook } from '@testing-library/react'; +import { Provider as JotaiProvider, createStore } from 'jotai'; import type { MutableSnapshot } from 'recoil'; import ApprovalProvider, { useApprovalContext, useResumeSubmit } from '../ApprovalContext'; import { ChatContext } from '~/Providers/ChatContext'; @@ -28,6 +29,7 @@ const chatContextValue = { const createWrapper = (generationCreatedAt: number | null) => function ResumeWrapper({ children }: { children: React.ReactNode }) { + const jotaiStore = React.useRef(createStore()).current; const initializeState = (snapshot: MutableSnapshot) => { if (generationCreatedAt != null) { snapshot.set( @@ -38,9 +40,11 @@ const createWrapper = (generationCreatedAt: number | null) => }; return ( - - {children} - + + + {children} + + ); }; diff --git a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.integration.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.integration.test.tsx index a88cc5cda2..c9bd3f1228 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.integration.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.integration.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { RecoilRoot } from 'recoil'; -import { ContentTypes } from 'librechat-data-provider'; +import { ContentTypes, Tools } from 'librechat-data-provider'; import { fireEvent, render, screen, within } from '@testing-library/react'; import type { TAttachment, TMessageContentParts } from 'librechat-data-provider'; import ContentParts from '../ContentParts'; @@ -90,6 +90,9 @@ jest.mock('../Parts', () => ({ Reasoning: () =>
, Summary: () =>
, Text: ({ text }: { text?: string }) =>
{text}
, + MemoryCall: ({ attachments }: { attachments?: TAttachment[] }) => ( +
+ ), })); jest.mock('../MemoryArtifacts', () => ({ @@ -99,7 +102,9 @@ jest.mock('../MemoryArtifacts', () => ({ jest.mock('../WebSearch', () => ({ __esModule: true, - default: () =>
, + default: ({ attachments }: { attachments?: TAttachment[] }) => ( +
+ ), })); jest.mock('../RetrievalCall', () => ({ @@ -163,6 +168,24 @@ const makeMcpToolCall = ( }, }) as unknown as TMessageContentParts; +const makeOwnedToolCall = ( + name: string, + id: string, + stepId?: string, + agentId?: string, +): TMessageContentParts => + ({ + type: ContentTypes.TOOL_CALL, + [ContentTypes.TOOL_CALL]: { + id, + name, + args: '{}', + output: 'completed', + ...(stepId == null ? {} : { stepId }), + ...(agentId == null ? {} : { agentId }), + }, + }) as unknown as TMessageContentParts; + const makeMcpToolCallWithoutId = (name: string, hasOutput = true): TMessageContentParts => ({ type: ContentTypes.TOOL_CALL, @@ -305,6 +328,75 @@ describe('ContentParts integration: MCP image hoist and grouping', () => { expect(groups[0]).toHaveAttribute('data-count', '1'); }); + it('routes producer-shaped search snapshots by agent and host step', () => { + const content = [ + makeOwnedToolCall(Tools.web_search, 'call_0', 'step-search-1', 'agent-a'), + makeTextPart('between searches'), + makeOwnedToolCall(Tools.web_search, 'call_0', undefined, 'agent-a'), + ]; + const attachments = [ + { + type: Tools.web_search, + messageId: 'm1', + toolCallId: 'call_0', + agentId: 'agent-a', + stepId: 'step-search-1', + conversationId: 'c1', + [Tools.web_search]: { turn: 0, organic: [{ link: 'https://first.example' }] }, + }, + { + type: Tools.web_search, + messageId: 'm1', + toolCallId: 'call_0', + agentId: 'agent-a', + stepId: 'step-search-2', + conversationId: 'c1', + [Tools.web_search]: { turn: 0, organic: [{ link: 'https://second.example' }] }, + }, + ] as unknown as TAttachment[]; + + renderContentParts({ ...baseProps, content, attachments }); + + expect(screen.getAllByTestId('web-search').map((card) => card.dataset.count)).toEqual([ + '1', + '1', + ]); + }); + + it('routes producer-shaped memory artifacts by agent and host step', () => { + const content = [ + makeOwnedToolCall('set_memory', 'call_0', 'step-memory-1', 'agent-a'), + makeTextPart('between memory writes'), + makeOwnedToolCall('set_memory', 'call_0', undefined, 'agent-a'), + ]; + const attachments = [ + { + type: Tools.memory, + messageId: 'm1', + toolCallId: 'call_0', + agentId: 'agent-a', + stepId: 'step-memory-1', + conversationId: 'c1', + [Tools.memory]: { key: 'first', type: 'update' }, + }, + { + type: Tools.memory, + messageId: 'm1', + toolCallId: 'call_0', + agentId: 'agent-a', + stepId: 'step-memory-2', + conversationId: 'c1', + [Tools.memory]: { key: 'second', type: 'update' }, + }, + ] as unknown as TAttachment[]; + + renderContentParts({ ...baseProps, content, attachments }); + + expect(screen.getAllByTestId('memory-call').map((card) => card.dataset.count)).toEqual([ + '1', + '1', + ]); + }); it('keeps a manually expanded completed tool group open when its content index shifts', () => { const content = [makeMcpToolCall('t1'), makeMcpToolCall('t2')]; const nextContent = [makeTextPart('streamed preface'), ...content]; diff --git a/client/src/components/Chat/Messages/Content/__tests__/MemoryInfo.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/MemoryInfo.test.tsx index 1e19c5af9d..5efbc54d54 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/MemoryInfo.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/MemoryInfo.test.tsx @@ -97,28 +97,6 @@ describe('MemoryInfo', () => { ), ).toBeInTheDocument(); }); - - test('applies correct styling to error messages', () => { - const memoryArtifacts: MemoryArtifact[] = [ - { - type: 'error', - key: 'system', - value: JSON.stringify({ errorType: 'would_exceed', tokenCount: 50 }), - }, - ]; - - render(); - - const errorMessage = screen.getByText( - 'Cannot save - would exceed limit by 50 tokens. Delete existing memories to make space.', - ); - const errorContainer = errorMessage.closest('[role="alert"]'); - - expect(errorContainer).toHaveClass('bg-status-error-subtle'); - expect(errorContainer).toHaveClass('text-status-error'); - expect(errorContainer).toHaveClass('border-status-error-border'); - expect(errorContainer).toHaveClass('p-3'); - }); }); describe('Mixed Memory Types', () => { diff --git a/client/src/components/Chat/Messages/Content/__tests__/WebSearch.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/WebSearch.test.tsx index 1ebc4cc1ff..f83b765430 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/WebSearch.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/WebSearch.test.tsx @@ -220,6 +220,15 @@ describe('WebSearch', () => { expect(hrefs).toContain('https://pi.example.com/2'); expect(hrefs).not.toContain('https://neutrino.example.com/1'); }); + + it('does not fall back to another execution when filtered attachments are empty', () => { + renderWebSearch({ + searchResults, + attachments: [], + }); + + expect(screen.getByRole('button', { name: 'Searched the web' })).toBeDisabled(); + }); }); describe('streaming favicons', () => { @@ -316,6 +325,7 @@ describe('WebSearch', () => { } as unknown as TAttachment; renderWebSearch({ attachments: [attachment] }); + fireEvent.click(screen.getByRole('button', { name: /Searched the web/ })); expect( screen.getByText('How context windows change what assistants can do.'), diff --git a/client/src/components/Chat/Messages/Content/verticals.tsx b/client/src/components/Chat/Messages/Content/verticals.tsx index 0eb318de59..6b3523147d 100644 --- a/client/src/components/Chat/Messages/Content/verticals.tsx +++ b/client/src/components/Chat/Messages/Content/verticals.tsx @@ -149,7 +149,10 @@ function ShoppingStrip({ items, label }: { items: ShoppingResult[]; label: strin return (