From c4d30a096ed55f0041313ccca899055ca5495e9b Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 28 Jul 2026 23:26:48 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20fix:=20Re-attribute=20A?= =?UTF-8?q?gent=20Content=20After=20In-Thread=20Steers=20(#14497)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🏷️ 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 --- .../Chat/Messages/Content/ContentParts.tsx | 75 +++++++++-- .../Chat/Messages/Content/ParallelContent.tsx | 20 ++- .../Messages/Content/Parts/AuthorHeader.tsx | 34 +++++ .../Chat/Messages/Content/Parts/index.ts | 1 + .../Chat/Messages/Content/SearchContent.tsx | 71 ++++++---- .../Content/__tests__/ContentParts.test.tsx | 122 +++++++++++++++++- .../components/Chat/Messages/MessageParts.tsx | 13 ++ .../Chat/Messages/SearchMessage.tsx | 20 ++- .../src/components/Messages/ContentRender.tsx | 13 ++ client/src/components/Share/Message.tsx | 9 ++ 10 files changed, 338 insertions(+), 40 deletions(-) create mode 100644 client/src/components/Chat/Messages/Content/Parts/AuthorHeader.tsx diff --git a/client/src/components/Chat/Messages/Content/ContentParts.tsx b/client/src/components/Chat/Messages/Content/ContentParts.tsx index 5b60b534c2..d017778585 100644 --- a/client/src/components/Chat/Messages/Content/ContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/ContentParts.tsx @@ -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(() => { + /** `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(); 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 ; + } + return {authorHeader}; + }, + [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} /> ); @@ -413,13 +456,20 @@ const ContentParts = memo(function ContentParts({ )} - {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( handleGroupExpansionChange(groupId, state)} - /> + />, ); + return nodes; })} diff --git a/client/src/components/Chat/Messages/Content/ParallelContent.tsx b/client/src/components/Chat/Messages/Content/ParallelContent.tsx index b1d75c86ef..ea1805737a 100644 --- a/client/src/components/Chat/Messages/Content/ParallelContent.tsx +++ b/client/src/components/Chat/Messages/Content/ParallelContent.tsx @@ -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({ {/* 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]; + })} ); }); diff --git a/client/src/components/Chat/Messages/Content/Parts/AuthorHeader.tsx b/client/src/components/Chat/Messages/Content/Parts/AuthorHeader.tsx new file mode 100644 index 0000000000..189c9a9268 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Parts/AuthorHeader.tsx @@ -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 ( +
+
+
+ {icon} +
+
+

{label}

+
+ ); +}); + +export default AuthorHeader; diff --git a/client/src/components/Chat/Messages/Content/Parts/index.ts b/client/src/components/Chat/Messages/Content/Parts/index.ts index 9459572b10..68da02ac1b 100644 --- a/client/src/components/Chat/Messages/Content/Parts/index.ts +++ b/client/src/components/Chat/Messages/Content/Parts/index.ts @@ -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'; diff --git a/client/src/components/Chat/Messages/Content/SearchContent.tsx b/client/src/components/Chat/Messages/Content/SearchContent.tsx index 325aae89c8..7675be7c42 100644 --- a/client/src/components/Chat/Messages/Content/SearchContent.tsx +++ b/client/src/components/Chat/Messages/Content/SearchContent.tsx @@ -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 ( - {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 ( - - ); - })} + {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 = ( + + ); + if (!resumesAfterSteer) { + return rendered; + } + return ( + + {resumeAgentId != null ? ( + + ) : ( + authorHeader + )} + {rendered} + + ); + })} {message.unfinished === true && ( diff --git a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx index 4f34e75fd7..63d29a47bb 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.test.tsx @@ -22,6 +22,9 @@ jest.mock('~/Providers', () => ({ jest.mock('../Parts', () => ({ EditTextPart: () =>
, EmptyText: () =>
, + AgentUpdate: ({ currentAgentId }: { currentAgentId: string }) => ( +
+ ), })); jest.mock('../MemoryArtifacts', () => ({ @@ -56,7 +59,19 @@ jest.mock('../Part', () => ({ })); jest.mock('../ParallelContent', () => ({ - ParallelContentRenderer: () =>
, + /** Invokes `renderResumeAttribution` per content index like the real + * renderer does for its sequential stretches, so the wiring is testable. */ + ParallelContentRenderer: ({ + content, + renderResumeAttribution, + }: { + content?: Array; + renderResumeAttribution?: (idx: number) => React.ReactNode; + }) => ( +
+ {content?.map((_, idx) => renderResumeAttribution?.(idx))} +
+ ), })); 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 =
; + + it('re-renders the author header between a steer and the content that resumes after it', () => { + render( + , + ); + 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( + , + ); + expect(screen.getAllByTestId('author-header')).toHaveLength(1); + }); + + it('skips empty content slots when finding the part that resumes after a steer', () => { + render( + , + ); + expect(screen.getAllByTestId('author-header')).toHaveLength(1); + }); + + it('renders no header when authorHeader is not provided', () => { + render(); + 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( + , + ); + 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( + , + ); + 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( + , + ); + expect(screen.getAllByTestId('author-header')).toHaveLength(1); + expect(screen.queryByTestId('post-steer-agent-update')).toBeNull(); + }); +}); diff --git a/client/src/components/Chat/Messages/MessageParts.tsx b/client/src/components/Chat/Messages/MessageParts.tsx index 4798bcd9a0..95eb5e711a 100644 --- a/client/src/components/Chat/Messages/MessageParts.tsx +++ b/client/src/components/Chat/Messages/MessageParts.tsx @@ -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 : ( + } + 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} diff --git a/client/src/components/Chat/Messages/SearchMessage.tsx b/client/src/components/Chat/Messages/SearchMessage.tsx index e5daace2b7..6405f7bff8 100644 --- a/client/src/components/Chat/Messages/SearchMessage.tsx +++ b/client/src/components/Chat/Messages/SearchMessage.tsx @@ -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 }) => (
); -const MessageBody = ({ message, messageLabel, fontSize }) => ( +const MessageBody = ({ message, messageLabel, fontSize, authorHeader }) => (
@@ -32,7 +33,7 @@ const MessageBody = ({ message, messageLabel, fontSize }) => ( {messageLabel}
- + @@ -124,6 +125,14 @@ function SearchMessage({ message }: Pick) { localize, ]); + const authorHeader = useMemo( + () => + message?.isCreatedByUser === true ? undefined : ( + } label={messageLabel} /> + ), + [message?.isCreatedByUser, iconData, messageLabel], + ); + if (!message) { return null; } @@ -133,7 +142,12 @@ function SearchMessage({ message }: Pick) {
- +
diff --git a/client/src/components/Messages/ContentRender.tsx b/client/src/components/Messages/ContentRender.tsx index f8429113de..0eb73c5e47 100644 --- a/client/src/components/Messages/ContentRender.tsx +++ b/client/src/components/Messages/ContentRender.tsx @@ -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 : ( + } + 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} diff --git a/client/src/components/Share/Message.tsx b/client/src/components/Share/Message.tsx index 3492054698..55a4969e98 100644 --- a/client/src/components/Share/Message.tsx +++ b/client/src/components/Share/Message.tsx @@ -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 : ( + } + label={messageLabel} + /> + ) + } /> ) : (