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
10 changes: 9 additions & 1 deletion api/server/controllers/agents/callbacks.background.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ describe('createBackgroundCodeResultHandler', () => {
agentId: 'agent_a',
output: 'stdout:\nhello',
attachments: [{ file_id: 'f1', filename: 'plot.png', toolCallId: 'call_code' }],
markBackgrounded: true,
});
expect(result).toEqual({
attachments: [{ file_id: 'f1', filename: 'plot.png', toolCallId: 'call_code' }],
Expand Down Expand Up @@ -156,7 +157,11 @@ describe('createBackgroundCodeResultHandler', () => {
const result = await handler(baseParams);

expect(updateToolCallResult).toHaveBeenCalledWith(
expect.objectContaining({ output: 'stdout:\nhello', attachments: [] }),
expect.objectContaining({
output: 'stdout:\nhello',
attachments: [],
markBackgrounded: true,
}),
);
expect(result).toEqual({ attachments: [] });
});
Expand All @@ -180,6 +185,9 @@ describe('createBackgroundCodeResultHandler', () => {
toolCallId: 'call_code',
output: 'stdout:\nhello',
attachments: [{ file_id: 'f1' }],
/** The heal path must re-stamp the marker: the full-row save it
* repairs reverted the whole patched part, marker included. */
markBackgrounded: true,
}),
);
expect(result).toEqual({ attachments: [{ file_id: 'f1' }] });
Expand Down
12 changes: 12 additions & 0 deletions api/server/controllers/agents/callbacks.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const {
FileContext,
ErrorTypes,
UsageEvents,
getRunStepDurationMs,
} = require('librechat-data-provider');
const {
GraphEvents,
Expand Down Expand Up @@ -460,6 +461,17 @@ 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;
/**
* The raw derivable duration, left unset rather than zeroed when
* the event cannot support a trustworthy one — no `created_at`,
* or clocks that disagree. Whether it is *worth showing* is the
* renderer's call; persisting the fact unfiltered keeps that
* threshold adjustable without data loss.
*/
const durationMs = getRunStepDurationMs(data);
if (durationMs != null) {
part.tool_call.runStepDurationMs = durationMs;
}
}
}
await emitForJob({ event, data });
Expand Down
12 changes: 12 additions & 0 deletions client/src/components/Chat/Messages/Content/Part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ const Part = memo(function Part({
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
runStepStatus={toolCall.runStepStatus}
runStepDurationMs={toolCall.runStepDurationMs}
backgrounded={toolCall.backgrounded}
attachments={attachments}
commandField="code"
hideAttachments={hideAttachments}
Expand All @@ -209,6 +211,8 @@ const Part = memo(function Part({
attachments={attachments}
isSubmitting={isSubmitting}
runStepStatus={toolCall.runStepStatus}
runStepDurationMs={toolCall.runStepDurationMs}
backgrounded={toolCall.backgrounded}
output={toolCall.output ?? ''}
initialProgress={toolCall.progress ?? 0.1}
args={toolCall.args}
Expand Down Expand Up @@ -255,6 +259,7 @@ const Part = memo(function Part({
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
runStepStatus={toolCall.runStepStatus}
runStepDurationMs={toolCall.runStepDurationMs}
attachments={attachments}
hideAttachments={hideAttachments}
onExpand={onToolExpand}
Expand Down Expand Up @@ -293,6 +298,7 @@ const Part = memo(function Part({
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
runStepStatus={toolCall.runStepStatus}
runStepDurationMs={toolCall.runStepDurationMs}
attachments={attachments}
hideAttachments={hideAttachments}
onExpand={onToolExpand}
Expand All @@ -307,6 +313,7 @@ const Part = memo(function Part({
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
runStepStatus={toolCall.runStepStatus}
runStepDurationMs={toolCall.runStepDurationMs}
attachments={attachments}
hideAttachments={hideAttachments}
onExpand={onToolExpand}
Expand All @@ -320,6 +327,8 @@ const Part = memo(function Part({
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
runStepStatus={toolCall.runStepStatus}
runStepDurationMs={toolCall.runStepDurationMs}
backgrounded={toolCall.backgrounded}
attachments={attachments}
hideAttachments={hideAttachments}
onExpand={onToolExpand}
Expand All @@ -345,6 +354,7 @@ const Part = memo(function Part({
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
runStepStatus={toolCall.runStepStatus}
runStepDurationMs={toolCall.runStepDurationMs}
args={toolCall.args}
output={toolCall.output ?? undefined}
attachments={attachments}
Expand All @@ -368,6 +378,7 @@ const Part = memo(function Part({
hideAttachments={hideAttachments}
onExpand={onToolExpand}
runStepStatus={toolCall.runStepStatus}
runStepDurationMs={toolCall.runStepDurationMs}
/>
);
})();
Expand Down Expand Up @@ -408,6 +419,7 @@ const Part = memo(function Part({
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
runStepStatus={toolCall.runStepStatus}
runStepDurationMs={toolCall.runStepDurationMs}
output={(toolCall as { output?: string }).output}
attachments={attachments}
onExpand={onToolExpand}
Expand Down
13 changes: 13 additions & 0 deletions client/src/components/Chat/Messages/Content/Parts/BashCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { cn } from '~/utils';
export default function BashCall({
isSubmitting,
runStepStatus,
runStepDurationMs,
backgrounded,
initialProgress = 0.1,
args,
output = '',
Expand All @@ -31,6 +33,8 @@ export default function BashCall({
initialProgress: number;
isSubmitting: boolean;
runStepStatus?: PartMetadata['runStepStatus'];
runStepDurationMs?: PartMetadata['runStepDurationMs'];
backgrounded?: PartMetadata['backgrounded'];
args?: string | Record<string, unknown>;
output?: string;
attachments?: TAttachment[];
Expand Down Expand Up @@ -108,6 +112,15 @@ export default function BashCall({
? localize('com_ui_cancelled')
: (backgroundFinishedText ?? intent ?? localize('com_ui_command_finished'))
}
/** A backgrounded call's run step closes when dispatch returns the
* handle, so its duration is the dispatch time — showing it would
* misstate a detached task's runtime as seconds. The handle check
* covers the live card; the persisted `backgrounded` marker covers
* the card after harvest replaces the handle with real stdout
* (and after any reload), when no transient signal survives. */
durationMs={
backgroundHandle == null && backgrounded !== true ? runStepDurationMs : undefined
}
errorSuffix={
(hasError && !cancelled) || backgroundFailed
? localize('com_ui_tool_failed')
Expand Down
13 changes: 13 additions & 0 deletions client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ export const ERROR_PATTERNS = /^(Traceback|Error:|Exception:|.*Error:)/m;
export default function ExecuteCode({
isSubmitting,
runStepStatus,
runStepDurationMs,
backgrounded,
initialProgress = 0.1,
args,
output = '',
Expand All @@ -68,6 +70,8 @@ export default function ExecuteCode({
initialProgress: number;
isSubmitting: boolean;
runStepStatus?: PartMetadata['runStepStatus'];
runStepDurationMs?: PartMetadata['runStepDurationMs'];
backgrounded?: PartMetadata['backgrounded'];
args?: string | Record<string, unknown>;
output?: string;
attachments?: TAttachment[];
Expand Down Expand Up @@ -120,6 +124,15 @@ export default function ExecuteCode({
? localize('com_ui_cancelled')
: (backgroundFinishedText ?? intent ?? localize('com_ui_analyzing_finished'))
}
/** A backgrounded call's run step closes when dispatch returns the
* handle, so its duration is the dispatch time — showing it would
* misstate a detached task's runtime as seconds. The handle check
* covers the live card; the persisted `backgrounded` marker covers
* the card after harvest replaces the handle with real stdout
* (and after any reload), when no transient signal survives. */
durationMs={
backgroundHandle == null && backgrounded !== true ? runStepDurationMs : undefined
}
errorSuffix={
(hasError && !cancelled) || backgroundFailed
? localize('com_ui_tool_failed')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export default function FileAuthoringCall({
toolName,
isSubmitting,
runStepStatus,
runStepDurationMs,
initialProgress = 0.1,
args,
output = '',
Expand All @@ -118,6 +119,7 @@ export default function FileAuthoringCall({
initialProgress: number;
isSubmitting: boolean;
runStepStatus?: PartMetadata['runStepStatus'];
runStepDurationMs?: PartMetadata['runStepDurationMs'];
args?: string | Record<string, unknown>;
output?: string;
attachments?: TAttachment[];
Expand Down Expand Up @@ -184,6 +186,7 @@ export default function FileAuthoringCall({
? localize('com_ui_cancelled')
: (intent ?? localize(finishedKey, { 0: fileName }))
}
durationMs={runStepDurationMs}
errorSuffix={hasError && !cancelled ? localize('com_ui_tool_failed') : undefined}
icon={
<Icon
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export function langFromPath(filePath: string): string {
export default function ReadFileCall({
isSubmitting,
runStepStatus,
runStepDurationMs,
initialProgress = 0.1,
args,
output = '',
Expand All @@ -75,6 +76,7 @@ export default function ReadFileCall({
initialProgress: number;
isSubmitting: boolean;
runStepStatus?: PartMetadata['runStepStatus'];
runStepDurationMs?: PartMetadata['runStepDurationMs'];
args?: string | Record<string, unknown>;
output?: string;
attachments?: TAttachment[];
Expand Down Expand Up @@ -104,6 +106,7 @@ export default function ReadFileCall({
? localize('com_ui_cancelled')
: (intent ?? localize('com_ui_read_file', { 0: fileName }))
}
durationMs={runStepDurationMs}
errorSuffix={hasError && !cancelled ? localize('com_ui_tool_failed') : undefined}
icon={
<FileText
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { cn } from '~/utils';
export default function SkillCall({
isSubmitting,
runStepStatus,
runStepDurationMs,
initialProgress = 0.1,
args,
output = '',
Expand All @@ -23,6 +24,7 @@ export default function SkillCall({
initialProgress: number;
isSubmitting: boolean;
runStepStatus?: PartMetadata['runStepStatus'];
runStepDurationMs?: PartMetadata['runStepDurationMs'];
args?: string | Record<string, unknown>;
output?: string;
attachments?: TAttachment[];
Expand All @@ -48,6 +50,7 @@ export default function SkillCall({
? localize('com_ui_cancelled')
: (intent ?? localize('com_ui_skill_finished', { 0: skillName }))
}
durationMs={runStepDurationMs}
errorSuffix={hasError && !cancelled ? localize('com_ui_tool_failed') : undefined}
icon={
<ScrollText
Expand Down
44 changes: 43 additions & 1 deletion client/src/components/Chat/Messages/Content/ProgressText.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { ChevronDown } from 'lucide-react';
import { Button } from '@librechat/client';
import { useTranslation } from 'react-i18next';
import * as Popover from '@radix-ui/react-popover';
import { isReportableRunStepDuration } from 'librechat-data-provider';
import { cn, getRunStepDurationLabels } from '~/utils';
import CancelledIcon from './CancelledIcon';
import { cn } from '~/utils';
import { useLocalize } from '~/hooks';

const wrapperClass =
'progress-text-wrapper text-token-text-secondary relative -mt-[0.75px] h-5 w-full leading-5';
Expand Down Expand Up @@ -44,6 +47,7 @@ export default function ProgressText({
icon: iconProp,
subtitle,
errorSuffix,
durationMs,
hasInput = true,
popover = false,
isExpanded = false,
Expand All @@ -57,11 +61,16 @@ export default function ProgressText({
icon?: React.ReactNode;
subtitle?: string;
errorSuffix?: string;
/** Wall-clock duration of the run step, from `PartMetadata.runStepDurationMs`. */
durationMs?: number;
hasInput?: boolean;
popover?: boolean;
isExpanded?: boolean;
error?: boolean;
}) {
const localize = useLocalize();
/** For locale-aware decimal formatting of the sub-10s duration value. */
const { i18n } = useTranslation();
const getText = () => {
if (error) {
return finishedText;
Expand All @@ -82,6 +91,23 @@ export default function ProgressText({
const text = getText();
const icon = getIcon();
const showShimmer = progress < 1 && !error;
/**
* Shown only on a settled, successful card. While the step is still running
* the number would be stale the instant it rendered, and on a cancelled or
* failed card "how long it took" is not the fact the reader needs — that
* slot already carries the cancelled icon or the error suffix.
*
* Both terminal-failure channels must be checked: at every call site
* `error` carries cancellation while failure arrives as `errorSuffix`
* alone, so gating on `error` by itself would print a duration beside
* "failed". Gating here on the component's own props rather than on a
* separate caller-supplied flag keeps this consistent with the label
* beside it by construction; the callers only forward the number.
*/
const duration =
progress >= 1 && !error && !errorSuffix && isReportableRunStepDuration(durationMs)
? getRunStepDurationLabels(durationMs, i18n.language)
: undefined;

return (
<Wrapper popover={popover}>
Expand All @@ -105,6 +131,22 @@ export default function ProgressText({
</span>
{subtitle && <span className="font-normal text-text-secondary">{subtitle}</span>}
{errorSuffix && <span className="font-normal text-status-error">· {errorSuffix}</span>}
{duration && (
<>
{/* The compact form is the readable one on screen but a poor
thing to hear ("one point four s"), so it is hidden from
assistive technology and paired with a spoken equivalent.
Both live inside the button, so its accessible name carries
the duration — this is not an `aria-live` region and does not
re-announce. */}
<span className="font-normal text-text-secondary" aria-hidden="true">
· {localize(duration.key, duration.values)}
</span>
<span className="sr-only">
{localize(duration.announcedKey, duration.announcedValues)}
</span>
</>
)}
{hasInput && (
<ChevronDown
className={cn(
Expand Down
3 changes: 3 additions & 0 deletions client/src/components/Chat/Messages/Content/RetrievalCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ export default function RetrievalCall({
attachments,
onExpand,
runStepStatus,
runStepDurationMs,
}: {
initialProgress: number;
isSubmitting: boolean;
Expand All @@ -338,6 +339,7 @@ export default function RetrievalCall({
attachments?: TAttachment[];
onExpand?: () => void;
runStepStatus?: PartMetadata['runStepStatus'];
runStepDurationMs?: PartMetadata['runStepDurationMs'];
}) {
const isClosed = runStepStatus != null;
/**
Expand Down Expand Up @@ -471,6 +473,7 @@ export default function RetrievalCall({
? localize('com_ui_cancelled')
: (intent ?? localize('com_ui_retrieved_files'))
}
durationMs={runStepDurationMs}
errorSuffix={errorState && !cancelled ? localize('com_ui_tool_failed') : undefined}
icon={
<ToolIcon type="file_search" isAnimating={progress < 1 && !cancelled && !errorState} />
Expand Down
Loading
Loading