fix(client): stop stacking search snapshots and fix three identity defects

Upsert re-emitted web-search attachments instead of appending them. The search
helper rewrites and re-emits the entire payload after every highlight update,
keeping the same type, toolCallId and turn, and those attachments carry no
file_id so the existing upsert never saw them. Each snapshot was appended, so
collectSearchVerticals concatenated one copy of every image, product and place
per update. Keyed by type, tool call and turn, replacing wholesale since the
incoming payload is complete.

Preserve MCP identity in the tool-call label. function_name has already had the
MCP delimiter and server stripped, so re-parsing it classified an MCP function
sharing a built-in's name as that native tool and both showed and announced an
unrelated label.

Qualify grouped tool identity by agent. Provider tool-call ids repeat across
agents in a handoff transcript, and a lone tool with reasoning now groups too,
so two sibling groups shared one React key and one expansion override.

Compose the shared Button for the handoff disclosure, keeping only its geometry
local.
This commit is contained in:
Marco Beretta 2026-08-28 23:41:14 +02:00
parent f84750ef22
commit 8aab624308
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
4 changed files with 92 additions and 17 deletions

View file

@ -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<AgentHandoffProps> = ({ name, args: _args = '' }) =
return (
<div className="my-2">
<button
type="button"
<Button
variant="ghost"
className={cn(
'tool-status-text flex appearance-none items-center gap-2 bg-transparent text-text-secondary',
hasInfo
? 'transition-colors hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy'
: 'pointer-events-none',
'tool-status-text h-auto justify-start gap-2 rounded-none p-0 font-normal text-text-secondary hover:bg-transparent hover:text-text-primary',
!hasInfo && 'pointer-events-none disabled:opacity-100',
)}
disabled={!hasInfo}
onClick={hasInfo ? () => setShowInfo(!showInfo) : undefined}
@ -176,7 +175,7 @@ const AgentHandoff: React.FC<AgentHandoffProps> = ({ name, args: _args = '' }) =
aria-hidden="true"
/>
)}
</button>
</Button>
<div id={contentId} style={expandStyle} aria-hidden={!showInfo || undefined}>
<div className="overflow-hidden" ref={expandRef}>
{hasInfo && (

View file

@ -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;

View file

@ -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

View file

@ -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],