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. */
};
}