📌 fix: Keep the Settled Turn Mounted Through Final Content Compaction (#15186)

* 📌 fix: Keep the Settled Turn Mounted Through Final Content Compaction

The agent aggregator writes content parts at provider-source indexes, so
the streamed array is sparse wherever a step produced nothing; the final
SSE event carries the persisted, compacted array. Adopting it verbatim
shifted every part after a hole, re-keying every index-derived React
identity: the settled message remounted wholesale, activity-phase groups
replayed their fold-in entrance, code panes re-highlighted, and the
thread visibly snapped up and down at the end of every tool-calling run.

finalHandler now pairs the compacted parts with their streamed
counterparts in order and stamps each with the index it streamed at
(`streamedIndex`, client-only); render keys read the stamp while all
coordinate logic (edit indexes, phase bounds, cursor) stays on the live
compacted positions the server persisted. Phase-segment keys also anchor
to their first defined part instead of the segment ordinal, since
phantom hole-only segments vanish at compaction and shifted every
segment after them.

* 🔁 fix: Carry Identity Stamps Through Re-Delivered Finals and Parallel Attribution

Codex round 1, both real:
- P1: a later final event can re-deliver an already-settled message as a
  fresh compact array (Assistants runMessages resync); index-aligned
  pairing returned it unstamped, wiping the previous settle's stamps and
  re-keying the older turn all over again. The pairing now carries the
  matched current part's stamp forward, so a settled turn keeps its keys
  through every subsequent final.
- P2: ParallelContentRenderer's sequential stretches invoked
  renderResumeAttribution with only the live index, so steer attribution
  nodes in parallel content still re-keyed at the swap. The stable key
  index now threads through both call sites; getPartKeyIndex moves to
  utils/messages beside the stamp writer it reads.

* 🧿 fix: Require Content Agreement Before Pairing Streamed Identity

Codex round 2 (P2, real): hide_sequential_outputs runs omit intermediate
parts from the final array, so a type-only match could hand the retained
output an omitted intermediate's identity — transferring its key and any
UI state. Non-tool pairing now requires content agreement: mutual-prefix
text for TEXT/THINK/ACTIVITY_LABEL (one side extending the other is the
same part observed at two moments), the Open Responses phase for TEXT,
and the label kind for activity labels — a blank reservation still pairs
with its filled label. Ambiguous shapes fall back to the pre-stamp full
re-key, which is honest for a final that visibly removes parts.

* 🪢 fix: Refuse Stamping When the Server Removed Content; Strip Stamps on Edited Reruns

Codex round 3, two of three real:
- Prefix agreement alone still mis-paired when an omitted intermediate
  happened to prefix the retained output. Pairing now also requires that
  no substantial streamed part is left over: leftovers mean the server
  removed content (hide_sequential_outputs), so every in-order pairing
  is suspect and the message re-keys plainly instead.
- An edited resubmission clones the settled (stamped) prefix and appends
  the rerun's parts at the prefix length; a retained stamp at or above
  that length collides with an appended part's key. The clone now strips
  the client-only stamps, reverting the retained prefix to physical
  identity for the rerun.

The third finding (content-segment keys under late-phase recovery) is
declined with rationale on the PR: user expansion overrides survive via
the message-wide expansion map with stable group ids, recovery is a
genuine restructure at the moment a phase materializes, and first-child
anchoring is the only choice stable under the two high-frequency events
(streaming appends and final compaction).
This commit is contained in:
Danny Avila 2026-08-25 06:26:17 -04:00 committed by GitHub
parent ac2aef00f6
commit a9d99b3771
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 605 additions and 43 deletions

View file

@ -8,7 +8,12 @@ import type {
} from 'librechat-data-provider';
import type { ReactNode, ReactElement } from 'react';
import type { ToolCallGroupExpansionState } from './ToolCallGroup';
import { mapAttachments, filterAttachmentsForPart, groupSequentialToolCalls } from '~/utils';
import {
mapAttachments,
getPartKeyIndex,
filterAttachmentsForPart,
groupSequentialToolCalls,
} from '~/utils';
import WorkspaceChanges, { partitionWorkspaceChanges } from './Parts/WorkspaceChanges';
import { groupActivityPhases, lastCursorContentIdx } from '~/utils/activityLabels';
import { ParallelContentRenderer, type PartWithIndex } from './ParallelContent';
@ -51,20 +56,20 @@ const getToolGroupId = (parts: PartWithIndex[], fallbackScope: number): string =
* absorbs the block's leading THINK part when its text lands, so keying on
* `parts[0]` would flip the key mid-run remounting the group and losing
* whatever the user had expanded. The tool calls themselves do not move. */
let firstToolIdx: number | undefined;
let firstToolKeyIdx: number | undefined;
for (const { part, idx } of parts) {
const toolCallId = getToolCallId(part);
if (toolCallId) {
return `tool:${toolCallId}`;
}
if (firstToolIdx === undefined && part?.type === ContentTypes.TOOL_CALL) {
firstToolIdx = idx;
if (firstToolKeyIdx === undefined && part?.type === ContentTypes.TOOL_CALL) {
firstToolKeyIdx = getPartKeyIndex(part, idx);
}
}
/** Same reasoning for id-less tool calls: anchor to the first TOOL entry's
* index rather than the block's first part, which shifts when reasoning is
* absorbed. Only a block with no tool call at all falls back to `parts[0]`. */
return `fallback:${fallbackScope}:${firstToolIdx ?? firstPart.idx}`;
return `fallback:${fallbackScope}:${firstToolKeyIdx ?? getPartKeyIndex(firstPart.part, firstPart.idx)}`;
};
type PartWithContextProps = {
@ -117,7 +122,7 @@ const PartWithContext = memo(function PartWithContext({
part={part}
attachments={partAttachments}
isSubmitting={isSubmitting}
key={`part-${messageId}-${idx}`}
key={`part-${messageId}-${getPartKeyIndex(part, idx)}`}
isCreatedByUser={isCreatedByUser}
isLast={isLastPart}
showCursor={isLastPart && isLast}
@ -248,7 +253,7 @@ const ContentPartsBody = memo(function ContentPartsBody({
const indices = new Set<number>();
for (const segment of phaseSegments ?? []) {
if (segment.type === 'phase') {
indices.add(segment.labelIndex);
indices.add(getPartKeyIndex(segment.labelPart, segment.labelIndex));
}
}
return indices;
@ -332,7 +337,7 @@ const ContentPartsBody = memo(function ContentPartsBody({
const localIdx = localIndexByAbsolute?.get(idx) ?? idx - contentIndexOffset;
return (
<PartWithContext
key={`provider-${messageId}-${idx}`}
key={`provider-${messageId}-${getPartKeyIndex(part, idx)}`}
idx={idx}
part={part}
isLast={isLast}
@ -369,7 +374,7 @@ const ContentPartsBody = memo(function ContentPartsBody({
const localIdx = localIndexByAbsolute?.get(idx) ?? idx - contentIndexOffset;
return (
<PartWithContext
key={`provider-${messageId}-${idx}`}
key={`provider-${messageId}-${getPartKeyIndex(part, idx)}`}
idx={idx}
part={part}
isLast={isLast}
@ -456,15 +461,15 @@ const ContentPartsBody = memo(function ContentPartsBody({
/** The re-attribution node for a part resuming after a steer block, shared
* by the sequential path and the parallel renderer's sequential stretches. */
const renderResumeAttribution = useCallback(
(idx: number): ReactElement | null => {
(idx: number, keyIdx: number = idx): ReactElement | null => {
if (authorHeader == null || !postSteerAuthors.has(idx)) {
return null;
}
const activeAgentId = postSteerAuthors.get(idx);
if (activeAgentId != null) {
return <AgentUpdate key={`author-${messageId}-${idx}`} currentAgentId={activeAgentId} />;
return <AgentUpdate key={`author-${messageId}-${keyIdx}`} currentAgentId={activeAgentId} />;
}
return <Fragment key={`author-${messageId}-${idx}`}>{authorHeader}</Fragment>;
return <Fragment key={`author-${messageId}-${keyIdx}`}>{authorHeader}</Fragment>;
},
[authorHeader, postSteerAuthors, messageId],
);
@ -500,6 +505,23 @@ const ContentPartsBody = memo(function ContentPartsBody({
const relativeGlobalLastContentIdx = lastCursorContentIdx(content ?? []);
const globalLastContentIdx =
relativeGlobalLastContentIdx < 0 ? -1 : absoluteIndexAt(relativeGlobalLastContentIdx);
/** Segment keys anchor to their first defined part's stable index, never
* to the segment's ordinal: hole-only slots form phantom segments while
* a run streams and vanish from the compacted final content, so ordinal
* keys shift at settle and remount every segment body after them. */
const segmentKeyIndex = (segment: {
content: Array<TMessageContentParts | undefined>;
contentIndices: number[];
startIndex: number;
}): number => {
for (let i = 0; i < segment.content.length; i++) {
const part = segment.content[i];
if (part != null) {
return getPartKeyIndex(part, absoluteIndexAt(segment.contentIndices[i]));
}
}
return absoluteIndexAt(segment.startIndex);
};
const renderSegment = (
segmentContent: Array<TMessageContentParts | undefined>,
segmentStartIndex: number,
@ -538,17 +560,26 @@ const ContentPartsBody = memo(function ContentPartsBody({
<Sources messageId={messageId} conversationId={conversationId || undefined} />
)}
{renderPendingSkills()}
{phaseSegments.map((segment, index) =>
segment.type === 'phase' ? (
{phaseSegments.map((segment) => {
if (segment.type !== 'phase') {
return renderSegment(
segment.content,
absoluteIndexAt(segment.startIndex),
segment.contentIndices.map(absoluteIndexAt),
`phase-adjacent-${segmentKeyIndex(segment)}`,
);
}
const phaseKeyIndex = getPartKeyIndex(segment.labelPart, segment.labelIndex);
return (
<ActivityPhaseGroup
key={`activity-phase-${messageId}-${segment.labelIndex}`}
key={`activity-phase-${messageId}-${phaseKeyIndex}`}
labelPart={segment.labelPart}
hasContent={segment.hasContent}
hasPendingApproval={segment.content.some(
(part) => part != null && hasPendingApprovalInPart(part),
)}
animateEntrance={
previousPhaseIndices != null && !previousPhaseIndices.has(segment.labelIndex)
previousPhaseIndices != null && !previousPhaseIndices.has(phaseKeyIndex)
}
showCursor={
isLast &&
@ -560,18 +591,11 @@ const ContentPartsBody = memo(function ContentPartsBody({
segment.content,
absoluteIndexAt(segment.startIndex),
segment.contentIndices.map(absoluteIndexAt),
`phase-content-${index}`,
`phase-content-${phaseKeyIndex}`,
)}
</ActivityPhaseGroup>
) : (
renderSegment(
segment.content,
absoluteIndexAt(segment.startIndex),
segment.contentIndices.map(absoluteIndexAt),
`phase-adjacent-${index}`,
)
),
)}
);
})}
<WorkspaceChanges attachments={workspaceChanges} />
</SearchContext.Provider>
</ApprovalProvider>
@ -639,9 +663,13 @@ const ContentPartsBody = memo(function ContentPartsBody({
)}
{!showEmptyCursor &&
groupedParts.flatMap((group) => {
const firstIdx = group.type === 'single' ? group.part.idx : (group.parts[0]?.idx ?? -1);
const first = group.type === 'single' ? group.part : group.parts[0];
const firstIdx = first?.idx ?? -1;
const nodes: ReactElement[] = [];
const attribution = renderResumeAttribution(firstIdx);
const attribution = renderResumeAttribution(
firstIdx,
first ? getPartKeyIndex(first.part, first.idx) : firstIdx,
);
if (attribution != null) {
nodes.push(attribution);
}

View file

@ -8,11 +8,11 @@ import {
} from '~/utils/activityLabels';
import MemoryArtifacts from './MemoryArtifacts';
import Sources from '~/components/Web/Sources';
import { cn, getPartKeyIndex } from '~/utils';
import { SearchContext } from '~/Providers';
import SiblingHeader from './SiblingHeader';
import { EmptyText } from './Parts';
import Container from './Container';
import { cn } from '~/utils';
export type PartWithIndex = { part: TMessageContentParts; idx: number };
@ -235,7 +235,7 @@ type ParallelContentRendererProps = {
* sequential before/after stretches consult it: column content already
* carries per-agent identity.
*/
renderResumeAttribution?: (idx: number) => React.ReactNode;
renderResumeAttribution?: (idx: number, keyIdx?: number) => React.ReactNode;
showDecorations?: boolean;
/** Absolute transcript index represented by `content[0]` in a phase slice. */
contentIndexOffset?: number;
@ -302,7 +302,7 @@ export const ParallelContentRenderer = memo(function ParallelContentRenderer({
{/* Sequential content BEFORE parallel sections */}
{before.flatMap(({ part, idx }) => {
const attribution = renderResumeAttribution?.(idx);
const attribution = renderResumeAttribution?.(idx, getPartKeyIndex(part, idx));
const rendered = renderPart(part, idx, false);
return attribution != null ? [attribution, rendered] : [rendered];
})}
@ -324,7 +324,7 @@ export const ParallelContentRenderer = memo(function ParallelContentRenderer({
{/* Sequential content AFTER parallel sections */}
{after.flatMap(({ part, idx }) => {
const attribution = renderResumeAttribution?.(idx);
const attribution = renderResumeAttribution?.(idx, getPartKeyIndex(part, idx));
const rendered = renderPart(part, idx, idx === lastContentIdx);
return attribution != null ? [attribution, rendered] : [rendered];
})}

View file

@ -2,6 +2,7 @@ import React from 'react';
import { ContentTypes, Tools } from 'librechat-data-provider';
import { fireEvent, render, screen } from '@testing-library/react';
import type { TMessageContentParts, TAttachment } from 'librechat-data-provider';
import { preserveStreamedContentIdentity } from '~/utils/messages';
import { groupSequentialToolCalls } from '~/utils';
jest.mock('~/utils', () => ({
@ -10,6 +11,7 @@ jest.mock('~/utils', () => ({
filterAttachmentsForPart: (attachments: unknown) => attachments,
groupSequentialToolCalls: jest.fn(),
hasPendingApprovalInPart: jest.requireActual('~/utils/groupToolCalls').hasPendingApprovalInPart,
getPartKeyIndex: jest.requireActual('~/utils/messages').getPartKeyIndex,
}));
jest.mock('~/Providers', () => {
@ -651,3 +653,69 @@ describe('ContentParts — activity phase state', () => {
);
});
});
describe('ContentParts — settled content identity across compaction', () => {
/** Mirrors a captured run: the aggregator leaves holes at the source indexes
* of steps that produced nothing, and `finalHandler` swaps in the server's
* compacted array. Without the streamed-index stamp every index-derived key
* shifts and the settled message remounts wholesale. */
const toolPart = {
type: ContentTypes.TOOL_CALL,
[ContentTypes.TOOL_CALL]: { id: 'call_a', name: 'search', args: {}, output: 'one' },
} as unknown as TMessageContentParts;
const batchLabel = {
type: ContentTypes.ACTIVITY_LABEL,
[ContentTypes.ACTIVITY_LABEL]: 'Recorded the fact',
tool_call_ids: ['call_a'],
} as unknown as TMessageContentParts;
const answer = { type: ContentTypes.TEXT, text: 'done' } as unknown as TMessageContentParts;
const phaseLabel = (bounds: { start: number; end: number }) =>
({
type: ContentTypes.ACTIVITY_LABEL,
[ContentTypes.ACTIVITY_LABEL]: 'Researched the question',
activity_label_type: 'phase',
activity_start_index: bounds.start,
activity_end_index: bounds.end,
activity_count: 1,
pending: false,
}) as unknown as TMessageContentParts;
const streamed: Array<TMessageContentParts | undefined> = [
undefined,
toolPart,
batchLabel,
undefined,
answer,
phaseLabel({ start: 1, end: 4 }),
];
const compacted = [toolPart, batchLabel, answer, phaseLabel({ start: 0, end: 2 })];
const renderStreaming = () =>
render(<ContentParts {...baseProps} content={streamed} isLast isSubmitting isLatestMessage />);
it('keeps every part and the phase group mounted when the final content is stamped', () => {
const { rerender } = renderStreaming();
const phaseNode = screen.getByTestId('activity-phase-group');
const toolNode = screen.getByTestId('real-part-tool_call');
const textNode = screen.getByTestId('real-part-text');
const finalContent = preserveStreamedContentIdentity(streamed, compacted);
rerender(<ContentParts {...baseProps} content={finalContent} isLast />);
expect(screen.getByTestId('activity-phase-group')).toBe(phaseNode);
expect(screen.getByTestId('real-part-tool_call')).toBe(toolNode);
expect(screen.getByTestId('real-part-text')).toBe(textNode);
expect(phaseNode).toHaveAttribute('data-animate-entrance', 'false');
});
it('remounts and replays the phase entrance without the stamp (regression control)', () => {
const { rerender } = renderStreaming();
const phaseNode = screen.getByTestId('activity-phase-group');
rerender(<ContentParts {...baseProps} content={compacted} isLast />);
const settledPhase = screen.getByTestId('activity-phase-group');
expect(settledPhase).not.toBe(phaseNode);
expect(settledPhase).toHaveAttribute('data-animate-entrance', 'true');
});
});

View file

@ -33,6 +33,7 @@ import {
isSubmittableMessage,
createDualMessageContent,
getRouteChatProjectId,
stripStreamedIndexStamps,
} from '~/utils';
import useFocusRegeneratedResponse from '~/hooks/Chat/useFocusRegeneratedResponse';
import useSetFilesToDelete from '~/hooks/Files/useSetFilesToDelete';
@ -626,7 +627,10 @@ export default function useChatFunctions({
initialResponse.text = '';
if (editedContent && latestMessage?.content) {
initialResponse.content = cloneDeep(latestMessage.content);
/** Stamps off: the rerun appends provider parts at the prefix LENGTH,
* and a retained `streamedIndex` at or above it would collide with an
* appended part's render key (see `stripStreamedIndexStamps`). */
initialResponse.content = stripStreamedIndexStamps(cloneDeep(latestMessage.content));
/** Captured now, while it is still the retained prefix: a later resume
* sync replaces this array with the server's completion-local
* snapshot, after which its length no longer describes the offset. */

View file

@ -36,6 +36,7 @@ import {
updateConvoInAllQueries,
removeConvoFromAllQueries,
findConversationInInfinite,
preserveStreamedContentIdentity,
} from '~/utils';
import {
startupConfigKey,
@ -870,19 +871,29 @@ export default function useEventHandlers({
finalMessages = [...messages, requestMessage, responseMessage];
}
/* Preserve files from current messages when server response lacks them */
/* Preserve files and streamed content identity from current messages:
* files fill in when the server response lacks them, and the persisted
* (compacted) content is stamped with the indexes it streamed at so
* index-keyed renders don't remount the settled message. */
if (finalMessages.length > 0) {
const currentMsgMap = new Map(
currentMessages
.filter((m) => m.files && m.files.length > 0)
.map((m) => [m.messageId, m.files]),
);
const currentMsgMap = new Map(currentMessages.map((m) => [m.messageId, m]));
for (let i = 0; i < finalMessages.length; i++) {
const msg = finalMessages[i];
const preservedFiles = currentMsgMap.get(msg.messageId);
if (msg.files == null && preservedFiles) {
finalMessages[i] = { ...msg, files: preservedFiles };
const currentMsg = currentMsgMap.get(msg.messageId);
if (!currentMsg) {
continue;
}
const preservedFiles =
msg.files == null && currentMsg.files?.length ? currentMsg.files : undefined;
const content = preserveStreamedContentIdentity(currentMsg.content, msg.content);
if (preservedFiles == null && content === msg.content) {
continue;
}
finalMessages[i] = {
...msg,
...(preservedFiles != null ? { files: preservedFiles } : {}),
...(content !== msg.content ? { content } : {}),
};
}
}

View file

@ -0,0 +1,242 @@
import { ContentTypes } from 'librechat-data-provider';
import type { TMessage, TMessageContentParts } from 'librechat-data-provider';
import { preserveStreamedContentIdentity, stripStreamedIndexStamps } from './messages';
const text = (value: string, extra: Record<string, unknown> = {}): TMessageContentParts =>
({ type: ContentTypes.TEXT, text: value, ...extra }) as TMessageContentParts;
const think = (value: string): TMessageContentParts =>
({ type: ContentTypes.THINK, think: value }) as TMessageContentParts;
const tool = (id: string | undefined, name = 'search'): TMessageContentParts =>
({
type: ContentTypes.TOOL_CALL,
tool_call: { id, name, args: '' },
}) as TMessageContentParts;
const label = (value: string, extra: Record<string, unknown> = {}): TMessageContentParts =>
({ type: ContentTypes.ACTIVITY_LABEL, activity_label: value, ...extra }) as TMessageContentParts;
const streamedIndexes = (content: TMessage['content']): Array<number | undefined> =>
(content ?? []).map((part) => part?.streamedIndex);
describe('preserveStreamedContentIdentity', () => {
it('stamps every part shifted by compacted holes with its streamed index', () => {
const streamed = [
undefined,
tool('call_a'),
label('first'),
undefined,
tool('call_b'),
label('second'),
text('answer'),
label('phase', { activity_label_type: 'phase' }),
];
const final = [
tool('call_a'),
label('first'),
tool('call_b'),
label('second'),
text('answer'),
label('phase', { activity_label_type: 'phase' }),
];
const result = preserveStreamedContentIdentity(streamed, final);
expect(streamedIndexes(result)).toEqual([1, 2, 4, 5, 6, 7]);
expect(final.every((part) => part.streamedIndex === undefined)).toBe(true);
});
it('returns the final array untouched when no hole shifted anything', () => {
const streamed = [tool('call_a'), text('answer')];
const final = [tool('call_a'), text('answer')];
expect(preserveStreamedContentIdentity(streamed, final)).toBe(final);
});
it('leaves aligned prefix parts unstamped while stamping the shifted tail', () => {
const streamed = [text('intro'), undefined, tool('call_a')];
const final = [text('intro'), tool('call_a')];
expect(streamedIndexes(preserveStreamedContentIdentity(streamed, final))).toEqual([
undefined,
2,
]);
});
it('skips streamed empty-text placeholders the compaction dropped', () => {
const streamed = [text(''), tool('call_a'), text('answer')];
const final = [tool('call_a'), text('answer')];
expect(streamedIndexes(preserveStreamedContentIdentity(streamed, final))).toEqual([1, 2]);
});
it('skips streamed empty think parts and typeless placeholders', () => {
const streamed = [
{ type: '' } as unknown as TMessageContentParts,
think(''),
think('reasoned'),
text('answer'),
];
const final = [think('reasoned'), text('answer')];
expect(streamedIndexes(preserveStreamedContentIdentity(streamed, final))).toEqual([2, 3]);
});
it('matches by identity, not equality: richer final text keeps its streamed slot', () => {
const streamed = [undefined, text('partial ans')];
const final = [text('partial answer, completed.')];
expect(streamedIndexes(preserveStreamedContentIdentity(streamed, final))).toEqual([1]);
});
it('pairs tool calls by id and abandons stamping on an id mismatch', () => {
const streamed = [undefined, tool('call_a')];
const final = [tool('call_other')];
expect(preserveStreamedContentIdentity(streamed, final)).toBe(final);
});
it('abandons stamping when the server appended a part that never streamed', () => {
const streamed = [undefined, tool('call_a')];
const final = [tool('call_a'), text('server-added')];
expect(preserveStreamedContentIdentity(streamed, final)).toBe(final);
});
it('abandons stamping on a type mismatch instead of mispairing', () => {
const streamed = [think('reasoned'), text('answer')];
const final = [text('answer')];
expect(preserveStreamedContentIdentity(streamed, final)).toBe(final);
});
it('returns final content untouched when nothing streamed', () => {
const final = [text('answer')];
expect(preserveStreamedContentIdentity(undefined, final)).toBe(final);
expect(preserveStreamedContentIdentity([], final)).toBe(final);
});
it('abandons stamping when a filtered run retains only a same-type later part', () => {
const streamed = [text('intermediate agent output'), text('final agent answer')];
const final = [text('final agent answer')];
expect(preserveStreamedContentIdentity(streamed, final)).toBe(final);
});
it('abandons stamping when an omitted intermediate is a prefix of the retained output', () => {
const streamed = [text('Answer:'), text('Answer: final details')];
const final = [text('Answer: final details')];
expect(preserveStreamedContentIdentity(streamed, final)).toBe(final);
});
it('ignores trailing holes and empty slots when checking for removed content', () => {
const streamed = [undefined, tool('call_a'), text('answer'), text(''), undefined];
const final = [tool('call_a'), text('answer')];
expect(streamedIndexes(preserveStreamedContentIdentity(streamed, final))).toEqual([1, 2]);
});
it('abandons stamping when streamed and final text diverge', () => {
const streamed = [undefined, text('answer A')];
const final = [text('answer B')];
expect(preserveStreamedContentIdentity(streamed, final)).toBe(final);
});
it('never pairs a batch label with a phase label of the same text', () => {
const streamed = [undefined, label('Ran the tools')];
const final = [label('Ran the tools', { activity_label_type: 'phase' })];
expect(preserveStreamedContentIdentity(streamed, final)).toBe(final);
});
it('pairs a blank label reservation with its filled final label', () => {
const streamed = [undefined, label('')];
const final = [label('Recorded the fact')];
expect(streamedIndexes(preserveStreamedContentIdentity(streamed, final))).toEqual([1]);
});
it('never pairs text parts across different phases', () => {
const streamed = [undefined, text('note', { phase: 'commentary' })];
const final = [text('note')];
expect(preserveStreamedContentIdentity(streamed, final)).toBe(final);
});
it('carries existing stamps forward when a settled message is re-delivered compact', () => {
const streamedSparse = [undefined, tool('call_a'), label('first'), undefined, text('answer')];
const settled = preserveStreamedContentIdentity(streamedSparse, [
tool('call_a'),
label('first'),
text('answer'),
]);
expect(streamedIndexes(settled)).toEqual([1, 2, 4]);
const redelivered = [tool('call_a'), label('first'), text('answer')];
const result = preserveStreamedContentIdentity(settled, redelivered);
expect(streamedIndexes(result)).toEqual([1, 2, 4]);
});
it('carries a partially stamped message forward without stamping its aligned prefix', () => {
const streamedSparse = [text('intro'), undefined, tool('call_a')];
const settled = preserveStreamedContentIdentity(streamedSparse, [
text('intro'),
tool('call_a'),
]);
expect(streamedIndexes(settled)).toEqual([undefined, 2]);
const result = preserveStreamedContentIdentity(settled, [text('intro'), tool('call_a')]);
expect(streamedIndexes(result)).toEqual([undefined, 2]);
});
it('pairs id-less tool calls by name', () => {
const streamed = [undefined, tool(undefined, 'execute_code')];
const final = [tool(undefined, 'execute_code')];
expect(streamedIndexes(preserveStreamedContentIdentity(streamed, final))).toEqual([1]);
});
it('abandons stamping when id-less tool call names differ', () => {
const streamed = [undefined, tool(undefined, 'execute_code')];
const final = [tool(undefined, 'web_search')];
expect(preserveStreamedContentIdentity(streamed, final)).toBe(final);
});
});
describe('stripStreamedIndexStamps', () => {
const tool = (id: string): TMessageContentParts =>
({
type: ContentTypes.TOOL_CALL,
tool_call: { id, name: 'search', args: '' },
}) as TMessageContentParts;
it('drops every stamp from a settled content array', () => {
const settled = preserveStreamedContentIdentity(
[
undefined,
tool('call_a'),
{ type: ContentTypes.TEXT, text: 'answer' } as TMessageContentParts,
],
[tool('call_a'), { type: ContentTypes.TEXT, text: 'answer' } as TMessageContentParts],
);
expect((settled ?? []).some((part) => part?.streamedIndex !== undefined)).toBe(true);
const stripped = stripStreamedIndexStamps(settled);
expect((stripped ?? []).every((part) => part?.streamedIndex === undefined)).toBe(true);
});
it('returns the same reference when nothing is stamped', () => {
const plain = [tool('call_a')];
expect(stripStreamedIndexStamps(plain)).toBe(plain);
expect(stripStreamedIndexStamps(undefined)).toBeUndefined();
});
});

View file

@ -9,6 +9,7 @@ import {
encodeEphemeralAgentId,
} from 'librechat-data-provider';
import type {
Agents,
TMessage,
TConversation,
TEndpointsConfig,
@ -192,6 +193,204 @@ export const getAllContentText = (message?: TMessage | null): string => {
return '';
};
const getPartTextValue = (value?: string | { value?: string }): string =>
(typeof value === 'string' ? value : value?.value) ?? '';
const getPartToolCall = (part: TMessageContentParts): Agents.ToolCall | undefined =>
part.type === ContentTypes.TOOL_CALL
? (part[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)
: undefined;
/** Slots the persistence compaction leaves nothing behind for: the
* dual-message `type: ''` placeholders, text/think parts that never received a
* delta, and tool calls missing their `tool_call` payload. */
const isEmptyContentPart = (part: TMessageContentParts): boolean => {
if (!part.type) {
return true;
}
if (part.type === ContentTypes.TEXT) {
return getPartTextValue(part.text).length === 0;
}
if (part.type === ContentTypes.THINK) {
return getPartTextValue(part.think).length === 0;
}
if (part.type === ContentTypes.TOOL_CALL) {
return getPartToolCall(part) == null;
}
return false;
};
/** One side extending the other is the same part observed at two moments
* a flushed tail or a server-side trim while divergent content is a
* different part that merely shares the type. */
const isMutualPrefix = (streamed: string, final: string): boolean =>
final.startsWith(streamed) || streamed.startsWith(final);
/** Identity match, not equality: the persisted part may carry richer content
* (flushed text, tool output) than its streamed counterpart, and updating a
* kept identity in place is exactly the point. Content still has to agree as
* an extension of what streamed: a filtered run (`hide_sequential_outputs`)
* omits intermediate parts from the final array, and a type-only match would
* hand the retained output an omitted intermediate's identity. */
const isSameStreamedPart = (
streamed: TMessageContentParts,
final: TMessageContentParts,
): boolean => {
if (streamed.type !== final.type) {
return false;
}
if (streamed.type === ContentTypes.TOOL_CALL) {
const streamedCall = getPartToolCall(streamed);
const finalCall = getPartToolCall(final);
if (streamedCall?.id != null && finalCall?.id != null) {
return streamedCall.id === finalCall.id;
}
if (streamedCall?.name != null && finalCall?.name != null) {
return streamedCall.name === finalCall.name;
}
return true;
}
if (streamed.type === ContentTypes.TEXT && final.type === ContentTypes.TEXT) {
if ((streamed.phase ?? null) !== (final.phase ?? null)) {
return false;
}
return isMutualPrefix(getPartTextValue(streamed.text), getPartTextValue(final.text));
}
if (streamed.type === ContentTypes.THINK && final.type === ContentTypes.THINK) {
return isMutualPrefix(getPartTextValue(streamed.think), getPartTextValue(final.think));
}
if (streamed.type === ContentTypes.ACTIVITY_LABEL && final.type === ContentTypes.ACTIVITY_LABEL) {
if ((streamed.activity_label_type ?? null) !== (final.activity_label_type ?? null)) {
return false;
}
return isMutualPrefix(
getPartTextValue(streamed.activity_label),
getPartTextValue(final.activity_label),
);
}
return true;
};
/**
* Stamps each part of a final (persisted, compacted) content array with the
* index it occupied while it streamed, pairing the two arrays in order.
*
* The aggregator writes parts at provider-source indexes, so the streamed
* array is sparse wherever a step produced nothing; persistence compacts the
* holes away and every later part shifts down. Adopting the compacted array
* verbatim re-keys every index-derived React identity at the final event
* the settled message remounts wholesale, entrance animations replay, and the
* thread visibly jumps. The stamp (`streamedIndex`) lets renderers keep the
* streamed key while all coordinate logic uses the compacted positions the
* server persisted.
*
* Pairing is all-or-nothing: a partially stamped array could collide a
* streamed key with a compacted fallback key. When any final part has no
* streamed counterpart (server-enriched content), or any substantial streamed
* part has no final counterpart (a filtered run that dropped intermediate
* outputs where in-order pairing could hand a retained part an omitted
* part's identity), the final array is returned untouched and the message
* re-keys as before.
*/
export const preserveStreamedContentIdentity = (
streamedContent: Array<TMessageContentParts | undefined> | undefined,
finalContent: TMessage['content'],
): TMessage['content'] => {
if (!streamedContent?.length || !finalContent?.length) {
return finalContent;
}
let cursor = 0;
let stamped: TMessageContentParts[] | null = null;
for (let index = 0; index < finalContent.length; index++) {
const finalPart = finalContent[index] as TMessageContentParts | undefined;
if (finalPart == null) {
return finalContent;
}
let matchedIndex = -1;
let matchedPart: TMessageContentParts | null = null;
while (cursor < streamedContent.length) {
const streamedPart = streamedContent[cursor];
if (streamedPart == null) {
cursor += 1;
continue;
}
/** An empty streamed slot facing a filled final part was dropped by the
* compaction never let it steal the match from the filled streamed
* part behind it (an empty THINK ahead of the real one, say). */
if (isEmptyContentPart(streamedPart) && !isEmptyContentPart(finalPart)) {
cursor += 1;
continue;
}
if (isSameStreamedPart(streamedPart, finalPart)) {
matchedIndex = cursor;
matchedPart = streamedPart;
cursor += 1;
}
break;
}
if (matchedIndex === -1 || matchedPart == null) {
return finalContent;
}
/** A settled message can be re-delivered by a LATER final event (e.g. an
* Assistants run resyncing prior turns): both sides arrive compact, but
* the current parts already carry stamps from their own settle. Carrying
* them forward keeps their keys stable forever, instead of silently
* reverting the identity this stamp exists to preserve. */
const stampIndex = matchedPart.streamedIndex ?? matchedIndex;
if (stampIndex !== index && stamped == null) {
stamped = [...finalContent];
}
if (stamped != null && stampIndex !== index) {
stamped[index] = { ...finalPart, streamedIndex: stampIndex };
}
}
/** Leftover substantial streamed parts mean the server REMOVED content
* (`hide_sequential_outputs`), so every pairing above is suspect an
* omitted intermediate that happens to prefix the retained output would
* have claimed its identity. Only holes and empty slots may remain. */
for (let rest = cursor; rest < streamedContent.length; rest++) {
const leftover = streamedContent[rest];
if (leftover != null && !isEmptyContentPart(leftover)) {
return finalContent;
}
}
return stamped ?? finalContent;
};
/**
* Drops the client-only `streamedIndex` stamps from a content array. An
* edited resubmission retains the settled prefix and appends the rerun's
* parts at the prefix LENGTH a stamp at or above that length would collide
* with an appended part's key so the retained prefix reverts to physical
* identity for the rerun. Returns the input untouched when nothing is
* stamped.
*/
export function stripStreamedIndexStamps(content: TMessageContentParts[]): TMessageContentParts[];
export function stripStreamedIndexStamps(content: TMessage['content']): TMessage['content'];
export function stripStreamedIndexStamps(content: TMessage['content']): TMessage['content'] {
if (!content?.length) {
return content;
}
let changed = false;
const next = content.map((part) => {
if (part == null || part.streamedIndex === undefined) {
return part;
}
changed = true;
const { streamedIndex: _streamedIndex, ...rest } = part;
return rest as TMessageContentParts;
});
return changed ? next : content;
}
/** Render-identity index for content-part keys: the streamed position stamped
* by the final handler survives the sparsecompact swap; everything else keys
* by the live index. Coordinate logic (edit indexes, phase bounds, cursor)
* must keep using the live index. */
export const getPartKeyIndex = (part: TMessageContentParts | undefined, idx: number): number =>
part?.streamedIndex ?? idx;
/**
* Whether a draft message has enough content to submit: non-whitespace
* text, or at least one attached file. Lets users send a file without

View file

@ -638,10 +638,20 @@ export type PartMetadata = {
* as dispatch time rather than the task's runtime.
*/
backgrounded?: boolean;
/**
* Content index this part occupied while its run streamed. The aggregator
* writes parts at provider-source indexes, so the streamed array is sparse;
* persistence compacts it and every part after a hole shifts down. The
* client's final handler stamps the streamed position onto the compacted
* parts it adopts, so index-derived render identity survives the swap
* instead of remounting the settled message. Client-only and absent
* everywhere else persisted content never carries it.
*/
streamedIndex?: number;
};
/** Metadata for parallel content rendering - subset of PartMetadata */
export type ContentMetadata = Pick<PartMetadata, 'agentId' | 'groupId'>;
export type ContentMetadata = Pick<PartMetadata, 'agentId' | 'groupId' | 'streamedIndex'>;
export type ContentPart = (
| CodeToolCall