⏱️ feat: Show Run-Step Durations On Tool Cards (#14892)

* ⏱️ feat: Show Run-Step Durations On Tool Cards

Surfaces how long each tool call took, derived from the `closed_at` /
`created_at` pair already carried by `on_run_step_closed` — the same event
#14871 and #14873 use for the terminal status. No new event, no new SDK
surface.

The duration is stamped onto the content part at the same three sites as
`runStepStatus`, so it survives a reload and a resumable reconnect rather
than living only on the live React message:

- `callbacks.js`, on the aggregated part before the event is forwarded
- `RedisJobStore`, in the host-authored replay reconstruction branch
- `useStepHandler`, on the live message

Rendering lands in the shared `ProgressText`, which nine tool cards already
use, rather than in each card: one place decides whether a duration is shown
and how it reads, and the cards only forward the number. That keeps this from
adding a tenth independent state derivation to a component family whose
label/announcement/progress split is already the subject of AI-1810.

The value is deliberately absent rather than zero whenever it would be a
guess — no `created_at`, non-finite input, or a negative elapsed time from
two clocks that disagree, which is now reachable because a step can be opened
in one process and closed in another after a checkpoint resume. Sub-second
durations are suppressed as noise, and it renders only on a settled,
non-error card, where the slot is not already carrying the cancelled icon or
the error suffix.

For assistive technology the compact form (`3.5s`) is hidden and paired with
a spoken equivalent ("took 3.5 seconds"), both inside the button, so the
accessible name carries the duration without an `aria-live` region
re-announcing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5

* 🎨 style: Sort Imports In Touched Files

The import-sort gate runs against the files a PR changes, so pre-existing
drift in `ProgressText.tsx` and `RedisJobStore.ts` surfaced on this branch.
Both were already unsorted on `dev`; this is the sorter's output, with no
semantic change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5

* 🐛 fix: Accept Partial Timestamps In Run-Step Duration Helper

`getReportableRunStepDurationMs` declared its parameter as
`Pick<RunStepClosedEvent, 'created_at' | 'closed_at'>`, where `closed_at` is
required. That contradicted the function's own purpose: every guard inside it
exists precisely to handle stamps that may be missing.

The Redis replay branch reconstructs closures from persisted JSON and holds
nothing stronger than "might be a number", so it failed to typecheck against
the narrower signature.

Widened to an exported `RunStepTimestamps` shape with both stamps optional,
rather than asserting at the call site — an assertion would move the decision
about what is trustworthy somewhere it cannot be enforced, which is the thing
the helper exists to centralize. Callers holding a fully-typed event still
pass, since a required field satisfies an optional one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5

* 🐛 fix: Suppress Duration When Failure Arrives As errorSuffix Alone

At every call site `error` carries cancellation while failure travels
through `errorSuffix` with `error` false, so gating the duration on
`!error` alone rendered "· 3.5s" beside "· failed" — and announced it.
The gate now checks both terminal-failure channels.

The original test pinned only the `error: true` path, which is why this
survived; the failed-via-suffix path is now pinned separately, both the
visible and the announced half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5

* 🧩 refactor: Persist Raw Run-Step Durations, Threshold At Render Only

The three stamp sites filtered through the 1-second reportability
threshold before persisting, baking a presentation rule into stored
data: a 900ms step stored nothing, making "fast" indistinguishable from
"not derivable" and unrecoverable if the display rule ever changes.

Stamp sites now persist the raw `getRunStepDurationMs` value — absent
only when genuinely not derivable — and the renderer alone decides what
is worth showing, which `ProgressText` already did. Rendering is
unchanged. `getReportableRunStepDurationMs` is removed; it existed only
to serve the write-time filter, and a test now pins that sub-threshold
durations survive to storage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5

* 🐛 fix: Suppress Duration On Backgrounded Bash And Code Cards

A backgrounded call's run step closes when dispatch returns the handle,
so the stamped duration is the dispatch time. Rendering it beside
"Running/Finished in background" misstated a detached task's runtime as
seconds — and violated the "settled card only" rule, since the card is
still tracking the detached run. Scope is exactly the two cards that
parse background handles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5

* 🌍 fix: Format The Sub-10s Decimal For The Active Locale

The fractional seconds value was interpolated as a raw JS number, which
hardcodes the en-US decimal point into every language — "1.4s" where
the locale writes "1,4 s" — and translators cannot fix a number
formatted in code. The value is now formatted via Intl.NumberFormat
with i18n.language, following MessageTimestamp's pattern of threading
the language into the util; plural-key selection stays on the numeric
value. A malformed language tag falls back to the plain number.

Also documents the two accepted limits of the derivation, so they read
as decisions rather than oversights: positive clock skew is
undetectable from a single stamp pair, and the value is wall-clock
elapsed, so a step held open across a suspension (checkpoint resume,
HITL approval wait) includes that time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5

* 🐛 fix: Persist A Durable `backgrounded` Marker Through Harvest; Localize Minute Digits

Codex round 3, both findings confirmed.

**Background origin survived only as transient state.** The dispatch
handle in `tool_call.output` and the live status-marker attachment are
both gone once the harvester patches the settled task's stdout over the
handle — so the round-2 suppression (`backgroundHandle == null`) came
back on after harvest or reload, showing dispatch time as the task's
runtime. Following the same rule as e4bd15d (persist facts, decide at
render): the harvest patch now stamps `backgrounded: true` onto the
tool call in the same atomic write that erases the handle — on the heal
path too, which re-applies over full-row saves that reverted the part.
The cards gate on handle-or-marker; the dispatch duration itself stays
stored.

**Minute-branch digits bypassed locale formatting.** The seconds branch
went through Intl.NumberFormat while minutes interpolated raw numbers,
so Arabic/Persian locales flipped to ASCII digits above one minute. All
interpolated values now flow through the (renamed) formatDurationValue;
an ar-EG test pins the localized digits.

data-schemas cannot be installed in this environment (same npm ci 403 as
packages/api), so message.ts/harvest.ts are syntax-checked with
resolution off and otherwise verified by review; CI runs their real
typecheck and suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5

* 🧪 test: Assert The `markBackgrounded` Stamp In Harvest Expectations

The successful-harvest test's exact `toHaveBeenCalledWith` object did
not include the newly forwarded `markBackgrounded`, so the API suite
would fail on it. All three harvest-call expectations now assert
`markBackgrounded: true` — the exact-object one of necessity, the two
`objectContaining` ones deliberately, since the durable stamp (on the
best-effort file-failure path and the reapply heal alike) is now part
of the behavior under test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5

* 🎨 style: Wrap Harvest Spec Expectation Per Prettier

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Danny Avila 2026-08-16 16:17:02 -04:00 committed by GitHub
parent d79d1ff76a
commit fb8ae881cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 625 additions and 4 deletions

View file

@ -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' }],
@ -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: [] });
});
@ -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' }] });

