diff --git a/client/src/components/Chat/Messages/Content/ParallelContent.tsx b/client/src/components/Chat/Messages/Content/ParallelContent.tsx index b1d75c86ef..d0bad2301b 100644 --- a/client/src/components/Chat/Messages/Content/ParallelContent.tsx +++ b/client/src/components/Chat/Messages/Content/ParallelContent.tsx @@ -1,5 +1,7 @@ import { memo, useMemo } from 'react'; +import { ContentTypes } from 'librechat-data-provider'; import type { TMessageContentParts, SearchResultData, TAttachment } from 'librechat-data-provider'; +import { getActivityLabelPart, getActivityLabelText } from '~/utils/activityLabels'; import MemoryArtifacts from './MemoryArtifacts'; import Sources from '~/components/Web/Sources'; import { SearchContext } from '~/Providers'; @@ -159,7 +161,17 @@ export const ParallelColumns = memo(function ParallelColumns({ }: ParallelColumnsProps) { return (
- {columns.map(({ agentId, parts: columnParts }, colIdx) => { + {columns.map(({ agentId, parts: allColumnParts }, colIdx) => { + /** Lanes render raw parts, so an activity label cannot become a + * collapsible header here (tracked separately). An UNFILLED one has + * nothing to render at all, and every batch now publishes its + * reservation immediately — so drop empty labels rather than emit a + * blank line into the column while generation is pending. */ + const columnParts = allColumnParts.filter( + ({ part }) => + part?.type !== ContentTypes.ACTIVITY_LABEL || + getActivityLabelText(getActivityLabelPart(part)).length > 0, + ); // Show loading cursor if column has no content parts yet (empty array from placeholder) const showLoadingCursor = isSubmitting && columnParts.length === 0; diff --git a/client/src/hooks/SSE/useResumableSSE.ts b/client/src/hooks/SSE/useResumableSSE.ts index eb816fda12..3d2a20c0a0 100644 --- a/client/src/hooks/SSE/useResumableSSE.ts +++ b/client/src/hooks/SSE/useResumableSSE.ts @@ -810,9 +810,16 @@ export default function useResumableSSE( * the run starts, and the server indexes only the NEW content — so * run steps offset by that prefix (`useStepHandler`). The label index * is claimed in the same server-side space and needs the identical - * shift, or it lands inside the prefix and overwrites kept content. */ + * shift, or it lands inside the prefix and overwrites kept content. + * + * NOT on a resume: the sync replaces `initialResponse.content` with + * the server's `aggregatedContent`, which already contains the prefix + * AND everything generated since. Its length is not the prefix + * length, and the indices reconciled from that snapshot are already + * absolute — offsetting again would push the label past its slot and + * overwrite a later part. */ const initialContent = - currentSubmission.editedContent != null + !isResume && currentSubmission.editedContent != null ? ((currentSubmission.initialResponse as TMessage | undefined)?.content ?? []) : []; const offsetEvent = diff --git a/client/src/utils/__tests__/groupToolCalls.test.ts b/client/src/utils/__tests__/groupToolCalls.test.ts new file mode 100644 index 0000000000..983c1c7159 --- /dev/null +++ b/client/src/utils/__tests__/groupToolCalls.test.ts @@ -0,0 +1,79 @@ +import { ContentTypes } from 'librechat-data-provider'; +import type { TMessageContentParts } from 'librechat-data-provider'; +import type { PartWithIndex } from '~/components/Chat/Messages/Content/ParallelContent'; +import { groupSequentialToolCalls } from '../groupToolCalls'; + +const toolCall = (id: string): TMessageContentParts => + ({ + type: ContentTypes.TOOL_CALL, + [ContentTypes.TOOL_CALL]: { id, name: 'web_search', args: '{}', output: 'ok' }, + }) as unknown as TMessageContentParts; + +const think = (text: string): TMessageContentParts => + ({ type: ContentTypes.THINK, [ContentTypes.THINK]: text }) as unknown as TMessageContentParts; + +const label = (text: string): TMessageContentParts => + ({ + type: ContentTypes.ACTIVITY_LABEL, + [ContentTypes.ACTIVITY_LABEL]: text, + pending: text.length === 0, + }) as unknown as TMessageContentParts; + +const withIndex = (parts: TMessageContentParts[]): PartWithIndex[] => + parts.map((part, idx) => ({ part, idx })); + +describe('groupSequentialToolCalls with activity labels', () => { + /** + * Every batch publishes its reservation the moment the batch ends, so an + * empty label is the NORMAL state while generation is in flight. It must + * render exactly as the feature-off path does: a lone tool call stays a + * single (no group wrapper) and THINK parts stay standalone. + */ + it('leaves a single tool call and its reasoning untouched while the label is empty', () => { + const grouped = groupSequentialToolCalls( + withIndex([think('deciding what to search'), toolCall('t1'), label('')]), + ); + + expect(grouped).toHaveLength(2); + expect(grouped[0]).toMatchObject({ type: 'single' }); + expect(grouped[1]).toMatchObject({ type: 'single' }); + /** No labelPart anywhere, and crucially no 'tool-group' wrapper. */ + expect(grouped.some((entry) => entry.type === 'tool-group')).toBe(false); + }); + + /** A blank/failed fill is permanent, and must stay equally invisible. */ + it('keeps legacy splitting for two tool calls when the label never fills', () => { + const grouped = groupSequentialToolCalls( + withIndex([toolCall('t1'), toolCall('t2'), label('')]), + ); + + /** Two adjacent tool calls group even without the feature. */ + expect(grouped).toHaveLength(1); + expect(grouped[0]).toMatchObject({ type: 'tool-group' }); + expect((grouped[0] as { labelPart?: PartWithIndex }).labelPart).toBeUndefined(); + }); + + /** Once real text lands the block becomes one labeled group, THINK included. */ + it('absorbs reasoning into a labeled group once the label has text', () => { + const grouped = groupSequentialToolCalls( + withIndex([ + think('deciding what to search'), + toolCall('t1'), + label('Found the failing spec'), + ]), + ); + + expect(grouped).toHaveLength(1); + expect(grouped[0].type).toBe('tool-group'); + const group = grouped[0] as { parts: PartWithIndex[]; labelPart?: PartWithIndex }; + expect(group.parts).toHaveLength(2); + expect(group.labelPart?.part).toMatchObject({ + [ContentTypes.ACTIVITY_LABEL]: 'Found the failing spec', + }); + }); + + /** An empty orphan label has nothing to render and no block to delimit. */ + it('drops an empty orphan label entirely', () => { + expect(groupSequentialToolCalls(withIndex([label('')]))).toEqual([]); + }); +}); diff --git a/client/src/utils/groupToolCalls.ts b/client/src/utils/groupToolCalls.ts index 9c1b51dd06..9688d9b837 100644 --- a/client/src/utils/groupToolCalls.ts +++ b/client/src/utils/groupToolCalls.ts @@ -70,19 +70,21 @@ export function groupSequentialToolCalls(parts: PartWithIndex[]): GroupedPart[] continue; } if (item.part.type === ContentTypes.ACTIVITY_LABEL) { - /** A reserved-but-unfilled slot (and a failed/blank fill) still - * DELIMITS its batch, so grouping does not re-shuffle when the text - * lands — but it is not attached as a labelPart, leaving the group to - * render its generic verb exactly as it would without the feature. */ const hasText = getActivityLabelText(getActivityLabelPart(item.part)).length > 0; + if (!hasText) { + /** A reserved-but-unfilled slot (and a failed/blank fill) must be + * INVISIBLE. Every batch now publishes its reservation immediately, + * so forming a group here would wrap even a single tool call and pull + * THINK parts inside it while generation is still pending. Flushing + * the legacy way instead delimits the batch AND re-splits it exactly + * as it renders with the feature off. */ + flushWithoutLabel(); + continue; + } if (currentBlock.length > 0) { - result.push({ - type: 'tool-group', - parts: [...currentBlock], - ...(hasText && { labelPart: item }), - }); + result.push({ type: 'tool-group', parts: [...currentBlock], labelPart: item }); currentBlock = []; - } else if (hasText) { + } else { /** Orphan label (block parts hidden/filtered): renders standalone. */ result.push({ type: 'single', part: item }); } diff --git a/packages/api/src/agents/activityLabels/host.ts b/packages/api/src/agents/activityLabels/host.ts index 578abe5af4..79a2acc209 100644 --- a/packages/api/src/agents/activityLabels/host.ts +++ b/packages/api/src/agents/activityLabels/host.ts @@ -99,15 +99,24 @@ export function resolveActivityConfig( const endpoints = appConfig?.endpoints as | (Record & { all?: TEndpoint }) | undefined; - const config: Partial | undefined = - endpoints?.all ?? endpoints?.[endpoint] ?? customEndpointConfig; + /** + * Resolved FIELD BY FIELD rather than by picking one config object whole. + * Selecting wholesale means any `endpoints.all` block — even one carrying + * nothing but `headers` — shadows the named/custom endpoint entirely and + * silently disables activity labels everywhere. Global still wins per + * field, so a real `all.activityLabel` keeps overriding the endpoint. + */ + const all = endpoints?.all as Partial | undefined; + const named = (endpoints?.[endpoint] ?? customEndpointConfig) as Partial | undefined; + const pick = (key: K): TEndpoint[K] | undefined => + all?.[key] ?? named?.[key]; return { - enabled: config?.activityLabel === true, - model: config?.activityModel, - endpoint: config?.activityEndpoint, - prompt: config?.activityPrompt, - maxPerRun: config?.activityMaxPerRun, - charLimit: config?.activityCharLimit, + enabled: pick('activityLabel') === true, + model: pick('activityModel'), + endpoint: pick('activityEndpoint'), + prompt: pick('activityPrompt'), + maxPerRun: pick('activityMaxPerRun'), + charLimit: pick('activityCharLimit'), /** `titleModel` is the documented fallback below, not a field here. */ }; }