Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api/server/controllers/agents/callbacks.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const {
ErrorTypes,
UsageEvents,
getRunStepDurationMs,
getRunStepCloseMetadata,
} = require('librechat-data-provider');
const {
GraphEvents,
Expand Down Expand Up @@ -595,6 +596,7 @@ function getDefaultHandlers({
const part = typeof index === 'number' ? contentParts[index] : undefined;
if (part?.type === ContentTypes.TOOL_CALL && part.tool_call) {
part.tool_call.runStepStatus = data.status;
Object.assign(part.tool_call, getRunStepCloseMetadata(data));
/**
* The raw derivable duration, left unset rather than zeroed when
* the event cannot support a trustworthy one — no `created_at`,
Expand Down
42 changes: 35 additions & 7 deletions client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { memo, useId, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAtomValue } from 'jotai';
import { Button } from '@librechat/client';
import { useTranslation } from 'react-i18next';
import { ContentTypes } from 'librechat-data-provider';
import { Check, Lightbulb, ChevronDown, TriangleAlert } from 'lucide-react';
import type { TAttachment, TMessageContentParts } from 'librechat-data-provider';
Expand Down Expand Up @@ -28,11 +29,13 @@ import { useMCPIconMap, useMCPServerNames } from '~/hooks/MCP';
import { getActivityLabelText } from '~/utils/activityLabels';
import { getOutcomeStatus, summarizeSpan } from './outcome';
import { sandboxStartingByToolCallId } from '~/store';
import useClockFormat from '~/hooks/useClockFormat';
import { cn, getMessageTimestamp } from '~/utils';
import { StackedToolIcons } from './ToolOutput';
import useTimeTick from '~/hooks/useTimeTick';
import { getSourceDomains } from './sources';
import { mapAttachments } from '~/utils/map';
import SearchVerticals from './verticals';
import { cn } from '~/utils';

/** Matches `EXPAND_TRANSITION` so the panel and the label ticker resolve on
* the same curve — two properties animating on two different easings is what
Expand Down Expand Up @@ -319,6 +322,7 @@ function LivePhaseHeader({
* fail while a later one runs, and the line alone would never say so. The
* hidden group header carries the same counts in the same words. */
const { failed, cancelled } = activity.outcome;
const total = activity.total;
let combo = '';
if (painted.comboCount > 1) {
combo = painted.isBackgroundTaskCheck
Expand All @@ -327,9 +331,7 @@ function LivePhaseHeader({
}
const failedNote =
failed > 0
? localize(failed === 1 ? 'com_ui_one_action_failed' : 'com_ui_n_actions_failed', {
0: String(failed),
})
? localize('com_ui_n_of_n_actions_failed', { 0: String(failed), 1: String(total) })
: '';
const cancelledNote =
cancelled > 0
Expand Down Expand Up @@ -435,6 +437,30 @@ function LivePhaseHeader({
* readable, and one click away, without unfolding. Its own component so only
* a collapsed card with a failure pays for the line's lookups.
*/
function FailedPeekTime({ failedAt }: { failedAt: number | Date }) {
useTimeTick();
const { i18n } = useTranslation();
const hour12 = useClockFormat();
const date = new Date(failedAt);
if (!Number.isFinite(date.getTime()) || date.getTime() > Date.now()) {
return null;
}
const timestamp = getMessageTimestamp(date.toISOString(), i18n.language, hour12);
if (timestamp == null) {
return null;
}
return (
<time
dateTime={timestamp.iso}
title={timestamp.absolute}
className="min-w-0 shrink truncate text-xs text-text-secondary"
data-testid="activity-phase-failed-time"
>
{timestamp.relative}
</time>
);
}

function FailedPeek({
parts,
attachmentsById,
Expand Down Expand Up @@ -476,6 +502,7 @@ function FailedPeek({
<span className="min-w-0 shrink truncate font-normal">{first.detail}</span>
)}
</span>
{first.failedAt != null && <FailedPeekTime failedAt={first.failedAt} />}
{count > 1 && (
<span className="shrink-0 text-xs font-normal">
{localize('com_ui_plus_n_more', { 0: String(count - 1) })}
Expand Down Expand Up @@ -529,8 +556,9 @@ export default function ActivityPhaseGroup({
/** The span's failed calls, for the peek under a collapsed header and the
* pill beside it. Read from the same parts the header's glyph and live
* line read, so the three can never disagree about the count. */
const failedCount = useMemo(
() => (outcomeParts == null ? 0 : summarizeSpan(outcomeParts, attachmentsById).failed),
const { failed: failedCount, total: toolCount } = useMemo(
() =>
outcomeParts == null ? { failed: 0, total: 0 } : summarizeSpan(outcomeParts, attachmentsById),
[outcomeParts, attachmentsById],
);

Expand Down Expand Up @@ -830,7 +858,7 @@ export default function ActivityPhaseGroup({
aria-hidden="true"
/>
</Button>
<FailedRevealPill count={failedCount} onReveal={handleRevealFailed} />
<FailedRevealPill count={failedCount} total={toolCount} onReveal={handleRevealFailed} />
</div>
</div>
{peek}
Expand Down
7 changes: 7 additions & 0 deletions client/src/components/Chat/Messages/Content/ContentParts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,8 @@ type ContentPartsProps = {
hideAttachments?: boolean;
/** Internal signal that this segment renders inside a completed phase card. */
withinActivityPhase?: boolean;
/** The parent phase owns the failure pill, including while it is live. */
parentPhaseOwnsFailurePill?: 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
Expand Down Expand Up @@ -309,6 +311,7 @@ const ContentPartsBody = memo(function ContentPartsBody({
foldLiveActivity = true,
nestedActivityPhase = false,
withinActivityPhase = false,
parentPhaseOwnsFailurePill = false,
cursorOwnedElsewhere = false,
hideAttachments = false,
workspaceAttachmentsPartitioned = false,
Expand Down Expand Up @@ -871,6 +874,7 @@ const ContentPartsBody = memo(function ContentPartsBody({
withinPhase = false,
ownsCursor = false,
hoisted = false,
underPhase = false,
) => {
return (
<ContentPartsBody
Expand All @@ -889,6 +893,7 @@ const ContentPartsBody = memo(function ContentPartsBody({
nestedActivityPhase
showThinking={showThinking}
withinActivityPhase={withinPhase}
parentPhaseOwnsFailurePill={underPhase}
cursorOwnedElsewhere={cursorOwnedByCard}
hideAttachments={hoisted}
workspaceAttachmentsPartitioned
Expand Down Expand Up @@ -1062,6 +1067,7 @@ const ContentPartsBody = memo(function ContentPartsBody({
!live,
ownsCursor,
true,
true,
)}
</ActivityPhaseGroup>
);
Expand Down Expand Up @@ -1177,6 +1183,7 @@ const ContentPartsBody = memo(function ContentPartsBody({
onExpansionChange={(state) => handleGroupExpansionChange(groupId, state)}
labelPart={group.labelPart}
withinActivityPhase={withinActivityPhase}
parentPhaseOwnsFailurePill={parentPhaseOwnsFailurePill}
/>,
);
return nodes;
Expand Down
24 changes: 15 additions & 9 deletions client/src/components/Chat/Messages/Content/ToolCallGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ interface ToolCallGroupProps {
* blocks the run, and hiding that card behind a second collapsed
* disclosure would bury the action the run is waiting on. */
withinActivityPhase?: boolean;
/** The phase header owns the failure pill even while its live groups stay expandable. */
parentPhaseOwnsFailurePill?: boolean;
}

export type ToolCallGroupExpansionState = {
Expand All @@ -83,6 +85,7 @@ export default function ToolCallGroup({
onExpansionChange,
labelPart,
withinActivityPhase = false,
parentPhaseOwnsFailurePill = false,
}: ToolCallGroupProps) {
const localize = useLocalize();
const mcpIconMap = useMCPIconMap();
Expand Down Expand Up @@ -448,12 +451,10 @@ export default function ToolCallGroup({
}
const failedNote =
activitySummary.failedCount > 0
? localize(
activitySummary.failedCount === 1
? 'com_ui_one_action_failed'
: 'com_ui_n_actions_failed',
{ 0: String(activitySummary.failedCount) },
)
? localize('com_ui_n_of_n_actions_failed', {
0: String(activitySummary.failedCount),
1: String(count),
})
: '';
if (failedNote !== '') {
groupDetailParts.push(failedNote);
Expand All @@ -479,7 +480,8 @@ export default function ToolCallGroup({
* the header, which is also the way to the failed rows; the text keeps it
* only for the accessible name. Inside a phase the pill is the phase's,
* so the group's detail says it in text. */
const showsFailurePill = !withinActivityPhase && activitySummary.failedCount > 0;
const showsFailurePill =
!withinActivityPhase && !parentPhaseOwnsFailurePill && activitySummary.failedCount > 0;
const visibleGroupDetail = showsFailurePill
? groupDetailParts.filter((part) => part && part !== failedNote).join(' · ')
: groupDetail;
Expand Down Expand Up @@ -578,8 +580,12 @@ export default function ToolCallGroup({
aria-hidden="true"
/>
</Button>
{!withinActivityPhase && (
<FailedRevealPill count={activitySummary.failedCount} onReveal={handleRevealFailed} />
{!withinActivityPhase && !parentPhaseOwnsFailurePill && (
<FailedRevealPill
count={activitySummary.failedCount}
total={count}
onReveal={handleRevealFailed}
/>
)}
</div>
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ jest.mock('~/hooks', () => {
const expandCollapse = jest.requireActual('~/hooks/Messages/useExpandCollapse');
const lazyCollapseBody = jest.requireActual('~/hooks/Messages/useLazyCollapseBody');
return {
useLocalize: () => (key: string) => key,
useLocalize: () => (key: string, values?: Record<string | number, string>) =>
key === 'com_ui_n_of_n_actions_failed' ? `${values?.[0]}/${values?.[1]} failed` : key,
useExpandCollapse: expandCollapse.default,
useLazyCollapseBody: lazyCollapseBody.default,
EXPAND_TRANSITION: expandCollapse.EXPAND_TRANSITION,
Expand Down Expand Up @@ -469,6 +470,25 @@ describe('ActivityPhaseGroup failure fast path', () => {
return <div data-testid="phase-content" />;
};

test('counts only tool calls in the phase, not reasoning, labels or missing stream slots', () => {
const thought = {
type: ContentTypes.THINK,
think: 'Checking a source',
} as TMessageContentParts;
render(
<ActivityPhaseGroup
labelPart={labelPart}
hasContent
spanParts={[okCall, undefined, thought, failedCall, okCall]}
>
<div />
</ActivityPhaseGroup>,
);

expect(screen.getByTestId('failed-reveal-pill')).toHaveTextContent('1/3 failed');
expect(screen.getByRole('button', { name: 'com_ui_show_failed_one_of_n' })).toBeInTheDocument();
});

test('peeks the first failed call under a collapsed card and reaches it in one click', () => {
const onReveal = jest.fn();
render(
Expand All @@ -479,6 +499,7 @@ describe('ActivityPhaseGroup failure fast path', () => {
const peek = screen.getByTestId('activity-phase-failed-peek');
expect(peek).toHaveTextContent('HTTP 429');
expect(peek).toHaveTextContent('com_ui_show_error');
expect(screen.queryByTestId('activity-phase-failed-time')).not.toBeInTheDocument();
expect(screen.queryByTestId('phase-content')).not.toBeInTheDocument();

fireEvent.click(peek);
Expand All @@ -488,6 +509,23 @@ describe('ActivityPhaseGroup failure fast path', () => {
expect(onReveal).toHaveBeenCalledTimes(1);
});

test('shows when the first failure happened, even after the conversation is restored', () => {
const failedAt = Date.now() - 2 * 60_000;
const timed = toPart(
{ name: 'fetch_page', runStepStatus: 'failed', runStepClosedAt: failedAt },
'timed',
);
render(
<ActivityPhaseGroup labelPart={labelPart} hasContent spanParts={[timed, okCall]}>
<div />
</ActivityPhaseGroup>,
);

const time = screen.getByTestId('activity-phase-failed-time');
expect(time).toHaveAttribute('dateTime', new Date(failedAt).toISOString());
expect(time).toHaveTextContent(/2 minutes ago/);
});

test('keeps the error action and remaining count outside the shrinking peek label', () => {
render(
<ActivityPhaseGroup labelPart={labelPart} hasContent spanParts={[failedCall, failedCall]}>
Expand All @@ -514,12 +552,23 @@ describe('ActivityPhaseGroup failure fast path', () => {
</ActivityPhaseGroup>,
);
fireEvent.click(screen.getByRole('button', { name: LABEL }));
const pill = screen.getByRole('button', { name: 'com_ui_show_failed_n' });
const pill = screen.getByRole('button', { name: 'com_ui_show_failed_n_of_n' });
expect(pill).toHaveTextContent('2/2 failed');
fireEvent.click(pill);
expect(onReveal).toHaveBeenCalledTimes(1);
expect(screen.getByRole('button', { name: LABEL })).toHaveAttribute('aria-expanded', 'true');
});

test('announces the same failed-over-total count while later calls are still running', () => {
render(
<ActivityPhaseGroup labelPart={labelPart} hasContent liveParts={[failedCall, okCall]}>
<div />
</ActivityPhaseGroup>,
);
expect(screen.getByTestId('failed-reveal-pill')).toHaveTextContent('1/2 failed');
expect(screen.getByTestId('live-phase-outcome')).toHaveTextContent('1/2 failed');
});

test('a card with no failure shows neither pill nor peek', () => {
render(
<ActivityPhaseGroup labelPart={labelPart} hasContent spanParts={[okCall]}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ jest.mock('~/hooks', () => ({
if (key === 'com_ui_running_n_actions') {
return `Running ${values?.[0]} actions`;
}
if (key === 'com_ui_n_of_n_actions_failed') {
return `${values?.[0]}/${values?.[1]} failed`;
}
return key;
},
useExpandCollapse: (isExpanded: boolean) => ({
Expand Down Expand Up @@ -652,6 +655,42 @@ describe('ContentParts — synthesized activity folds', () => {
);
});

it('shows one failure pill when an expanded live phase contains a running tool group', () => {
const failed = {
type: ContentTypes.TOOL_CALL,
tool_call: {
id: 't1',
name: `getTinyImage${MCP_DELIMITER}Everything`,
args: '{}',
output: 'image_returned',
runStepStatus: 'failed',
},
} as TMessageContentParts;
const live = [failed, makeMcpToolCall('t2', false)];
const props = { ...baseProps, isSubmitting: true, content: live };
const { rerender } = renderContentParts(props);

const phase = screen.getByTestId('activity-phase-card');
expect(within(phase).getByTestId('failed-reveal-pill')).toHaveTextContent('1/2 failed');
fireEvent.click(within(phase).getAllByRole('button')[0]);

const group = screen.getByRole('button', { name: /Running 2 actions.*1\/2 failed/ });
expect(group).toHaveAttribute('aria-expanded', 'true');
expect(group).toHaveTextContent('1/2 failed');
expect(screen.getAllByTestId('failed-reveal-pill')).toHaveLength(1);

rerender(
<RecoilRoot>
<ContentParts
{...props}
isSubmitting={false}
content={[failed, makeMcpToolCall('t2'), makePhasePart(0, 2, 'Reviewed calls')]}
/>
</RecoilRoot>,
);
expect(screen.getAllByTestId('failed-reveal-pill')).toHaveLength(1);
});

it('keeps the in-flight tool call inside the card while the run streams', () => {
renderContentParts({
...baseProps,
Expand Down
Loading
Loading