diff --git a/client/src/components/Chat/Messages/Content/AgentHandoff.tsx b/client/src/components/Chat/Messages/Content/AgentHandoff.tsx index 21f58cb900..b3ac9f3efb 100644 --- a/client/src/components/Chat/Messages/Content/AgentHandoff.tsx +++ b/client/src/components/Chat/Messages/Content/AgentHandoff.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useId, useMemo, useRef, useState, useCallback } from 'react'; import copy from 'copy-to-clipboard'; import { ChevronDown } from 'lucide-react'; +import { Button } from '@librechat/client'; import { EModelEndpoint, Constants } from 'librechat-data-provider'; import type { TMessage } from 'librechat-data-provider'; import CopyButton from '~/components/Messages/Content/CopyButton'; @@ -140,13 +141,11 @@ const AgentHandoff: React.FC = ({ name, args: _args = '' }) = return (
- +
{hasInfo && ( diff --git a/client/src/components/Chat/Messages/Content/ToolCall.tsx b/client/src/components/Chat/Messages/Content/ToolCall.tsx index 8a3ecf2675..d497513582 100644 --- a/client/src/components/Chat/Messages/Content/ToolCall.tsx +++ b/client/src/components/Chat/Messages/Content/ToolCall.tsx @@ -12,9 +12,9 @@ import type { TAttachment, PartMetadata } from 'librechat-data-provider'; import { useLocalize, useProgress, useExpandCollapse, useLazyCollapseBody } from '~/hooks'; import { ToolIcon, getToolIconType, isError } from './ToolOutput'; import { useMCPIconMap, useMCPServerNames } from '~/hooks/MCP'; +import { resolveToolCallPhase } from '~/utils/toolCallPhase'; import { cn, getToolDisplayLabel, logger } from '~/utils'; import { toolPanelSpacingClassName } from './disclosure'; -import { resolveToolCallPhase } from '~/utils/toolCallPhase'; import { useToolCallIntent } from './Parts/intent'; import { AttachmentGroup } from './Parts'; import ToolCallInfo from './ToolCallInfo'; @@ -119,8 +119,13 @@ export default function ToolCall({ const toolIconType = useMemo(() => getToolIconType(name), [name]); const displayFunctionName = useMemo( - () => getToolDisplayLabel(function_name, localize, mcpServerNames), - [function_name, localize, mcpServerNames], + () => + /** `function_name` has already had the MCP delimiter and server stripped + * above, so re-parsing it would classify an MCP function that happens to + * share a built-in's name (`read_file`, `set_memory`) as that native + * tool and show, and announce, an unrelated label. */ + isMCPToolCall ? function_name : getToolDisplayLabel(function_name, localize, mcpServerNames), + [function_name, isMCPToolCall, localize, mcpServerNames], ); const mcpIconMap = useMCPIconMap(); const mcpIconUrl = isMCPToolCall ? mcpIconMap.get(mcpServerName) : undefined; diff --git a/client/src/hooks/SSE/__tests__/useAttachmentHandler.spec.tsx b/client/src/hooks/SSE/__tests__/useAttachmentHandler.spec.tsx index a23abe41fa..ef2cffb2eb 100644 --- a/client/src/hooks/SSE/__tests__/useAttachmentHandler.spec.tsx +++ b/client/src/hooks/SSE/__tests__/useAttachmentHandler.spec.tsx @@ -127,23 +127,61 @@ describe('useAttachmentHandler upsert-by-file_id', () => { ]); }); - it('appends (does NOT merge) attachments with no file_id', () => { - /* Lightweight attachments like file_search citations and web_search - * results don't carry file_id. The handler must keep its legacy - * append behavior for them — merging would lose distinct citations - * and is unnecessary because they're never the target of a - * deferred preview update. */ + it('appends (does NOT merge) citation attachments with no file_id', () => { + /* Lightweight attachments like file_search citations don't carry file_id. + * The handler must keep its legacy append behavior for them: merging + * would lose distinct citations and is unnecessary because they're never + * the target of a deferred preview update. */ const ctx = setup(); const noFileId = { messageId, toolCallId: 'tc-1', - type: Tools.web_search, + type: Tools.file_search, } as unknown as TAttachment; ctx.handle(noFileId); ctx.handle(noFileId); expect(ctx.list).toHaveLength(2); }); + it('replaces a re-emitted web-search snapshot instead of stacking it', () => { + /* The search helper rewrites and re-emits the WHOLE payload after every + * highlight update, keeping the same toolCallId and turn. Appending those + * stacked one copy of every image, product and place per update. */ + const ctx = setup(); + const snapshot = (processed: boolean) => + ({ + messageId, + toolCallId: 'tc-1', + type: Tools.web_search, + [Tools.web_search]: { turn: 0, organic: [{ link: 'https://example.com', processed }] }, + }) as unknown as TAttachment; + + ctx.handle(snapshot(false)); + ctx.handle(snapshot(true)); + + expect(ctx.list).toHaveLength(1); + expect( + (ctx.list[0] as unknown as { web_search: { organic: Array<{ processed: boolean }> } }) + .web_search.organic[0].processed, + ).toBe(true); + }); + + it('keeps web-search snapshots from distinct tool calls separate', () => { + const ctx = setup(); + const snapshot = (toolCallId: string) => + ({ + messageId, + toolCallId, + type: Tools.web_search, + [Tools.web_search]: { turn: 0 }, + }) as unknown as TAttachment; + + ctx.handle(snapshot('tc-1')); + ctx.handle(snapshot('tc-2')); + + expect(ctx.list).toHaveLength(2); + }); + it('keeps sibling tool calls separate when they share a file_id (distinct toolCallIds)', () => { /* Two background code calls regenerated the same filename — same * claimed file_id, different toolCallId. Each card anchors its own diff --git a/client/src/hooks/SSE/useAttachmentHandler.ts b/client/src/hooks/SSE/useAttachmentHandler.ts index 2431fcb96a..4b735ec325 100644 --- a/client/src/hooks/SSE/useAttachmentHandler.ts +++ b/client/src/hooks/SSE/useAttachmentHandler.ts @@ -10,6 +10,21 @@ import type { QueryClient } from '@tanstack/react-query'; import { handleMemoryArtifact } from '~/utils/memory'; import store from '~/store'; +/** Identity of a re-emitted web-search snapshot: its tool call plus the turn + * inside that call. Scoped to `web_search` because that is the flow whose + * server helper rebuilds and rewrites the whole payload on each highlight. */ +const searchSnapshotKeyOf = (attachment: TAttachment): string | undefined => { + if (attachment.type !== Tools.web_search) { + return undefined; + } + const toolCallId = (attachment as { toolCallId?: string }).toolCallId; + if (!toolCallId) { + return undefined; + } + const turn = attachment[Tools.web_search]?.turn; + return `${Tools.web_search}:${toolCallId}:${turn ?? 0}`; +}; + export default function useAttachmentHandler(queryClient?: QueryClient) { const setAttachmentsMap = useSetRecoilState(store.messageAttachmentsMap); @@ -141,6 +156,24 @@ export default function useAttachmentHandler(queryClient?: QueryClient) { return { ...prevMap, [messageId]: merged }; } } + /** Search attachments are full snapshots rather than deltas: the server + * re-emits the entire payload after every highlight update, keeping the + * same type, toolCallId and turn. They carry no `file_id`, so the upsert + * above never saw them and each snapshot was appended, stacking one copy + * of every image, product and place per update. Replace the previous + * snapshot in place; a wholesale swap is right because the incoming + * payload is complete. */ + const searchSnapshotKey = searchSnapshotKeyOf(data); + if (searchSnapshotKey) { + const existingIndex = messageAttachments.findIndex( + (a) => searchSnapshotKeyOf(a) === searchSnapshotKey, + ); + if (existingIndex > -1) { + const merged = [...messageAttachments]; + merged[existingIndex] = data; + return { ...prevMap, [messageId]: merged }; + } + } return { ...prevMap, [messageId]: [...messageAttachments, data],