View file

@ -8,6 +8,7 @@ const {
FileContext,
ErrorTypes,
UsageEvents,
getRunStepDurationMs,
} = require('librechat-data-provider');
const {
GraphEvents,
@ -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 });

View file

@ -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}
@ -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}
@ -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}
@ -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}
@ -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}
@ -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}
@ -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}
@ -368,6 +378,7 @@ const Part = memo(function Part({
hideAttachments={hideAttachments}
onExpand={onToolExpand}
runStepStatus={toolCall.runStepStatus}
runStepDurationMs={toolCall.runStepDurationMs}
/>
);
})();
@ -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}

View file

@ -19,6 +19,8 @@ import { cn } from '~/utils';
export default function BashCall({
isSubmitting,
runStepStatus,
runStepDurationMs,
backgrounded,
initialProgress = 0.1,
args,
output = '',
@ -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[];
@ -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')

View file

@ -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 = '',
@ -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[];
@ -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')

View file

@ -107,6 +107,7 @@ export default function FileAuthoringCall({
toolName,
isSubmitting,
runStepStatus,
runStepDurationMs,
initialProgress = 0.1,
args,
output = '',
@ -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[];
@ -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

View file

@ -65,6 +65,7 @@ export function langFromPath(filePath: string): string {
export default function ReadFileCall({
isSubmitting,
runStepStatus,
runStepDurationMs,
initialProgress = 0.1,
args,
output = '',
@ -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[];
@ -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

View file

@ -13,6 +13,7 @@ import { cn } from '~/utils';
export default function SkillCall({
isSubmitting,
runStepStatus,
runStepDurationMs,
initialProgress = 0.1,
args,
output = '',
@ -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[];
@ -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

View file

@ -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';
@ -44,6 +47,7 @@ export default function ProgressText({
icon: iconProp,
subtitle,
errorSuffix,
durationMs,
hasInput = true,
popover = false,
isExpanded = false,
@ -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;
@ -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}>
@ -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(

View file

@ -330,6 +330,7 @@ export default function RetrievalCall({
attachments,
onExpand,
runStepStatus,
runStepDurationMs,
}: {
initialProgress: number;
isSubmitting: boolean;
@ -338,6 +339,7 @@ export default function RetrievalCall({
attachments?: TAttachment[];
onExpand?: () => void;
runStepStatus?: PartMetadata['runStepStatus'];
runStepDurationMs?: PartMetadata['runStepDurationMs'];
}) {
const isClosed = runStepStatus != null;
/**
@ -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} />

View file

@ -33,6 +33,7 @@ export default function ToolCall({
hideAttachments = false,
onExpand,
runStepStatus,
runStepDurationMs,
}: {
initialProgress: number;
isLast?: boolean;
@ -46,6 +47,7 @@ export default function ToolCall({
hideAttachments?: boolean;
onExpand?: () => void;
runStepStatus?: PartMetadata['runStepStatus'];
runStepDurationMs?: PartMetadata['runStepDurationMs'];
}) {
const localize = useLocalize();
const autoExpand = useRecoilValue(store.autoExpandTools);
@ -281,6 +283,7 @@ export default function ToolCall({
}
finishedText={getFinishedText()}
subtitle={subtitle}
durationMs={runStepDurationMs}
errorSuffix={errorState && !cancelled ? localize('com_ui_tool_failed') : undefined}
icon={
<ToolIcon

View file

@ -0,0 +1,113 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import ProgressText from '../ProgressText';
jest.mock('~/hooks', () => ({
useLocalize:
() =>
(key: string, values?: Record<string, string | number>): string => {
const translations: Record<string, string> = {
com_ui_duration_seconds: `${values?.[0]}s`,
com_ui_duration_minutes: `${values?.[0]}m ${values?.[1]}s`,
com_ui_duration_announced_seconds: `took ${values?.count} seconds`,
com_ui_duration_announced_seconds_one: `took ${values?.count} second`,
com_ui_duration_announced_minutes: `took ${values?.count} minutes`,
com_ui_duration_announced_minutes_one: `took ${values?.count} minute`,
};
return translations[key] ?? key;
},
}));
jest.mock('react-i18next', () => ({
useTranslation: () => ({ i18n: { language: 'en' } }),
}));
jest.mock('../CancelledIcon', () => ({
__esModule: true,
default: () => <span data-testid="cancelled-icon" />,
}));
const defaults = {
progress: 1,
inProgressText: 'Running foo',
finishedText: 'Completed foo',
};
const renderProgressText = (props: Partial<React.ComponentProps<typeof ProgressText>> = {}) =>
render(<ProgressText {...defaults} {...props} />);
describe('ProgressText duration', () => {
it('renders the compact duration on a settled card', () => {
renderProgressText({ durationMs: 3500 });
expect(screen.getByText('· 3.5s')).toBeInTheDocument();
});
it('formats durations of a minute or more as minutes and seconds', () => {
renderProgressText({ durationMs: 65_000 });
expect(screen.getByText('· 1m 5s')).toBeInTheDocument();
});
/**
* The number would be stale the moment it rendered, and the label beside it
* is still the in-progress one.
*/
it('does not render while the step is still running', () => {
renderProgressText({ progress: 0.4, durationMs: 3500 });
expect(screen.queryByText('· 3.5s')).not.toBeInTheDocument();
});
/**
* On a cancelled or failed card the slot already carries the cancelled icon
* or the error suffix, and "how long it took" is not the fact the reader
* needs. The two states arrive through different props `error` carries
* cancellation, `errorSuffix` alone carries failure so both are pinned
* separately; gating on `error` alone rendered a duration beside "failed"
* (Codex round 1 on #14892).
*/
it('does not render on a cancelled card', () => {
renderProgressText({ error: true, durationMs: 3500 });
expect(screen.queryByText('· 3.5s')).not.toBeInTheDocument();
});
it('does not render on a failed card, where failure arrives as errorSuffix alone', () => {
renderProgressText({ errorSuffix: 'failed', durationMs: 3500 });
expect(screen.queryByText('· 3.5s')).not.toBeInTheDocument();
expect(screen.queryByText('took 3.5 seconds')).not.toBeInTheDocument();
});
it('renders nothing when no duration was derivable', () => {
renderProgressText({});
expect(screen.queryByText(/took/)).not.toBeInTheDocument();
});
/** Sub-threshold durations are noise; the gate lives in the shared helper. */
it('suppresses a duration too short to be worth reporting', () => {
renderProgressText({ durationMs: 300 });
expect(screen.queryByText('· 0.3s')).not.toBeInTheDocument();
});
describe('accessibility', () => {
it('hides the compact form from assistive technology and pairs it with a spoken one', () => {
renderProgressText({ durationMs: 3500 });
expect(screen.getByText('· 3.5s')).toHaveAttribute('aria-hidden', 'true');
expect(screen.getByText('took 3.5 seconds')).toHaveClass('sr-only');
});
it('announces the singular form for exactly one second', () => {
renderProgressText({ durationMs: 1000 });
expect(screen.getByText('took 1 second')).toBeInTheDocument();
});
it('announces whole minutes for longer steps', () => {
renderProgressText({ durationMs: 150_000 });
expect(screen.getByText('took 3 minutes')).toBeInTheDocument();
});
/** Both spans sit inside the button, so its accessible name carries the
* duration without an `aria-live` region re-announcing it. */
it('keeps the duration inside the button', () => {
renderProgressText({ durationMs: 3500 });
expect(screen.getByRole('button')).toHaveTextContent('took 3.5 seconds');
});
});
});

View file

@ -7,6 +7,7 @@ import {
ContentTypes,
ToolCallTypes,
getNonEmptyValue,
getRunStepDurationMs,
} from 'librechat-data-provider';
import type {
Agents,
@ -1211,12 +1212,17 @@ export default function useStepHandler({
return;
}
/** Spread conditionally so an unknowable duration leaves any value the
* server already stamped in place, rather than overwriting it with
* `undefined`. */
const durationMs = getRunStepDurationMs(closed);
const updatedContent = [...(response.content ?? [])];
updatedContent[currentIndex] = {
...existing,
[ContentTypes.TOOL_CALL]: {
...existingToolCall,
runStepStatus: closed.status,
...(durationMs != null && { runStepDurationMs: durationMs }),
},
};

View file

@ -1180,6 +1180,12 @@
"com_ui_duplication_error": "There was an error duplicating the conversation",
"com_ui_duplication_processing": "Duplicating conversation...",
"com_ui_duplication_success": "Successfully duplicated conversation",
"com_ui_duration_announced_minutes": "took {{count}} minutes",
"com_ui_duration_announced_minutes_one": "took {{count}} minute",
"com_ui_duration_announced_seconds": "took {{count}} seconds",
"com_ui_duration_announced_seconds_one": "took {{count}} second",
"com_ui_duration_minutes": "{{0}}m {{1}}s",
"com_ui_duration_seconds": "{{0}}s",
"com_ui_during_run_actions": "More send options",
"com_ui_edit": "Edit",
"com_ui_edit_editing_image": "Editing image",

View file

@ -0,0 +1,84 @@
import { getRunStepDurationLabels } from '../runStepDuration';
describe('getRunStepDurationLabels', () => {
describe('under ten seconds', () => {
it('keeps one decimal, where the tenth still distinguishes two durations', () => {
expect(getRunStepDurationLabels(1400, 'en')).toMatchObject({
key: 'com_ui_duration_seconds',
values: { 0: '1.4' },
});
});
it('drops a trailing zero rather than rendering "1.0s"', () => {
expect(getRunStepDurationLabels(1000, 'en').values).toEqual({ 0: '1' });
});
/** The decimal separator belongs to the locale, not to the code a raw
* JS number interpolated into the label hardcodes the en-US point into
* every language (Codex-era self-audit finding on #14892). */
it('formats the decimal for the active locale', () => {
expect(getRunStepDurationLabels(1400, 'de').values).toEqual({ 0: '1,4' });
});
it('falls back to the plain number on a malformed language tag', () => {
expect(getRunStepDurationLabels(1400, 'not a tag').values).toEqual({ 0: '1.4' });
});
it('announces the singular form only for exactly one second', () => {
expect(getRunStepDurationLabels(1000).announcedKey).toBe(
'com_ui_duration_announced_seconds_one',
);
expect(getRunStepDurationLabels(1400).announcedKey).toBe('com_ui_duration_announced_seconds');
});
});
describe('ten seconds to a minute', () => {
it('rounds to whole seconds, where the tenth is only jitter', () => {
expect(getRunStepDurationLabels(12_400, 'en')).toMatchObject({
key: 'com_ui_duration_seconds',
values: { 0: '12' },
});
expect(getRunStepDurationLabels(12_600, 'en').values).toEqual({ 0: '13' });
});
});
describe('a minute and over', () => {
it('splits into minutes and seconds', () => {
expect(getRunStepDurationLabels(65_000, 'en')).toMatchObject({
key: 'com_ui_duration_minutes',
values: { 0: '1', 1: '5' },
});
expect(getRunStepDurationLabels(723_000, 'en').values).toEqual({ 0: '12', 1: '3' });
});
it('renders an exact minute without a stray remainder', () => {
expect(getRunStepDurationLabels(60_000, 'en').values).toEqual({ 0: '1', 1: '0' });
});
/** Branching on the raw seconds would render the nonsensical `60s`. */
it('promotes a value that rounds up to a full minute', () => {
expect(getRunStepDurationLabels(59_600, 'en')).toMatchObject({
key: 'com_ui_duration_minutes',
values: { 0: '1', 1: '0' },
});
});
it('announces whole minutes, leaving the precise value on the button', () => {
expect(getRunStepDurationLabels(65_000, 'en')).toMatchObject({
announcedKey: 'com_ui_duration_announced_minutes_one',
announcedValues: { count: '1' },
});
expect(getRunStepDurationLabels(150_000, 'en')).toMatchObject({
announcedKey: 'com_ui_duration_announced_minutes',
announcedValues: { count: '3' },
});
});
/** Locales with localized digits must not silently revert to ASCII in
* the minute branch while the seconds branch respects them (Codex
* round 3 on #14892). */
it('uses localized digits in the minute branch', () => {
expect(getRunStepDurationLabels(65_000, 'ar-EG').values).toEqual({ 0: '١', 1: '٥' });
});
});
});

View file

@ -44,6 +44,7 @@ export * from './favoritesError';
export * from './approval';
export * from './steer';
export * from './activityLabels';
export * from './runStepDuration';
export * from './documentTitle';
export * from './numbers';
export { default as cn } from './cn';

View file

@ -0,0 +1,98 @@
import type { TranslationKeys } from '~/hooks/useLocalize';
const MS_PER_SECOND = 1000;
const SECONDS_PER_MINUTE = 60;
/** Below this, a decimal carries real information (1.4s reads differently from
* 1.9s). Above it, the tenth is noise on a number the reader is only
* skimming, and it makes the label jitter by a character as it settles. */
const DECIMAL_PRECISION_BELOW_SECONDS = 10;
/** What a duration should render as, in both of the places it is presented. */
export interface RunStepDurationLabels {
/** Compact form for the visible label, e.g. `1.4s`, `12s`, `2m 5s`. */
key: TranslationKeys;
values: Record<string, string | number>;
/** Spoken form for assistive technology, e.g. "took 1.4 seconds". */
announcedKey: TranslationKeys;
announcedValues: Record<string, string | number>;
}
/**
* Every interpolated number goes through this, not just the fractional one:
* a raw JS number hardcodes en-US conventions into every locale the
* decimal point ("1.4s" where the convention is "1,4 s") and the digits
* themselves (Arabic and Persian locales write localized digits, which a raw
* `1` silently reverts to ASCII). Translators cannot fix a number formatted
* in code, so it is formatted per-locale here, following `MessageTimestamp`'s
* pattern of threading `i18n.language` into the util. The guard covers
* malformed language tags, which `Intl` throws on.
*/
function formatDurationValue(value: number, language?: string): string {
try {
return new Intl.NumberFormat(language, { maximumFractionDigits: 1 }).format(value);
} catch {
return String(value);
}
}
/**
* Resolve the localization keys and interpolation values for a run-step
* duration.
*
* Returns keys rather than strings so the caller localizes once, at the point
* of render, and so this stays testable without a translation context.
*
* The visible and announced forms are produced together, deliberately: they
* are the same fact presented twice, and deriving them apart is exactly how
* the tool cards drifted before (see the label/announcement split called out
* in AI-1810). The announced form rounds to whole minutes above a minute
* the precise value stays on the button, which assistive technology reads
* when the reader navigates to it.
*/
export function getRunStepDurationLabels(
durationMs: number,
language?: string,
): RunStepDurationLabels {
const totalSeconds = durationMs / MS_PER_SECOND;
/** Branch on the rounded value, not the raw one, so 59.6s renders as
* `1m 0s` rather than the nonsensical `60s`. */
if (Math.round(totalSeconds) < SECONDS_PER_MINUTE) {
const seconds =
totalSeconds < DECIMAL_PRECISION_BELOW_SECONDS
? Number(totalSeconds.toFixed(1))
: Math.round(totalSeconds);
/** Plural selection stays on the numeric value; only the interpolated
* text is locale-formatted. */
const formatted = formatDurationValue(seconds, language);
return {
key: 'com_ui_duration_seconds',
values: { 0: formatted },
/** The caller picks the plural form explicitly, matching the
* `com_ui_tools_count` / `_one` convention already used across the
* locale files, rather than relying on i18next's plural resolution. */
announcedKey:
seconds === 1
? 'com_ui_duration_announced_seconds_one'
: 'com_ui_duration_announced_seconds',
announcedValues: { count: formatted },
};
}
const wholeSeconds = Math.round(totalSeconds);
const minutes = Math.floor(wholeSeconds / SECONDS_PER_MINUTE);
const seconds = wholeSeconds % SECONDS_PER_MINUTE;
const announcedMinutes = Math.round(totalSeconds / SECONDS_PER_MINUTE);
return {
key: 'com_ui_duration_minutes',
values: {
0: formatDurationValue(minutes, language),
1: formatDurationValue(seconds, language),
},
announcedKey:
announcedMinutes === 1
? 'com_ui_duration_announced_minutes_one'
: 'com_ui_duration_announced_minutes',
announcedValues: { count: formatDurationValue(announcedMinutes, language) },
};
}

View file

@ -41,6 +41,7 @@ export interface CodeHarvestDeps {
agentId?: string;
output?: string;
attachments?: unknown[];
markBackgrounded?: boolean;
}) => Promise<{ matched: boolean; unfinished: boolean }>;
/** Host file service: downloads and persists one code output file. */
processCodeOutput: (params: {
@ -133,6 +134,9 @@ export function createBackgroundCodeResultHandler(deps: CodeHarvestDeps): CodeHa
agentId,
output,
attachments: knownAttachments ?? [],
/** The heal path must re-stamp the marker too: the full-row save it
* repairs reverted the whole patched part, marker included. */
markBackgrounded: true,
});
if (!reapplied.matched) {
logger.debug(
@ -194,6 +198,10 @@ export function createBackgroundCodeResultHandler(deps: CodeHarvestDeps): CodeHa
agentId,
output,
attachments,
/** This patch replaces the dispatch-handle output the client's only
* transient signal that the call ran detached so it persists the
* durable `backgrounded` marker in the same atomic write. */
markBackgrounded: true,
});
patched = result.matched;
/** An `unfinished` match is a mid-turn partial save (client disconnect):

View file

@ -1,6 +1,6 @@
import { logger } from '@librechat/data-schemas';
import { ContentTypes } from 'librechat-data-provider';
import { createContentAggregator } from '@librechat/agents';
import { ContentTypes, getRunStepDurationMs } from 'librechat-data-provider';
import type { StandardGraph } from '@librechat/agents';
import type { Agents } from 'librechat-data-provider';
import type { Redis, Cluster } from 'ioredis';
@ -3127,11 +3127,17 @@ export class RedisJobStore implements IJobStoreV2 {
const closed = event.data as {
id?: string;
status?: Agents.RunStepClosedStatus;
created_at?: number;
closed_at?: number;
};
const index = closed.id != null ? replayedStepIndices.get(closed.id) : undefined;
const part = index != null ? contentParts[index] : undefined;
if (closed.status && part?.type === ContentTypes.TOOL_CALL && part.tool_call) {
part.tool_call.runStepStatus = closed.status;
const durationMs = getRunStepDurationMs(closed);
if (durationMs != null) {
part.tool_call.runStepDurationMs = durationMs;
}
}
continue;
}

View file

@ -6,6 +6,8 @@ export * from './config';
export * from './file-config';
/* messages */
export * from './messages';
/* run steps */
export * from './runSteps';
/* artifacts */
export * from './artifacts';
/* schema helpers */

View file

@ -0,0 +1,73 @@
import {
getRunStepDurationMs,
isReportableRunStepDuration,
MIN_REPORTABLE_RUN_STEP_DURATION_MS,
} from './runSteps';
describe('getRunStepDurationMs', () => {
it('returns the elapsed time between the two stamps', () => {
expect(getRunStepDurationMs({ created_at: 1000, closed_at: 4500 })).toBe(3500);
});
it('returns 0 for a step that opened and closed on the same tick', () => {
expect(getRunStepDurationMs({ created_at: 1000, closed_at: 1000 })).toBe(0);
});
it('returns undefined when the emitter did not report when the step opened', () => {
expect(getRunStepDurationMs({ closed_at: 4500 })).toBeUndefined();
});
it('returns undefined when the closure carries no terminal stamp', () => {
expect(getRunStepDurationMs({ created_at: 1000 })).toBeUndefined();
});
/**
* Since `@librechat/agents` v3.6.0 a step can be opened in one process and
* closed in another after a checkpoint resume, so the two stamps can come
* from clocks that disagree. A negative elapsed time is the observable
* symptom, and reporting it as a duration would be worse than reporting
* nothing.
*/
it('returns undefined when the clocks disagree rather than a negative duration', () => {
expect(getRunStepDurationMs({ created_at: 4500, closed_at: 1000 })).toBeUndefined();
});
it('does not propagate non-finite input', () => {
expect(getRunStepDurationMs({ created_at: NaN, closed_at: 4500 })).toBeUndefined();
expect(getRunStepDurationMs({ created_at: 1000, closed_at: Infinity })).toBeUndefined();
});
it('ignores values that are not numbers', () => {
expect(
getRunStepDurationMs({ created_at: '1000' as unknown as number, closed_at: 4500 }),
).toBeUndefined();
});
});
describe('isReportableRunStepDuration', () => {
it('accepts a duration at the threshold', () => {
expect(isReportableRunStepDuration(MIN_REPORTABLE_RUN_STEP_DURATION_MS)).toBe(true);
});
it('rejects sub-threshold durations, which are noise rather than information', () => {
expect(isReportableRunStepDuration(MIN_REPORTABLE_RUN_STEP_DURATION_MS - 1)).toBe(false);
expect(isReportableRunStepDuration(0)).toBe(false);
});
it('rejects an absent duration', () => {
expect(isReportableRunStepDuration(undefined)).toBe(false);
});
});
/**
* The stamp sites persist {@link getRunStepDurationMs} raw a sub-threshold
* duration is stored as the fact it is, and only the renderer decides
* whether to show it. This pins that a fast step still yields a value, so a
* future "helpful" pre-filter at a stamp site fails a test instead of
* silently discarding data.
*/
it('derives sub-threshold durations rather than discarding them at the source', () => {
const durationMs = getRunStepDurationMs({ created_at: 1000, closed_at: 1300 });
expect(durationMs).toBe(300);
expect(isReportableRunStepDuration(durationMs)).toBe(false);
});

View file

@ -0,0 +1,74 @@
/**
* The timestamp pair these helpers derive from.
*
* Both stamps are optional here even though `closed_at` is required on
* `Agents.RunStepClosedEvent`, because not every caller holds a well-typed
* event: the Redis replay branch reconstructs closures from persisted JSON and
* legitimately has nothing stronger than "might be a number". Widening the
* parameter rather than making callers assert keeps the guards below as the
* single place that decides what is trustworthy an assertion at a call site
* would move that decision somewhere it cannot be enforced.
*/
export interface RunStepTimestamps {
created_at?: number;
closed_at?: number;
}
/**
* Below this, a duration is noise rather than information: sub-second tool
* calls are the common case, and labelling every one of them `· 0.3s` adds a
* moving number to the end of most cards without telling the reader anything
* they could act on. Callers use {@link isReportableRunStepDuration} rather
* than comparing against this directly.
*/
export const MIN_REPORTABLE_RUN_STEP_DURATION_MS = 1000;
/**
* Wall-clock duration of a run step, derived from the terminal
* `on_run_step_closed` event.
*
* Returns `undefined` rather than a fallback whenever the value would be a
* guess, because a wrong duration is worse than an absent one an absent one
* renders nothing, a wrong one is indistinguishable from a real measurement:
*
* - `created_at` is optional on the event; emitters that do not know when the
* step opened cannot have their duration inferred from anything else.
* - A negative result means the two timestamps came from clocks that disagree.
* That is not hypothetical: since `@librechat/agents` v3.6.0 a step can be
* opened in one process and closed in another after a checkpoint resume, so
* the two stamps can legitimately originate on different machines.
* - Non-finite input is treated as absent instead of propagating `NaN` into
* rendering.
*
* Known limits, accepted rather than guessed at: only the negative direction
* of clock skew is detectable from a single stamp pair positive skew
* inflates the result and cannot be distinguished from a genuinely long
* step. And the value is wall-clock elapsed between open and close, so a
* step held open across a suspension (a checkpoint resume, a HITL approval
* wait) includes that held-open time. Both are properties of the only data
* available, not derivation bugs.
*/
export function getRunStepDurationMs(closed: RunStepTimestamps): number | undefined {
const { created_at: createdAt, closed_at: closedAt } = closed;
if (typeof createdAt !== 'number' || typeof closedAt !== 'number') {
return undefined;
}
if (!Number.isFinite(createdAt) || !Number.isFinite(closedAt)) {
return undefined;
}
const durationMs = closedAt - createdAt;
return durationMs >= 0 ? durationMs : undefined;
}
/**
* Whether a derived duration is worth showing to the reader.
*
* This is a presentation judgment, so it belongs at render time only. The
* stamp sites persist the raw {@link getRunStepDurationMs} value instead of
* pre-filtering through this thresholding at write time would bake a
* display rule into stored data, making "fast" indistinguishable from "not
* derivable" and unrecoverable if the rule ever changes.
*/
export function isReportableRunStepDuration(durationMs?: number): durationMs is number {
return typeof durationMs === 'number' && durationMs >= MIN_REPORTABLE_RUN_STEP_DURATION_MS;
}

View file

@ -587,6 +587,25 @@ export type PartMetadata = {
* back to inferring "stopped" from `progress` and `isSubmitting`.
*/
runStepStatus?: Agents.RunStepClosedStatus;
/**
* Wall-clock milliseconds the run step took, derived from the same
* `on_run_step_closed` event as {@link runStepStatus} via
* `getRunStepDurationMs`. Only written when the event carried both
* timestamps and they agree in order so its absence means "not
* derivable", never "instant". The raw value is persisted unfiltered;
* whether it is worth showing (`isReportableRunStepDuration`) is decided
* at render time.
*/
runStepDurationMs?: number;
/**
* Stamped by the background harvester when a detached task's final output
* replaces the dispatch handle in `tool_call.output`. The handle JSON and
* the live status-marker attachment are both transient, so after the patch
* (or a reload) this is the only signal that the call ran in the
* background renderers use it to keep treating {@link runStepDurationMs}
* as dispatch time rather than the task's runtime.
*/
backgrounded?: boolean;
};
/** Metadata for parallel content rendering - subset of PartMetadata */

View file

@ -41,6 +41,7 @@ export interface MessageMethods {
agentId?: string;
output?: string;
attachments?: unknown[];
markBackgrounded?: boolean;
}): Promise<{ matched: boolean; unfinished: boolean }>;
updateMessage(
userId: string,
@ -298,6 +299,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
agentId,
output,
attachments,
markBackgrounded,
}: {
userId: string;
messageId: string;
@ -309,6 +311,14 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
agentId?: string;
output?: string;
attachments?: unknown[];
/**
* Stamps `backgrounded: true` onto the patched tool call. Replacing the
* dispatch-handle output with the settled task's stdout destroys the only
* signal renderers had that this call ran detached (the handle JSON and
* the live status-marker attachment are both transient), so the patch
* that erases it must persist a durable one alongside.
*/
markBackgrounded?: boolean;
}): Promise<{ matched: boolean; unfinished: boolean }> {
const stages: Record<string, unknown>[] = [];
if (output !== undefined) {
@ -341,7 +351,13 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
'$$part',
{
tool_call: {
$mergeObjects: ['$$part.tool_call', { output: { $literal: output } }],
$mergeObjects: [
'$$part.tool_call',
{
output: { $literal: output },
...(markBackgrounded === true ? { backgrounded: true } : {}),
},
],
},
},
],