🏷️ fix: Re-attribute Agent Content After In-Thread Steers (#14497)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions

* 🏷️ fix: Re-attribute Agent Content After In-Thread Steers

* 🏷️ fix: Attribute Post-Steer Resume to the Active Handed-Off Agent

* 🏷️ fix: Re-attribute Post-Steer Resumes in Parallel Sequential Stretches
This commit is contained in:
Danny Avila 2026-07-28 23:26:48 -04:00 committed by GitHub
parent 3edb497502
commit c4d30a096e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 338 additions and 40 deletions

View file

@ -1,4 +1,4 @@
import { memo, useRef, useMemo, useCallback } from 'react';
import { memo, useRef, useMemo, useCallback, Fragment } from 'react';
import { ContentTypes } from 'librechat-data-provider';
import type {
TMessageContentParts,
@ -6,12 +6,13 @@ import type {
TAttachment,
Agents,
} from 'librechat-data-provider';
import type { ReactNode, ReactElement } from 'react';
import type { ToolCallGroupExpansionState } from './ToolCallGroup';
import { mapAttachments, filterAttachmentsForPart, groupSequentialToolCalls } from '~/utils';
import { ParallelContentRenderer, type PartWithIndex } from './ParallelContent';
import { EditTextPart, EmptyText, AgentUpdate } from './Parts';
import { MessageContext, SearchContext } from '~/Providers';
import PendingSkillCall from './Parts/PendingSkillCall';
import { EditTextPart, EmptyText } from './Parts';
import ApprovalProvider from './ApprovalContext';
import MemoryArtifacts from './MemoryArtifacts';
import ToolCallGroup from './ToolCallGroup';
@ -112,6 +113,12 @@ type ContentPartsProps = {
manualSkills?: string[];
/** ISO timestamp of the parent message, surfaced in parallel column headers. */
createdAt?: string | null;
/**
* Author icon + label node re-rendered before content that resumes after an
* inline STEER part the steer renders as a full user turn inside the
* response, so what follows must be visibly re-attributed to the author.
*/
authorHeader?: ReactNode;
conversationId?: string | null;
attachments?: TAttachment[];
searchResults?: { [key: string]: SearchResultData };
@ -146,6 +153,7 @@ const ContentParts = memo(function ContentParts({
isSubmitting,
setSiblingIdx,
searchResults,
authorHeader,
conversationId,
isCreatedByUser,
isLatestMessage,
@ -302,17 +310,35 @@ const ContentParts = memo(function ContentParts({
],
);
const sequentialParts = useMemo<PartWithIndex[]>(() => {
/** `postSteerAuthors` marks each part that resumes the response after a
* steer block where attribution is re-rendered. The value is the ACTIVE
* agent id when a preceding AGENT_UPDATE handed the run off (the resumed
* content belongs to that agent, not the message-level author), undefined
* for the top-level `authorHeader`. Read BEFORE applying the current
* part's own handoff, so a resume point that IS an agent update keeps the
* pre-handoff author and lets the real marker announce the transition. */
const { sequentialParts, postSteerAuthors } = useMemo(() => {
const parts: PartWithIndex[] = [];
const authors = new Map<number, string | undefined>();
if (!content) {
return [];
return { sequentialParts: parts, postSteerAuthors: authors };
}
const result: PartWithIndex[] = [];
let prevType: string | undefined;
let activeAgentId: string | undefined;
content.forEach((part, idx) => {
if (part) {
result.push({ part, idx });
if (!part) {
return;
}
if (prevType === ContentTypes.STEER && part.type !== ContentTypes.STEER) {
authors.set(idx, activeAgentId);
}
if (part.type === ContentTypes.AGENT_UPDATE) {
activeAgentId = part[ContentTypes.AGENT_UPDATE]?.agentId || undefined;
}
prevType = part.type;
parts.push({ part, idx });
});
return result;
return { sequentialParts: parts, postSteerAuthors: authors };
}, [content]);
const groupedParts = useMemo(
@ -332,6 +358,22 @@ const ContentParts = memo(function ContentParts({
[sequentialParts, attachmentMap, fallbackScope],
);
/** 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 => {
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 <Fragment key={`author-${messageId}-${idx}`}>{authorHeader}</Fragment>;
},
[authorHeader, postSteerAuthors, messageId],
);
// Early return: no content to render AND no pending skill cards
if (!content && !hasPendingSkills) {
return null;
@ -397,6 +439,7 @@ const ContentParts = memo(function ContentParts({
searchResults={searchResults}
isSubmitting={effectiveIsSubmitting}
renderPart={renderPart}
renderResumeAttribution={renderResumeAttribution}
/>
</ApprovalProvider>
);
@ -413,13 +456,20 @@ const ContentParts = memo(function ContentParts({
<EmptyText />
</Container>
)}
{groupedParts.map((group) => {
{groupedParts.flatMap((group) => {
const firstIdx = group.type === 'single' ? group.part.idx : (group.parts[0]?.idx ?? -1);
const nodes: ReactElement[] = [];
const attribution = renderResumeAttribution(firstIdx);
if (attribution != null) {
nodes.push(attribution);
}
if (group.type === 'single') {
const { part, idx } = group.part;
return renderPart(part, idx, idx === lastContentIdx);
nodes.push(renderPart(part, idx, idx === lastContentIdx));
return nodes;
}
const { groupId } = group;
return (
nodes.push(
<ToolCallGroup
key={`tool-group-${groupId}`}
parts={group.parts}
@ -430,8 +480,9 @@ const ContentParts = memo(function ContentParts({
groupAttachments={group.groupAttachments}
initialExpansionState={toolGroupExpansionRef.current.get(groupId)}
onExpansionChange={(state) => handleGroupExpansionChange(groupId, state)}
/>
/>,
);
return nodes;
})}
</SearchContext.Provider>
</ApprovalProvider>

View file

@ -202,6 +202,13 @@ type ParallelContentRendererProps = {
searchResults?: { [key: string]: SearchResultData };
isSubmitting: boolean;
renderPart: (part: TMessageContentParts, idx: number, isLastPart: boolean) => React.ReactNode;
/**
* Author re-attribution for a part that resumes after an inline steer
* returns the header node to render before that part, or null. Only the
* sequential before/after stretches consult it: column content already
* carries per-agent identity.
*/
renderResumeAttribution?: (idx: number) => React.ReactNode;
};
/**
@ -217,6 +224,7 @@ export const ParallelContentRenderer = memo(function ParallelContentRenderer({
searchResults,
isSubmitting,
renderPart,
renderResumeAttribution,
}: ParallelContentRendererProps) {
const { parallelSections, sequentialParts } = useMemo(
() => groupParallelContent(content),
@ -249,7 +257,11 @@ export const ParallelContentRenderer = memo(function ParallelContentRenderer({
<Sources messageId={messageId} conversationId={conversationId || undefined} />
{/* Sequential content BEFORE parallel sections */}
{before.map(({ part, idx }) => renderPart(part, idx, false))}
{before.flatMap(({ part, idx }) => {
const attribution = renderResumeAttribution?.(idx);
const rendered = renderPart(part, idx, false);
return attribution != null ? [attribution, rendered] : [rendered];
})}
{/* Parallel sections - each group renders as columns */}
{parallelSections.map(({ groupId, columns }) => (
@ -267,7 +279,11 @@ export const ParallelContentRenderer = memo(function ParallelContentRenderer({
))}
{/* Sequential content AFTER parallel sections */}
{after.map(({ part, idx }) => renderPart(part, idx, idx === lastContentIdx))}
{after.flatMap(({ part, idx }) => {
const attribution = renderResumeAttribution?.(idx);
const rendered = renderPart(part, idx, idx === lastContentIdx);
return attribution != null ? [attribution, rendered] : [rendered];
})}
</SearchContext.Provider>
);
});

View file

@ -0,0 +1,34 @@
import { memo } from 'react';
import { useAtomValue } from 'jotai';
import type { ReactNode } from 'react';
import { fontSizeAtom } from '~/store/fontSize';
import { cn } from '~/utils';
/**
* Re-attributes response content to its author mid-message. A `SteerPart`
* renders a full user turn inside the response, so the parts that resume
* after it need the author's icon and label restated the message-level
* header only renders once, above the first part. Outdented past the icon
* column (like `SteerPart`) so it aligns with the top-level header.
*/
const AuthorHeader = memo(function AuthorHeader({
icon,
label,
}: {
icon: ReactNode;
label: string;
}) {
const fontSize = useAtomValue(fontSizeAtom);
return (
<div className="relative -ml-9 flex w-[calc(100%+2.25rem)] gap-3" data-testid="author-header">
<div className="relative flex flex-shrink-0 flex-col items-center">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
{icon}
</div>
</div>
<h2 className={cn('select-none font-semibold text-text-primary', fontSize)}>{label}</h2>
</div>
);
});
export default AuthorHeader;

View file

@ -15,3 +15,4 @@ export { default as FileAuthoringCall } from './FileAuthoringCall';
export { default as BashCall } from './BashCall';
export { default as SubagentCall } from './SubagentCall';
export { default as SteerPart } from './SteerPart';
export { default as AuthorHeader } from './AuthorHeader';

View file

@ -1,4 +1,4 @@
import { Suspense, useMemo } from 'react';
import { Suspense, useMemo, Fragment } from 'react';
import { useRecoilValue } from 'recoil';
import { DelayedRender } from '@librechat/client';
import { ContentTypes } from 'librechat-data-provider';
@ -9,10 +9,12 @@ import type {
SearchResultData,
TMessageContentParts,
} from 'librechat-data-provider';
import type { ReactNode, ReactElement } from 'react';
import { UnfinishedMessage } from './MessageContent';
import { cn, mapAttachments } from '~/utils';
import { SearchContext } from '~/Providers';
import MarkdownLite from './MarkdownLite';
import { AgentUpdate } from './Parts';
import store from '~/store';
import Part from './Part';
@ -20,10 +22,13 @@ const SearchContent = ({
message,
attachments,
searchResults,
authorHeader,
}: {
message: TMessage;
attachments?: TAttachment[];
searchResults?: { [key: string]: SearchResultData };
/** Author icon + label re-rendered before content that resumes after an inline steer. */
authorHeader?: ReactNode;
}) => {
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
const { messageId } = message;
@ -31,29 +36,51 @@ const SearchContent = ({
const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]);
if (Array.isArray(message.content) && message.content.length > 0) {
const parts = message.content.filter((part): part is TMessageContentParts => part != null);
/** Active agent from the latest preceding AGENT_UPDATE: post-steer content
* after a handoff belongs to that agent, not the message-level author.
* Captured BEFORE the current part's own handoff applies, mirroring
* `ContentParts`' `postSteerAuthors` scan. */
let activeAgentId: string | undefined;
return (
<SearchContext.Provider value={{ searchResults }}>
{message.content
.filter((part: TMessageContentParts | undefined) => part)
.map((part: TMessageContentParts | undefined, idx: number) => {
if (!part) {
return null;
}
const toolCallId =
(part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
const partAttachments = attachmentMap[toolCallId];
return (
<Part
key={`display-${messageId}-${idx}`}
showCursor={false}
isSubmitting={false}
isCreatedByUser={message.isCreatedByUser}
attachments={partAttachments}
part={part}
/>
);
})}
{parts.map((part: TMessageContentParts, idx: number) => {
const toolCallId =
(part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
const partAttachments = attachmentMap[toolCallId];
const resumesAfterSteer =
authorHeader != null &&
idx > 0 &&
parts[idx - 1].type === ContentTypes.STEER &&
part.type !== ContentTypes.STEER;
const resumeAgentId = resumesAfterSteer ? activeAgentId : undefined;
if (part.type === ContentTypes.AGENT_UPDATE) {
activeAgentId = part[ContentTypes.AGENT_UPDATE]?.agentId || undefined;
}
const rendered: ReactElement = (
<Part
key={`display-${messageId}-${idx}`}
showCursor={false}
isSubmitting={false}
isCreatedByUser={message.isCreatedByUser}
attachments={partAttachments}
part={part}
/>
);
if (!resumesAfterSteer) {
return rendered;
}
return (
<Fragment key={`display-${messageId}-${idx}`}>
{resumeAgentId != null ? (
<AgentUpdate currentAgentId={resumeAgentId} />
) : (
authorHeader
)}
{rendered}
</Fragment>
);
})}
{message.unfinished === true && (
<Suspense>
<DelayedRender delay={250}>

View file

@ -22,6 +22,9 @@ jest.mock('~/Providers', () => ({
jest.mock('../Parts', () => ({
EditTextPart: () => <div data-testid="edit-text-part" />,
EmptyText: () => <div data-testid="empty-text" />,
AgentUpdate: ({ currentAgentId }: { currentAgentId: string }) => (
<div data-testid="post-steer-agent-update" data-agent-id={currentAgentId} />
),
}));
jest.mock('../MemoryArtifacts', () => ({
@ -56,7 +59,19 @@ jest.mock('../Part', () => ({
}));
jest.mock('../ParallelContent', () => ({
ParallelContentRenderer: () => <div data-testid="parallel-renderer" />,
/** Invokes `renderResumeAttribution` per content index like the real
* renderer does for its sequential stretches, so the wiring is testable. */
ParallelContentRenderer: ({
content,
renderResumeAttribution,
}: {
content?: Array<TMessageContentParts | undefined>;
renderResumeAttribution?: (idx: number) => React.ReactNode;
}) => (
<div data-testid="parallel-renderer">
{content?.map((_, idx) => renderResumeAttribution?.(idx))}
</div>
),
}));
import ContentParts from '../ContentParts';
@ -139,3 +154,108 @@ describe('ContentParts — interim skill cards', () => {
expect(skillCard.compareDocumentPosition(textPart)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
});
});
describe('ContentParts — post-steer author re-attribution', () => {
const steerPart = {
type: ContentTypes.STEER,
steer: 'go left',
} as unknown as TMessageContentParts;
const textPart = (text: string) =>
({ type: ContentTypes.TEXT, text }) as unknown as TMessageContentParts;
const header = <div data-testid="author-header" />;
it('re-renders the author header between a steer and the content that resumes after it', () => {
render(
<ContentParts
{...baseProps}
content={[textPart('a'), steerPart, textPart('b')]}
authorHeader={header}
/>,
);
const headers = screen.getAllByTestId('author-header');
expect(headers).toHaveLength(1);
const steer = screen.getByTestId(`real-part-${ContentTypes.STEER}`);
const textParts = screen.getAllByTestId(`real-part-${ContentTypes.TEXT}`);
expect(steer.compareDocumentPosition(headers[0])).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(headers[0].compareDocumentPosition(textParts[1])).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
});
it('renders one header per steer block and none after a trailing steer', () => {
render(
<ContentParts
{...baseProps}
content={[textPart('a'), steerPart, steerPart, textPart('b'), steerPart]}
authorHeader={header}
/>,
);
expect(screen.getAllByTestId('author-header')).toHaveLength(1);
});
it('skips empty content slots when finding the part that resumes after a steer', () => {
render(
<ContentParts
{...baseProps}
content={[steerPart, undefined, textPart('b')]}
authorHeader={header}
/>,
);
expect(screen.getAllByTestId('author-header')).toHaveLength(1);
});
it('renders no header when authorHeader is not provided', () => {
render(<ContentParts {...baseProps} content={[textPart('a'), steerPart, textPart('b')]} />);
expect(screen.queryByTestId('author-header')).toBeNull();
});
it('re-attributes to the ACTIVE agent when a handoff preceded the steer', () => {
const agentUpdate = {
type: ContentTypes.AGENT_UPDATE,
[ContentTypes.AGENT_UPDATE]: { agentId: 'agent_b', index: 1 },
} as unknown as TMessageContentParts;
render(
<ContentParts
{...baseProps}
content={[textPart('a'), agentUpdate, textPart('b'), steerPart, textPart('c')]}
authorHeader={header}
/>,
);
const marker = screen.getAllByTestId('post-steer-agent-update');
expect(marker).toHaveLength(1);
expect(marker[0]).toHaveAttribute('data-agent-id', 'agent_b');
expect(screen.queryByTestId('author-header')).toBeNull();
});
it('provides resume attribution to the parallel renderer for its sequential stretches', () => {
const parallelText = {
type: ContentTypes.TEXT,
text: 'column',
groupId: 1,
} as unknown as TMessageContentParts;
render(
<ContentParts
{...baseProps}
content={[parallelText, steerPart, textPart('resumed')]}
authorHeader={header}
/>,
);
const renderer = screen.getByTestId('parallel-renderer');
expect(renderer).toBeTruthy();
expect(screen.getAllByTestId('author-header')).toHaveLength(1);
});
it('keeps the top-level header when the handoff comes AFTER the steer', () => {
const agentUpdate = {
type: ContentTypes.AGENT_UPDATE,
[ContentTypes.AGENT_UPDATE]: { agentId: 'agent_b', index: 2 },
} as unknown as TMessageContentParts;
render(
<ContentParts
{...baseProps}
content={[textPart('a'), steerPart, agentUpdate, textPart('b')]}
authorHeader={header}
/>,
);
expect(screen.getAllByTestId('author-header')).toHaveLength(1);
expect(screen.queryByTestId('post-steer-agent-update')).toBeNull();
});
});

View file

@ -10,6 +10,7 @@ import {
getHeaderPrefixForScreenReader,
} from '~/utils';
import { useMessageHelpers, useLocalize, useAttachments, useContentMetadata } from '~/hooks';
import AuthorHeader from '~/components/Chat/Messages/Content/Parts/AuthorHeader';
import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp';
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
import ContentParts from './Content/ContentParts';
@ -79,6 +80,17 @@ function MessageParts(props: TMessageProps) {
],
);
const authorHeader = useMemo(
() =>
isCreatedByUser === true ? undefined : (
<AuthorHeader
icon={<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />}
label={name}
/>
),
[isCreatedByUser, iconData, assistant, agent, name],
);
const { hasParallelContent } = useContentMetadata(message);
if (!message) {
@ -153,6 +165,7 @@ function MessageParts(props: TMessageProps) {
searchResults={searchResults}
manualSkills={message.manualSkills}
messageId={message.messageId}
authorHeader={authorHeader}
setSiblingIdx={setSiblingIdx}
isCreatedByUser={message.isCreatedByUser}
conversationId={conversation?.conversationId}

View file

@ -3,6 +3,7 @@ import { useAtomValue } from 'jotai';
import { useRecoilValue } from 'recoil';
import type { TMessage } from 'librechat-data-provider';
import type { TMessageProps, TMessageIcon } from '~/common';
import AuthorHeader from '~/components/Chat/Messages/Content/Parts/AuthorHeader';
import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons';
import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp';
import Icon from '~/components/Chat/Messages/MessageIcon';
@ -24,7 +25,7 @@ const MessageAvatar = ({ iconData }: { iconData: TMessageIcon }) => (
</div>
);
const MessageBody = ({ message, messageLabel, fontSize }) => (
const MessageBody = ({ message, messageLabel, fontSize, authorHeader }) => (
<div
className={cn('relative flex w-11/12 flex-col', message.isCreatedByUser ? '' : 'agent-turn')}
>
@ -32,7 +33,7 @@ const MessageBody = ({ message, messageLabel, fontSize }) => (
{messageLabel}
<MessageTimestamp value={message.createdAt ?? message.clientTimestamp} />
</div>
<SearchContent message={message} />
<SearchContent message={message} authorHeader={authorHeader} />
<SubRow classes="text-xs">
<MinimalHoverButtons message={message} />
<SearchButtons message={message} />
@ -124,6 +125,14 @@ function SearchMessage({ message }: Pick<TMessageProps, 'message'>) {
localize,
]);
const authorHeader = useMemo(
() =>
message?.isCreatedByUser === true ? undefined : (
<AuthorHeader icon={<Icon iconData={iconData} />} label={messageLabel} />
),
[message?.isCreatedByUser, iconData, messageLabel],
);
if (!message) {
return null;
}
@ -133,7 +142,12 @@ function SearchMessage({ message }: Pick<TMessageProps, 'message'>) {
<div className="m-auto p-4 py-2 md:gap-6">
<div className="final-completion group mx-auto flex flex-1 gap-3 md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
<MessageAvatar iconData={iconData} />
<MessageBody message={message} messageLabel={messageLabel} fontSize={fontSize} />
<MessageBody
message={message}
messageLabel={messageLabel}
fontSize={fontSize}
authorHeader={authorHeader}
/>
</div>
</div>
</div>

View file

@ -10,6 +10,7 @@ import {
getMessageAriaLabel,
} from '~/utils';
import { useAttachments, useLocalize, useMessageActions, useContentMetadata } from '~/hooks';
import AuthorHeader from '~/components/Chat/Messages/Content/Parts/AuthorHeader';
import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp';
import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow';
@ -131,6 +132,17 @@ const ContentRender = memo(function ContentRender({
],
);
const authorHeader = useMemo(
() =>
msg?.isCreatedByUser === true ? undefined : (
<AuthorHeader
icon={<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />}
label={messageLabel ?? ''}
/>
),
[msg?.isCreatedByUser, iconData, assistant, agent, messageLabel],
);
const { hasParallelContent } = useContentMetadata(msg);
if (!msg) {
@ -201,6 +213,7 @@ const ContentRender = memo(function ContentRender({
attachments={attachments}
searchResults={searchResults}
manualSkills={msg.manualSkills}
authorHeader={authorHeader}
setSiblingIdx={setSiblingIdx}
isLatestMessage={isLatestMessage}
isSubmitting={isSubmitting}

View file

@ -1,5 +1,6 @@
import { useAtomValue } from 'jotai';
import type { TMessageProps } from '~/common';
import AuthorHeader from '~/components/Chat/Messages/Content/Parts/AuthorHeader';
import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons';
import MessageContent from '~/components/Chat/Messages/Content/MessageContent';
import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp';
@ -86,6 +87,14 @@ export default function Message(props: TMessageProps) {
message={message}
attachments={attachments}
searchResults={searchResults}
authorHeader={
isCreatedByUser ? undefined : (
<AuthorHeader
icon={<Icon message={message} conversation={conversation} />}
label={messageLabel}
/>
)
}
/>
) : (
<MessageContent