mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
⏱️ 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:
parent
d79d1ff76a
commit
fb8ae881cf
24 changed files with 625 additions and 4 deletions
|
|
@ -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' }] });
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 }),
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
84
client/src/utils/__tests__/runStepDuration.spec.ts
Normal file
84
client/src/utils/__tests__/runStepDuration.spec.ts
Normal 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: '٥' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
98
client/src/utils/runStepDuration.ts
Normal file
98
client/src/utils/runStepDuration.ts
Normal 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) },
|
||||
};
|
||||
}
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
73
packages/data-provider/src/runSteps.spec.ts
Normal file
73
packages/data-provider/src/runSteps.spec.ts
Normal 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);
|
||||
});
|
||||
74
packages/data-provider/src/runSteps.ts
Normal file
74
packages/data-provider/src/runSteps.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue