diff --git a/client/src/components/Chat/Messages/Content/ContentParts.tsx b/client/src/components/Chat/Messages/Content/ContentParts.tsx index 75d6c5ecf1..af665dbf99 100644 --- a/client/src/components/Chat/Messages/Content/ContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/ContentParts.tsx @@ -8,6 +8,7 @@ import type { } from 'librechat-data-provider'; import type { ReactNode, ReactElement } from 'react'; import type { ToolCallGroupExpansionState } from './ToolCallGroup'; +import type { ActivityPhaseSegment } from '~/utils/activityLabels'; import { mapAttachments, getPartKeyIndex, @@ -216,6 +217,11 @@ type ContentPartsProps = { nestedActivityPhase?: boolean; /** Internal signal that this segment renders inside a completed phase card. */ withinActivityPhase?: boolean; + /** Internal signal that a phase card already carries this message's + * streaming cursor. A solitary empty provider slot looks like the initial + * waiting state from inside its own segment, so without this it renders a + * second cursor beside the one already on screen. */ + cursorOwnedElsewhere?: boolean; /** Internal signal that the parent already removed message-level workspace attachments. */ workspaceAttachmentsPartitioned?: boolean; /** Absolute transcript index represented by `content[0]` in a phase slice. */ @@ -257,6 +263,7 @@ const ContentPartsBody = memo(function ContentPartsBody({ createdAt, nestedActivityPhase = false, withinActivityPhase = false, + cursorOwnedElsewhere = false, workspaceAttachmentsPartitioned = false, contentIndexOffset = 0, contentIndices, @@ -328,13 +335,20 @@ const ContentPartsBody = memo(function ContentPartsBody({ const labels = new Map(); const ordinals = new Map(); for (const segment of phaseSegments ?? []) { - if (segment.type === 'phase') { - const text = getActivityLabelText(segment.labelPart); - const occurrence = (labels.get(text) ?? 0) + 1; - indices.add(getPartKeyIndex(segment.labelPart, segment.labelIndex)); - labels.set(text, occurrence); - ordinals.set(getPartKeyIndex(segment.labelPart, segment.labelIndex), occurrence); + if (segment.type !== 'phase') { + continue; } + /** Synthesized cards are excluded: their header key moves with the + * ticker, and a moving key reads here as a re-key — which would + * suppress the entrance fold on every real marker after it. */ + if (segment.synthesized === true) { + continue; + } + const text = getActivityLabelText(segment.labelPart); + const occurrence = (labels.get(text) ?? 0) + 1; + indices.add(getPartKeyIndex(segment.labelPart, segment.labelIndex)); + labels.set(text, occurrence); + ordinals.set(getPartKeyIndex(segment.labelPart, segment.labelIndex), occurrence); } return { indices, labels, ordinals }; }, [phaseSegments]); @@ -676,12 +690,55 @@ const ContentPartsBody = memo(function ContentPartsBody({ } return absoluteIndexAt(segment.startIndex); }; + /** Stable render identity for a phase card. See the key comment below. + * + * Anchored to the first TOOL CALL, not the span's first part, for the + * reason `getToolGroupId` gives: an empty leading TEXT or THINK is + * DROPPED by `preserveStreamedContentIdentity` at settle, which would + * move a first-part anchor and remount the card exactly when the run + * finishes. The tool calls themselves do not move. */ + const phaseCardKey = (segment: Extract): string => { + for (let position = 0; position < segment.content.length; position += 1) { + const part = segment.content[position]; + const toolCallId = part == null ? '' : getToolCallId(part); + if (toolCallId) { + const anchor = getPartKeyIndex(part, absoluteIndexAt(segment.contentIndices[position])); + return `${toolCallId}:${anchor}`; + } + } + /** No provider id in the span at all — id-less legacy calls, + * reasoning-only, or children compacted away. A bare position cannot + * separate two siblings whose cards start at the same index, so this + * branch carries the message-change counter, exactly as + * `getToolGroupId` does for its own id-less fallback. The tradeoff is + * the same one accepted there: the counter also advances at settle, so + * these cards remount then. */ + const position = segment.hasContent + ? segmentKeyIndex(segment) + : getPartKeyIndex(segment.labelPart, segment.labelIndex); + return `fallback:${fallbackScope}:${position}`; + }; + /** Exactly one thing may hold the streaming cursor. A card carries it + * below its own header whenever the tail of the run sits inside its + * span, which puts every sibling segment out of the running. */ + const cursorOwnedByCard = + isLast && + effectiveIsSubmitting && + phaseSegments.some((segment) => { + if (segment.type !== 'phase') { + return false; + } + return segment.synthesized === true + ? segment.contentIndices.map(absoluteIndexAt).includes(globalLastContentIdx) + : absoluteIndexAt(segment.labelIndex) === globalLastContentIdx; + }); const renderSegment = ( segmentContent: Array, segmentStartIndex: number, segmentIndices: ReadonlyArray, key: string, withinPhase = false, + ownsCursor = false, ) => { return ( part != null && hasPendingApprovalInPart(part), + ); + /** `ToolCallGroup` hoists `groupAttachments` OUTSIDE its own panel + * precisely so a generated image or file survives collapsing the + * group. Folding that group into a card puts the hoist back + * inside a disclosure and the output vanishes — and these never + * appear in `segment.content`, so the visible-part allowlist + * cannot catch them. */ + const hasSpanAttachments = segment.content.some( + (part) => + part != null && + (filterAttachmentsForPart( + attachmentMap[getToolCallId(part)], + getPartAgentId(part), + getPartStepId(part), + getSiblingStepIds(part, resolvedToolCallStepOwners), + )?.length ?? 0) > 0, + ); + /** A fold summarizes work that is DONE. An unresolved approval is + * the run asking the reader for something and blocking until it + * gets it — never put that behind a disclosure they have to find. + * A server marker cannot hit this (it is emitted after the batch + * resolves), but a subagent nested in an already-labeled group + * can raise one long after its parent's label filled. The span + * renders exactly as it would without the feature until it + * clears, then folds. The same escape covers hoisted tool output. */ + if (synthesized && (hasPendingApproval || hasSpanAttachments)) { + return renderSegment( + segment.content, + absoluteIndexAt(segment.startIndex), + segmentIndices, + `phase-awaiting-${cardKey}`, + ); + } return ( part != null && hasPendingApprovalInPart(part), - )} + hasPendingApproval={hasPendingApproval} animateEntrance={ + /** Never for a synthesized card. The entrance mounts a card + * OPEN and folds it shut, and this component remounts + * mid-run — the key carries `messageId`, which changes when + * the placeholder hydrates to the server id, and a reconnect + * mounts straight onto existing labeled content. Either + * would flash the whole fold back open. Fold GROWTH is + * already unanimated, so formation matching it is the + * consistent behavior, not a downgrade. */ + !synthesized && previousPhases != null && !previousPhases.indices.has(phaseKeyIndex) && (!hasPhaseRekey || @@ -745,17 +877,20 @@ const ContentPartsBody = memo(function ContentPartsBody({ (previousPhases.labels.get(labelText) ?? 0)) } showCursor={ - isLast && - effectiveIsSubmitting && - absoluteIndexAt(segment.labelIndex) === globalLastContentIdx + synthesized + ? ownsCursor + : isLast && + effectiveIsSubmitting && + absoluteIndexAt(segment.labelIndex) === globalLastContentIdx } > {renderSegment( segment.content, absoluteIndexAt(segment.startIndex), - segment.contentIndices.map(absoluteIndexAt), - `phase-content-${phaseKeyIndex}`, + segmentIndices, + `phase-content-${cardKey}`, true, + ownsCursor, )} ); @@ -773,7 +908,10 @@ const ContentPartsBody = memo(function ContentPartsBody({ * flows share the gated header-axis nudge. Never solitary mid-stream, so * empty TEXT after real parts keeps its flush in-flow cursor. */ const solitaryEmptyText = safeContent.length === 1 && isEmptyTextPart(safeContent[0]); - const showEmptyCursor = (safeContent.length === 0 || solitaryEmptyText) && effectiveIsSubmitting; + const showEmptyCursor = + (safeContent.length === 0 || solitaryEmptyText) && + effectiveIsSubmitting && + !cursorOwnedElsewhere; /** Skips trailing blank label reservations and empty provider placeholders, * keeping the cursor attached to the last visible output. */ const relativeLastContentIdx = lastCursorContentIdx(safeContent); diff --git a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.integration.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.integration.test.tsx index c0158e1198..b7ee48d449 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.integration.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.integration.test.tsx @@ -480,3 +480,347 @@ describe('ContentParts integration: MCP image hoist and grouping', () => { ); }); }); + +describe('ContentParts — synthesized activity folds', () => { + /** Settled by default: the fold is content-derived, so history and a live + * run partition identically. The streaming case gets its own test below. */ + const baseProps = { + messageId: 'msg1', + isCreatedByUser: false, + isLast: true, + isSubmitting: false, + isLatestMessage: true, + }; + + const makeChildLabel = (label: string): TMessageContentParts => + ({ + type: ContentTypes.ACTIVITY_LABEL, + [ContentTypes.ACTIVITY_LABEL]: label, + pending: false, + }) as unknown as TMessageContentParts; + + const TICKER = 'Confirmed the fix'; + const FIRST = 'Found 43 available GitHub tools'; + + const labeledRun = () => [ + makeMcpToolCall('t1'), + makeChildLabel(FIRST), + makeMcpToolCall('t2'), + makeChildLabel(TICKER), + ]; + + /** The card's header and the last sub-group's header carry the same text by + * design — the ticker IS that sub-group's line. The card is the outer one. */ + const foldHeader = () => screen.getAllByRole('button', { name: TICKER })[0]; + + it('folds a labeled run into one collapsed card headed by the newest label', () => { + renderContentParts({ ...baseProps, content: labeledRun() }); + + expect(foldHeader()).toHaveAttribute('aria-expanded', 'false'); + /** Collapsed means unmounted, so the earlier row is not merely hidden. */ + expect(screen.queryByRole('button', { name: FIRST })).toBeNull(); + expect(screen.getAllByRole('button', { name: TICKER })).toHaveLength(1); + }); + + it('reveals the sub-groups, each still collapsed, when the card is opened', () => { + renderContentParts({ ...baseProps, content: labeledRun() }); + + fireEvent.click(foldHeader()); + + expect(screen.getByRole('button', { name: FIRST })).toHaveAttribute('aria-expanded', 'false'); + expect(screen.getAllByRole('button', { name: TICKER })[1]).toHaveAttribute( + 'aria-expanded', + 'false', + ); + }); + + it('leaves the in-flight tool call outside the card', () => { + renderContentParts({ + ...baseProps, + isSubmitting: true, + content: [...labeledRun(), makeMcpToolCall('t3', false)], + }); + + /** The call the reader is watching stays a sibling of the card, so it is + * still on screen once the fold settles shut over everything before it. */ + const panel = screen.getByTestId('activity-phase-panel'); + expect(panel.contains(screen.getByTestId('tool-call'))).toBe(false); + }); + + it('keeps the reader’s toggle when the ticker advances', () => { + const { rerender } = render( + + + , + ); + fireEvent.click(foldHeader()); + + rerender( + + + , + ); + + expect(screen.getAllByRole('button', { name: 'Checked the callers' })[0]).toHaveAttribute( + 'aria-expanded', + 'true', + ); + }); + + it('keeps the reader’s toggle when a server summary replaces the ticker', () => { + const { rerender } = render( + + + , + ); + fireEvent.click(foldHeader()); + + rerender( + + + , + ); + + expect(screen.getByRole('button', { name: 'Reviewed the release paths' })).toHaveAttribute( + 'aria-expanded', + 'true', + ); + }); + + it('does not replay the entrance fold when the summary replaces the ticker', () => { + const streaming = { ...baseProps, isSubmitting: true }; + const { rerender } = render( + + + , + ); + + rerender( + + + , + ); + + /** The entrance mounts a card OPEN and folds it shut over the next two + * painted frames. Playing it here would flash every sub-group back open + * on top of a card the reader already watched fold. */ + expect(screen.getByRole('button', { name: 'Reviewed the release paths' })).toHaveAttribute( + 'aria-expanded', + 'false', + ); + }); + + it('ticks the header forward as each sub-group resolves', () => { + const steps = ['Read the config', 'Checked the callers', 'Confirmed the fix']; + const content: TMessageContentParts[] = []; + const { rerender } = render( + + + , + ); + + const headers: string[] = []; + steps.forEach((step, index) => { + content.push(makeMcpToolCall(`t${index}`), makeChildLabel(step)); + rerender( + + + , + ); + const panel = screen.queryByTestId('activity-phase-panel'); + if (panel == null) { + return; + } + const card = panel.parentElement?.querySelector('button'); + headers.push(card?.getAttribute('aria-label') ?? ''); + expect(card).toHaveAttribute('aria-expanded', 'false'); + }); + + /** One card from the second activity on, its line always the newest — + * and never re-opening underneath the reader as the run advances. */ + expect(headers).toEqual(['Checked the callers', 'Confirmed the fix']); + }); + + it('refuses to fold a span holding an unresolved approval', () => { + const awaiting = { + type: ContentTypes.TOOL_CALL, + [ContentTypes.TOOL_CALL]: { + id: 't2', + name: `getTinyImage${MCP_DELIMITER}Everything`, + args: '{}', + output: '', + approval: { state: 'pending' }, + }, + } as unknown as TMessageContentParts; + + renderContentParts({ + ...baseProps, + content: [makeMcpToolCall('t1'), makeChildLabel(FIRST), awaiting, makeChildLabel(TICKER)], + }); + + /** No card: the run is blocked on the reader, and the request must not sit + * behind a disclosure they have to discover. */ + expect(screen.queryByTestId('activity-phase-panel')).toBeNull(); + expect(screen.getByRole('button', { name: FIRST })).toBeInTheDocument(); + }); + + it('never mounts a fold expanded while the run is live', () => { + /** The entrance mounts a card OPEN and folds it shut. This component + * remounts mid-run — `messageId` is in the key and changes when the + * placeholder hydrates to the server id — so an entrance here would flash + * the whole fold back open partway through the run. */ + renderContentParts({ ...baseProps, isSubmitting: true, content: labeledRun() }); + + expect(foldHeader()).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByRole('button', { name: FIRST })).toBeNull(); + }); + + it('keeps the toggle even when the summary claims more content than the fold', () => { + /** The server extends a phase back across short intermediate text that the + * client treats as a boundary, so the two spans start in different + * places. They still share their first tool call, which is what the card + * is anchored to — so this is the same card gaining content, not a new + * one, and the reader's choice rides through. */ + const preface = makeTextPart('Checking now.'); + const { rerender } = render( + + + , + ); + fireEvent.click(foldHeader()); + + rerender( + + + , + ); + + expect(screen.getByRole('button', { name: 'Reviewed the release paths' })).toHaveAttribute( + 'aria-expanded', + 'true', + ); + }); + + it('refuses to fold a span whose tool output is hoisted outside its group', () => { + /** `ToolCallGroup` renders `groupAttachments` outside its own panel so a + * generated image survives collapsing the group. A fold would put that + * hoist back inside a disclosure. */ + renderContentParts({ + ...baseProps, + content: labeledRun(), + attachments: [imageAttachment('t2', 'chart.png')], + }); + + expect(screen.queryByTestId('activity-phase-panel')).toBeNull(); + expect(screen.getByRole('button', { name: FIRST })).toBeInTheDocument(); + }); + + it('keeps the reader’s card open when the message takes its server id', () => { + /** The assistant streams under a synthetic `_` for the whole run + * and only takes its real id at finalize. A key carrying `messageId` + * would remount every card at that moment and drop the toggle. */ + const { rerender } = render( + + + , + ); + fireEvent.click(foldHeader()); + + rerender( + + + , + ); + + expect(foldHeader()).toHaveAttribute('aria-expanded', 'true'); + }); + + it('resets the card when the reader pages to another sibling', () => { + /** `MultiMessage` reuses this instance across siblings, so a card anchored + * only to a content index would carry one response's open state into an + * unrelated one. The provider id on the span's first call separates them. + * No message identity can: `messageId` moves at settle too. */ + const otherSibling = [ + makeMcpToolCall('other-1'), + makeChildLabel(FIRST), + makeMcpToolCall('other-2'), + makeChildLabel(TICKER), + ]; + const { rerender } = render( + + + , + ); + fireEvent.click(foldHeader()); + + rerender( + + + , + ); + + expect(foldHeader()).toHaveAttribute('aria-expanded', 'false'); + }); + + it('survives background churn that renumbers the response in place', () => { + /** `MultiMessage` recomputes the viewed response's positional index when a + * sibling is added or dropped around it — dropping an optimistic row + * moves the newest response from index 1 to 0 while the reader stays on + * it. Nothing about the response changed, so neither should the card. */ + const { rerender } = render( + + + , + ); + fireEvent.click(foldHeader()); + + rerender( + + + , + ); + + expect(foldHeader()).toHaveAttribute('aria-expanded', 'true'); + }); + + it('shows exactly one streaming cursor when a placeholder trails the fold', () => { + /** Providers append an empty TEXT slot after visible output. The cursor + * belongs to the card (the run's tail is inside its span), and that + * trailing slot looks like the initial waiting state from inside its own + * segment — so without the ownership signal both render one. */ + renderContentParts({ + ...baseProps, + isSubmitting: true, + content: [...labeledRun(), makeTextPart('')], + }); + + expect(screen.getAllByTestId('empty-text')).toHaveLength(1); + }); + + it('leaves an unlabeled run rendering exactly as before', () => { + renderContentParts({ + ...baseProps, + content: [makeMcpToolCall('t1'), makeMcpToolCall('t2')], + }); + + expect(screen.getByRole('button', { name: 'Used 2 tools' })).toBeInTheDocument(); + expect(screen.queryByTestId('activity-phase-panel')).toBeNull(); + }); +}); diff --git a/client/src/utils/__tests__/activityLabels.spec.ts b/client/src/utils/__tests__/activityLabels.spec.ts index 0a4c75e25b..fd4be7b3fd 100644 --- a/client/src/utils/__tests__/activityLabels.spec.ts +++ b/client/src/utils/__tests__/activityLabels.spec.ts @@ -1,7 +1,8 @@ -import { ContentTypes } from 'librechat-data-provider'; +import { Constants, ContentTypes } from 'librechat-data-provider'; import type { TActivityLabelEvent, TMessage, TMessageContentParts } from 'librechat-data-provider'; import { applyActivityLabelPart, + getActivityLabelText, groupActivityPhases, lastCursorContentIdx, lastVisibleContentIdx, @@ -516,3 +517,295 @@ describe('groupActivityPhases', () => { ]); }); }); + +describe('groupActivityPhases — synthesized folds', () => { + const toolPart = (id: string): TMessageContentParts => + ({ + type: ContentTypes.TOOL_CALL, + tool_call: { id, name: 'web_search', args: '{}', output: 'ok' }, + }) as unknown as TMessageContentParts; + + const childLabel = (label: string, status?: string): TMessageContentParts => + labelPart({ + activity_label: label, + pending: false, + ...(status != null && { status }), + } as Partial) as unknown as TMessageContentParts; + + const answer = (value = 'Here is what I found.'): TMessageContentParts => + ({ type: ContentTypes.TEXT, text: value }) as TMessageContentParts; + + const commentary = (value: string): TMessageContentParts => + ({ type: ContentTypes.TEXT, text: value, phase: 'commentary' }) as TMessageContentParts; + + const thinking = (): TMessageContentParts => + ({ type: ContentTypes.THINK, think: 'weighing options' }) as unknown as TMessageContentParts; + + const foldOf = (segments: ReturnType) => { + const fold = segments?.find((segment) => segment.type === 'phase'); + return fold?.type === 'phase' ? fold : undefined; + }; + + it('folds a run of two labeled activity blocks into one card', () => { + const segments = groupActivityPhases([ + toolPart('t1'), + childLabel('Read the config'), + toolPart('t2'), + childLabel('Checked the callers'), + ]); + + expect(segments).toHaveLength(1); + expect(foldOf(segments)).toMatchObject({ + synthesized: true, + startIndex: 0, + labelIndex: 3, + hasContent: true, + }); + expect(getActivityLabelText(foldOf(segments)?.labelPart)).toBe('Checked the callers'); + }); + + it('carries the newest filled child label as the ticker', () => { + const segments = groupActivityPhases([ + toolPart('t1'), + childLabel('Read the config'), + toolPart('t2'), + childLabel('Checked the callers'), + toolPart('t3'), + childLabel('Confirmed the fix'), + ]); + + expect(getActivityLabelText(foldOf(segments)?.labelPart)).toBe('Confirmed the fix'); + expect(foldOf(segments)?.labelPart.activity_count).toBe(3); + }); + + it('leaves in-flight activity past the newest label outside the card', () => { + const segments = groupActivityPhases([ + toolPart('t1'), + childLabel('Read the config'), + toolPart('t2'), + childLabel('Checked the callers'), + thinking(), + toolPart('t3'), + ]); + + expect(foldOf(segments)?.contentIndices).toEqual([0, 1, 2, 3]); + expect(segments?.[1]).toMatchObject({ type: 'content', contentIndices: [4, 5] }); + }); + + it('does not fold a single labeled block', () => { + expect( + groupActivityPhases([toolPart('t1'), toolPart('t2'), childLabel('Read both files')]), + ).toBeUndefined(); + }); + + it('does not fold unlabeled tool calls', () => { + expect(groupActivityPhases([toolPart('t1'), toolPart('t2'), toolPart('t3')])).toBeUndefined(); + }); + + it('stops a fold at the answer text so it can never be hidden', () => { + const segments = groupActivityPhases([ + toolPart('t1'), + childLabel('Read the config'), + toolPart('t2'), + childLabel('Checked the callers'), + answer('Done.'), + ]); + + expect(foldOf(segments)?.contentIndices).toEqual([0, 1, 2, 3]); + expect(segments?.[1]).toMatchObject({ type: 'content', contentIndices: [4] }); + }); + + it('absorbs short commentary but breaks on a substantial block', () => { + const short = groupActivityPhases([ + commentary('One moment.'), + toolPart('t1'), + childLabel('Read the config'), + toolPart('t2'), + childLabel('Checked the callers'), + ]); + expect(foldOf(short)?.contentIndices).toEqual([0, 1, 2, 3, 4]); + + const long = groupActivityPhases([ + commentary('x'.repeat(201)), + toolPart('t1'), + childLabel('Read the config'), + toolPart('t2'), + childLabel('Checked the callers'), + ]); + expect(foldOf(long)?.contentIndices).toEqual([1, 2, 3, 4]); + }); + + it('aggregates child status onto the card', () => { + const partial = groupActivityPhases([ + toolPart('t1'), + childLabel('Read the config', 'failed'), + toolPart('t2'), + childLabel('Checked the callers'), + ]); + expect(foldOf(partial)?.labelPart.status).toBe('partial'); + + const failed = groupActivityPhases([ + toolPart('t1'), + childLabel('Read the config', 'failed'), + toolPart('t2'), + childLabel('Checked the callers', 'failed'), + ]); + expect(foldOf(failed)?.labelPart.status).toBe('failed'); + }); + + it('stands down when the message carries parallel content', () => { + const parallel = { + ...(toolPart('t2') as object), + groupId: 'g1', + } as unknown as TMessageContentParts; + + expect( + groupActivityPhases([ + toolPart('t1'), + childLabel('Read the config'), + parallel, + childLabel('Checked the callers'), + ]), + ).toBeUndefined(); + }); + + it('never folds a span a server marker already claims', () => { + const phase = labelPart({ activity_label: 'Reviewed the release paths', pending: false }); + Object.assign(phase, { + activity_label_type: 'phase', + activity_start_index: 0, + activity_end_index: 4, + activity_count: 2, + }); + + const segments = groupActivityPhases([ + toolPart('t1'), + childLabel('Read the config'), + toolPart('t2'), + childLabel('Checked the callers'), + phase as never, + ]); + + expect(segments).toHaveLength(1); + expect(segments?.[0]).toMatchObject({ type: 'phase', labelIndex: 4 }); + expect(segments?.[0]).not.toHaveProperty('synthesized', true); + }); + + it('folds a trailing run left unclaimed after a server phase', () => { + const phase = labelPart({ activity_label: 'Reviewed the release paths', pending: false }); + Object.assign(phase, { + activity_label_type: 'phase', + activity_start_index: 0, + activity_end_index: 2, + activity_count: 2, + }); + + const segments = groupActivityPhases([ + toolPart('t1'), + childLabel('Read the config'), + phase as never, + toolPart('t2'), + childLabel('Checked the callers'), + toolPart('t3'), + childLabel('Confirmed the fix'), + ]); + + const synthesized = segments?.filter( + (segment) => segment.type === 'phase' && segment.synthesized === true, + ); + expect(synthesized).toHaveLength(1); + expect(synthesized?.[0]).toMatchObject({ startIndex: 3 }); + }); +}); + +describe('groupActivityPhases — what a fold may not swallow', () => { + const toolPart = (id: string, name = 'web_search'): TMessageContentParts => + ({ + type: ContentTypes.TOOL_CALL, + tool_call: { id, name, args: '{}', output: 'ok' }, + }) as unknown as TMessageContentParts; + + const childLabel = (label: string): TMessageContentParts => + labelPart({ activity_label: label, pending: false }) as unknown as TMessageContentParts; + + const foldOf = (segments: ReturnType) => { + const fold = segments?.find((segment) => segment.type === 'phase'); + return fold?.type === 'phase' ? fold : undefined; + }; + + it('breaks the fold on a visible non-activity part', () => { + const error = { type: ContentTypes.ERROR, error: 'boom' } as unknown as TMessageContentParts; + const segments = groupActivityPhases([ + toolPart('t1'), + childLabel('Read the config'), + error, + toolPart('t2'), + childLabel('Checked the callers'), + ]); + + /** One activity either side of the error, so neither side reaches the + * threshold — the error cannot end up behind a tool disclosure. */ + expect(segments).toBeUndefined(); + }); + + it('keeps a handoff inside the fold, matching the server', () => { + const handoff = { + type: ContentTypes.AGENT_UPDATE, + agent_update: { agentId: 'agent_2' }, + } as unknown as TMessageContentParts; + const segments = groupActivityPhases([ + toolPart('t1'), + childLabel('Read the config'), + handoff, + toolPart('t2'), + childLabel('Checked the callers'), + ]); + + expect(foldOf(segments)?.contentIndices).toEqual([0, 1, 2, 3, 4]); + }); + + it('does not count orphan labels that head no group', () => { + expect( + groupActivityPhases([childLabel('Read the config'), childLabel('Checked the callers')]), + ).toBeUndefined(); + }); + + it('does not count a label whose only call is a handoff', () => { + const transfer = toolPart('t2', `${Constants.LC_TRANSFER_TO_}agent_2`); + expect( + groupActivityPhases([ + toolPart('t1'), + childLabel('Read the config'), + transfer, + childLabel('Handed off'), + ]), + ).toBeUndefined(); + }); + + it('does not let a handoff carry an earlier call into the next label', () => { + /** `groupSequentialToolCalls` flushes the block at the non-groupable + * transfer and then drops the label through `coversTransferCall`, so + * neither batch heads a rendered group. */ + const batch = (index: number) => [ + toolPart(`t${index}a`), + toolPart(`t${index}b`, `${Constants.LC_TRANSFER_TO_}agent_${index}`), + childLabel(`Handed off ${index}`), + ]; + + expect(groupActivityPhases([...batch(1), ...batch(2)])).toBeUndefined(); + }); + + it('resets the claim at a blank reservation, as the grouping does', () => { + /** The blank label closes the first batch's claim, so the filled label + * after it claims nothing and heads no group. */ + expect( + groupActivityPhases([ + toolPart('t1'), + childLabel('Read the config'), + toolPart('t2'), + labelPart() as unknown as TMessageContentParts, + childLabel('Checked the callers'), + ]), + ).toBeUndefined(); + }); +}); diff --git a/client/src/utils/activityLabels.ts b/client/src/utils/activityLabels.ts index 73d2279300..49d49d8d74 100644 --- a/client/src/utils/activityLabels.ts +++ b/client/src/utils/activityLabels.ts @@ -1,4 +1,4 @@ -import { ContentTypes } from 'librechat-data-provider'; +import { Constants, ContentTypes } from 'librechat-data-provider'; import type { TMessage, TActivityLabelEvent, TMessageContentParts } from 'librechat-data-provider'; type ActivityLabelPart = Extract & { @@ -24,6 +24,10 @@ export type ActivityPhaseSegment = labelPart: ActivityLabelPart; labelIndex: number; hasContent: boolean; + /** Client-built card: no server phase marker covers this span yet, so + * the header carries the newest child label as a ticker instead of a + * generated summary. Set only by `synthesizeActivityFolds`. */ + synthesized?: boolean; }; function isVisibleContentPart(part: TMessageContentParts | undefined): boolean { @@ -64,9 +68,7 @@ function isLogicallyEarlierPhaseMarker( if (trailingPart?.type !== ContentTypes.TEXT) { return true; } - const text = - typeof trailingPart.text === 'string' ? trailingPart.text : trailingPart.text?.value; - return typeof text === 'string' && text.length > 0; + return textValue(trailingPart).length > 0; }); } @@ -139,6 +141,270 @@ export function offsetActivityPhaseBoundary( return boundary + prefixLength - (foldedFirstPart && boundary <= 1 ? 1 : 0); } +/** + * Mirrors `SUBSTANTIAL_TEXT_CHARS` in `activityPhases/runtime.ts`. Short + * commentary belongs inside a phase; a real block of prose ends one. + */ +const SUBSTANTIAL_TEXT_CHARS = 200; + +/** + * Activities a span needs before it is worth folding, matching the server's + * `MIN_ACTIVITIES`. Below it the child groups already read as a short list + * and a card would add a disclosure without hiding anything. + */ +const MIN_FOLD_ACTIVITIES = 2; + +function textValue(part: TMessageContentParts | undefined): string { + if (part?.type !== ContentTypes.TEXT) { + return ''; + } + return (typeof part.text === 'string' ? part.text : part.text?.value) ?? ''; +} + +/** + * What an activity block may contain — the membership `groupSequentialToolCalls` + * uses, plus `AGENT_UPDATE`, which the server deliberately keeps inside a phase + * (a transfer card cannot join a tool group, so the parent card is where it + * belongs). Everything absent here — an error, an image, a summary — is content + * in its own right and ends the fold rather than disappearing into it. + */ +const ACTIVITY_BLOCK_TYPES = new Set([ + ContentTypes.TOOL_CALL, + ContentTypes.THINK, + ContentTypes.ACTIVITY_LABEL, + ContentTypes.AGENT_UPDATE, +]); + +/** + * True at a hard UI boundary — where a fold has to stop. + * + * Prose is the strictest case. `groupSequentialToolCalls` absorbs only + * `commentary` text into an activity block, so anything else the model says is + * an answer the reader came for and must never end up behind a disclosure — + * including a two-word reply on a provider that never stamps `phase`, which + * the server's own 200-character rule would let through. Long commentary ends + * a fold too, matching `SUBSTANTIAL_TEXT_CHARS`, so a card cannot swallow an + * essay. Steers and existing phase markers end one because the server says so. + */ +function isFoldBoundaryPart(part: TMessageContentParts | undefined): boolean { + if (part == null) { + return false; + } + if (isPhaseActivityLabel(getActivityLabelPart(part))) { + return true; + } + if (part.type === ContentTypes.TEXT) { + const text = textValue(part).trim(); + if (text.length === 0) { + return false; + } + return ( + (part as { phase?: string }).phase !== 'commentary' || text.length > SUBSTANTIAL_TEXT_CHARS + ); + } + return !ACTIVITY_BLOCK_TYPES.has(part.type); +} + +/** + * True when the part is one an activity label can CLAIM. Mirrors + * `isGroupableToolCall` plus the block's reasoning and commentary members: a + * handoff call is never groupable, so a label covering only handoffs claims + * nothing and renders standalone rather than heading a group. + */ +function claimsActivity(part: TMessageContentParts | undefined): boolean { + if (part == null) { + return false; + } + if (part.type === ContentTypes.THINK) { + return true; + } + if (part.type === ContentTypes.TEXT) { + return (part as { phase?: string }).phase === 'commentary'; + } + if (part.type !== ContentTypes.TOOL_CALL) { + return false; + } + const name = (part[ContentTypes.TOOL_CALL] as { name?: string } | undefined)?.name; + return typeof name !== 'string' || !name.startsWith(Constants.LC_TRANSFER_TO_); +} + +type FoldRun = { + content: Array; + contentIndices: number[]; +}; + +type SynthesizedPhaseHeader = { + labelPart: ActivityLabelPart; + labelIndex: number; + /** Position in the run of the last filled child label — the fold's tail. */ + endPosition: number; +}; + +/** + * Builds the header for a synthesized fold, or undefined when the span has not + * accumulated enough filled child labels to be worth folding. + * + * The text is the NEWEST filled child label, which makes the collapsed row a + * ticker: it reads as the line the reader would have seen at the bottom of the + * unfolded list, and the generated summary replaces it verbatim once a real + * phase marker claims the span. + */ +function buildSynthesizedPhaseLabel(run: FoldRun): SynthesizedPhaseHeader | undefined { + let endPosition = -1; + let text = ''; + let count = 0; + let failed = 0; + let degraded = 0; + /** Parts available to the next label. Any label — blank reservation included + * — closes the claim, exactly as `claimStart` does in + * `groupSequentialToolCalls`, so a label can only ever claim its own batch. */ + let claimable = 0; + for (let position = 0; position < run.content.length; position += 1) { + const part = run.content[position]; + if (part?.type !== ContentTypes.ACTIVITY_LABEL) { + /** A part the block cannot hold FLUSHES it in `groupSequentialToolCalls` + * — a handoff call, or the agent update beside it — so whatever came + * before is no longer claimable. Without the reset, `tool → transfer → + * label` reads as a claimed batch here while the grouping drops that + * label entirely through `coversTransferCall`. */ + claimable = claimsActivity(part) ? claimable + 1 : 0; + continue; + } + const claimed = claimable; + claimable = 0; + const child = getBatchActivityLabelPart(part); + const childText = getActivityLabelText(child); + /** An orphan label heads no group — it renders as a standalone line. Two of + * them are not two activities, and folding them would hide the first + * behind a card whose body is just the pair of lines. */ + if (child == null || childText.length === 0 || claimed === 0) { + continue; + } + count += 1; + text = childText; + endPosition = position; + if (child.status === 'failed') { + failed += 1; + degraded += 1; + } else if (child.status === 'partial') { + degraded += 1; + } + } + if (count < MIN_FOLD_ACTIVITIES || endPosition < 0) { + return undefined; + } + const labelIndex = run.contentIndices[endPosition]; + let status: 'ok' | 'partial' | 'failed' = 'ok'; + if (failed === count) { + status = 'failed'; + } else if (degraded > 0) { + status = 'partial'; + } + return { + labelIndex, + endPosition, + labelPart: { + type: ContentTypes.ACTIVITY_LABEL, + [ContentTypes.ACTIVITY_LABEL]: text, + activity_label_type: 'phase', + activity_start_index: run.contentIndices[0], + activity_end_index: labelIndex + 1, + activity_count: count, + status, + /** Never summarized by a model, so it stays pending forever — which also + * keeps it out of `completed` if it is ever read back through here. */ + pending: true, + } as ActivityLabelPart, + }; +} + +/** + * Splits one unclaimed content segment at its hard boundaries and folds every + * run that carries enough labeled activity into a phase segment. + * + * This is why the module owns the partition: a run of labeled tool blocks is + * one card whether the server has summarized it yet or not, so `ContentParts` + * renders `ActivityPhaseGroup` either way and the card chrome has a single + * definition. Returns the segment untouched when nothing folds, so a message + * that never accumulates labeled activity keeps its exact prior shape. + */ +function synthesizeActivityFolds( + segment: Extract, +): ActivityPhaseSegment[] { + const segments: ActivityPhaseSegment[] = []; + const pending: FoldRun = { content: [], contentIndices: [] }; + const run: FoldRun = { content: [], contentIndices: [] }; + let folded = false; + + const flushPending = () => { + if (pending.contentIndices.length === 0) { + return; + } + segments.push({ + type: 'content', + content: pending.content, + contentIndices: pending.contentIndices, + /** The leading chunk keeps the original span start; a sparse segment can + * begin before its first defined index and the nested renderer offsets + * from it. */ + startIndex: segments.length === 0 ? segment.startIndex : pending.contentIndices[0], + }); + pending.content = []; + pending.contentIndices = []; + }; + const carryOver = (from: number) => { + for (let position = from; position < run.content.length; position += 1) { + pending.content.push(run.content[position]); + pending.contentIndices.push(run.contentIndices[position]); + } + }; + const flushRun = () => { + if (run.contentIndices.length === 0) { + return; + } + const header = buildSynthesizedPhaseLabel(run); + if (header == null) { + carryOver(0); + } else { + flushPending(); + folded = true; + const content = run.content.slice(0, header.endPosition + 1); + segments.push({ + type: 'phase', + content, + contentIndices: run.contentIndices.slice(0, header.endPosition + 1), + startIndex: run.contentIndices[0], + labelPart: header.labelPart, + labelIndex: header.labelIndex, + hasContent: content.some(isVisibleContentPart), + synthesized: true, + }); + /** Everything past the newest label is still in flight — the reasoning + * and the tool call the reader is watching right now. It stays outside + * the card and joins on the commit where its own label fills. */ + carryOver(header.endPosition + 1); + } + run.content = []; + run.contentIndices = []; + }; + + for (let position = 0; position < segment.content.length; position += 1) { + const part = segment.content[position]; + const index = segment.contentIndices[position]; + if (isFoldBoundaryPart(part)) { + flushRun(); + pending.content.push(part); + pending.contentIndices.push(index); + continue; + } + run.content.push(part); + run.contentIndices.push(index); + } + flushRun(); + flushPending(); + return folded ? segments : [segment]; +} + /** * Partitions completed phase markers into collapsed parent groups while * carrying absolute indexes alongside compact content slices. Pending markers @@ -151,6 +417,13 @@ export function groupActivityPhases( return undefined; } const definedIndices = Object.keys(content).map(Number); + /** Parallel columns lay their own activity out and are rendered by + * `ParallelContentRenderer`, which a synthesized card would pull onto the + * phase path. Server markers may still claim parallel spans; only the + * client-built folds stand down. */ + const foldable = !definedIndices.some( + (index) => (content[index] as { groupId?: string } | undefined)?.groupId != null, + ); const completed = definedIndices .map((index) => ({ part: getActivityLabelPart(content[index]), index })) .filter( @@ -160,7 +433,19 @@ export function groupActivityPhases( typeof part?.activity_start_index === 'number', ); if (completed.length === 0) { - return undefined; + if (!foldable) { + return undefined; + } + /** No marker has landed yet — the whole message is one unclaimed span. + * Returning `undefined` when nothing folds keeps the untouched + * fast path for messages that never accumulate labeled activity. */ + const folded = synthesizeActivityFolds({ + type: 'content', + content: definedIndices.map((index) => content[index]), + contentIndices: definedIndices, + startIndex: definedIndices[0] ?? 0, + }); + return folded.some((segment) => segment.type === 'phase') ? folded : undefined; } const segments: ActivityPhaseSegment[] = []; @@ -312,7 +597,12 @@ export function groupActivityPhases( } pushContent(adjacent, cursor); } - return segments; + if (!foldable) { + return segments; + } + return segments.flatMap((segment) => + segment.type === 'content' ? synthesizeActivityFolds(segment) : segment, + ); } /** @@ -360,11 +650,7 @@ export function lastVisibleContentIdx( } function isEmptyTextContentPart(part: TMessageContentParts | undefined): boolean { - if (part == null || part.type !== ContentTypes.TEXT) { - return false; - } - const text = typeof part.text === 'string' ? part.text : part.text?.value; - return (text ?? '').length === 0; + return part?.type === ContentTypes.TEXT && textValue(part).length === 0; } /**