From b252892d63c2fa9e1b708d7867381e7a0435c3cc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 13:57:28 +0000 Subject: [PATCH 01/10] =?UTF-8?q?=E2=8F=B1=EF=B8=8F=20feat:=20Show=20Run-S?= =?UTF-8?q?tep=20Durations=20On=20Tool=20Cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5 --- api/server/controllers/agents/callbacks.js | 10 ++ .../components/Chat/Messages/Content/Part.tsx | 9 ++ .../Chat/Messages/Content/Parts/BashCall.tsx | 3 + .../Messages/Content/Parts/ExecuteCode.tsx | 3 + .../Content/Parts/FileAuthoringCall.tsx | 3 + .../Messages/Content/Parts/ReadFileCall.tsx | 3 + .../Chat/Messages/Content/Parts/SkillCall.tsx | 3 + .../Chat/Messages/Content/ProgressText.tsx | 38 ++++++- .../Chat/Messages/Content/RetrievalCall.tsx | 3 + .../Chat/Messages/Content/ToolCall.tsx | 3 + .../Content/__tests__/ProgressText.test.tsx | 100 ++++++++++++++++++ client/src/hooks/SSE/useStepHandler.ts | 6 ++ client/src/locales/en/translation.json | 6 ++ .../utils/__tests__/runStepDuration.spec.ts | 66 ++++++++++++ client/src/utils/index.ts | 1 + client/src/utils/runStepDuration.ts | 71 +++++++++++++ .../stream/implementations/RedisJobStore.ts | 8 +- packages/data-provider/src/index.ts | 2 + packages/data-provider/src/runSteps.spec.ts | 77 ++++++++++++++ packages/data-provider/src/runSteps.ts | 59 +++++++++++ .../data-provider/src/types/assistants.ts | 9 ++ 21 files changed, 481 insertions(+), 2 deletions(-) create mode 100644 client/src/components/Chat/Messages/Content/__tests__/ProgressText.test.tsx create mode 100644 client/src/utils/__tests__/runStepDuration.spec.ts create mode 100644 client/src/utils/runStepDuration.ts create mode 100644 packages/data-provider/src/runSteps.spec.ts create mode 100644 packages/data-provider/src/runSteps.ts diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index a43b387d73b..de66d31d3de 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -8,6 +8,7 @@ const { FileContext, ErrorTypes, UsageEvents, + getReportableRunStepDurationMs, } = require('librechat-data-provider'); const { GraphEvents, @@ -460,6 +461,15 @@ 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; + /** + * Left unset rather than zeroed when the event cannot support a + * trustworthy duration, so a reader can tell "we don't know" from + * "it was fast". + */ + const durationMs = getReportableRunStepDurationMs(data); + if (durationMs != null) { + part.tool_call.runStepDurationMs = durationMs; + } } } await emitForJob({ event, data }); diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx index 6c26dcd32a1..c57c1861c5e 100644 --- a/client/src/components/Chat/Messages/Content/Part.tsx +++ b/client/src/components/Chat/Messages/Content/Part.tsx @@ -192,6 +192,7 @@ const Part = memo(function Part({ initialProgress={toolCall.progress ?? 0.1} isSubmitting={isSubmitting} runStepStatus={toolCall.runStepStatus} + runStepDurationMs={toolCall.runStepDurationMs} attachments={attachments} commandField="code" hideAttachments={hideAttachments} @@ -209,6 +210,7 @@ const Part = memo(function Part({ attachments={attachments} isSubmitting={isSubmitting} runStepStatus={toolCall.runStepStatus} + runStepDurationMs={toolCall.runStepDurationMs} output={toolCall.output ?? ''} initialProgress={toolCall.progress ?? 0.1} args={toolCall.args} @@ -255,6 +257,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 +296,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 +311,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 +325,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} @@ -345,6 +351,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 +375,7 @@ const Part = memo(function Part({ hideAttachments={hideAttachments} onExpand={onToolExpand} runStepStatus={toolCall.runStepStatus} + runStepDurationMs={toolCall.runStepDurationMs} /> ); })(); @@ -408,6 +416,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} diff --git a/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx b/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx index 41c2f341895..ae1309746b9 100644 --- a/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx @@ -19,6 +19,7 @@ import { cn } from '~/utils'; export default function BashCall({ isSubmitting, runStepStatus, + runStepDurationMs, initialProgress = 0.1, args, output = '', @@ -31,6 +32,7 @@ export default function BashCall({ initialProgress: number; isSubmitting: boolean; runStepStatus?: PartMetadata['runStepStatus']; + runStepDurationMs?: PartMetadata['runStepDurationMs']; args?: string | Record; output?: string; attachments?: TAttachment[]; @@ -108,6 +110,7 @@ export default function BashCall({ ? localize('com_ui_cancelled') : (backgroundFinishedText ?? intent ?? localize('com_ui_command_finished')) } + durationMs={runStepDurationMs} errorSuffix={ (hasError && !cancelled) || backgroundFailed ? localize('com_ui_tool_failed') diff --git a/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx b/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx index 6af4c79982c..627789ac710 100644 --- a/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx @@ -57,6 +57,7 @@ export const ERROR_PATTERNS = /^(Traceback|Error:|Exception:|.*Error:)/m; export default function ExecuteCode({ isSubmitting, runStepStatus, + runStepDurationMs, initialProgress = 0.1, args, output = '', @@ -68,6 +69,7 @@ export default function ExecuteCode({ initialProgress: number; isSubmitting: boolean; runStepStatus?: PartMetadata['runStepStatus']; + runStepDurationMs?: PartMetadata['runStepDurationMs']; args?: string | Record; output?: string; attachments?: TAttachment[]; @@ -120,6 +122,7 @@ export default function ExecuteCode({ ? localize('com_ui_cancelled') : (backgroundFinishedText ?? intent ?? localize('com_ui_analyzing_finished')) } + durationMs={runStepDurationMs} errorSuffix={ (hasError && !cancelled) || backgroundFailed ? localize('com_ui_tool_failed') diff --git a/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx b/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx index e36987c1e49..bb531054e77 100644 --- a/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx @@ -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; 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={ ; 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={ ; 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={ { if (error) { return finishedText; @@ -82,6 +88,20 @@ export default function ProgressText({ const text = getText(); const icon = getIcon(); const showShimmer = progress < 1 && !error; + /** + * Shown only on a settled, non-error 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. + * + * Gating on the component's own `progress`/`error` 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 && isReportableRunStepDuration(durationMs) + ? getRunStepDurationLabels(durationMs) + : undefined; return ( @@ -105,6 +125,22 @@ export default function ProgressText({ {subtitle && {subtitle}} {errorSuffix && · {errorSuffix}} + {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. */} + + + {localize(duration.announcedKey, duration.announcedValues)} + + + )} {hasInput && ( 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={ diff --git a/client/src/components/Chat/Messages/Content/ToolCall.tsx b/client/src/components/Chat/Messages/Content/ToolCall.tsx index 0be80546639..862b3978461 100644 --- a/client/src/components/Chat/Messages/Content/ToolCall.tsx +++ b/client/src/components/Chat/Messages/Content/ToolCall.tsx @@ -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={ ({ + useLocalize: + () => + (key: string, values?: Record): string => { + const translations: Record = { + 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('../CancelledIcon', () => ({ + __esModule: true, + default: () => , +})); + +const defaults = { + progress: 1, + inProgressText: 'Running foo', + finishedText: 'Completed foo', +}; + +const renderProgressText = (props: Partial> = {}) => + render(); + +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. + */ + it('does not render on an errored or cancelled card', () => { + renderProgressText({ error: true, durationMs: 3500 }); + expect(screen.queryByText('· 3.5s')).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'); + }); + }); +}); diff --git a/client/src/hooks/SSE/useStepHandler.ts b/client/src/hooks/SSE/useStepHandler.ts index 0b703648e13..fab57787828 100644 --- a/client/src/hooks/SSE/useStepHandler.ts +++ b/client/src/hooks/SSE/useStepHandler.ts @@ -7,6 +7,7 @@ import { ContentTypes, ToolCallTypes, getNonEmptyValue, + getReportableRunStepDurationMs, } 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 = getReportableRunStepDurationMs(closed); const updatedContent = [...(response.content ?? [])]; updatedContent[currentIndex] = { ...existing, [ContentTypes.TOOL_CALL]: { ...existingToolCall, runStepStatus: closed.status, + ...(durationMs != null && { runStepDurationMs: durationMs }), }, }; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 7c00e96c86e..27b30fa7077 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1178,6 +1178,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", diff --git a/client/src/utils/__tests__/runStepDuration.spec.ts b/client/src/utils/__tests__/runStepDuration.spec.ts new file mode 100644 index 00000000000..29e299d576e --- /dev/null +++ b/client/src/utils/__tests__/runStepDuration.spec.ts @@ -0,0 +1,66 @@ +import { getRunStepDurationLabels } from '../runStepDuration'; + +describe('getRunStepDurationLabels', () => { + describe('under ten seconds', () => { + it('keeps one decimal, where the tenth still distinguishes two durations', () => { + expect(getRunStepDurationLabels(1400)).toMatchObject({ + key: 'com_ui_duration_seconds', + values: { 0: 1.4 }, + }); + }); + + it('drops a trailing zero rather than rendering "1.0s"', () => { + expect(getRunStepDurationLabels(1000).values).toEqual({ 0: 1 }); + }); + + 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)).toMatchObject({ + key: 'com_ui_duration_seconds', + values: { 0: 12 }, + }); + expect(getRunStepDurationLabels(12_600).values).toEqual({ 0: 13 }); + }); + }); + + describe('a minute and over', () => { + it('splits into minutes and seconds', () => { + expect(getRunStepDurationLabels(65_000)).toMatchObject({ + key: 'com_ui_duration_minutes', + values: { 0: 1, 1: 5 }, + }); + expect(getRunStepDurationLabels(723_000).values).toEqual({ 0: 12, 1: 3 }); + }); + + it('renders an exact minute without a stray remainder', () => { + expect(getRunStepDurationLabels(60_000).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)).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)).toMatchObject({ + announcedKey: 'com_ui_duration_announced_minutes_one', + announcedValues: { count: 1 }, + }); + expect(getRunStepDurationLabels(150_000)).toMatchObject({ + announcedKey: 'com_ui_duration_announced_minutes', + announcedValues: { count: 3 }, + }); + }); + }); +}); diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts index 91f47f129a5..f6a3fb0d3c7 100644 --- a/client/src/utils/index.ts +++ b/client/src/utils/index.ts @@ -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'; diff --git a/client/src/utils/runStepDuration.ts b/client/src/utils/runStepDuration.ts new file mode 100644 index 00000000000..dd7e447d149 --- /dev/null +++ b/client/src/utils/runStepDuration.ts @@ -0,0 +1,71 @@ +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; + /** Spoken form for assistive technology, e.g. "took 1.4 seconds". */ + announcedKey: TranslationKeys; + announcedValues: Record; +} + +/** + * 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): 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); + return { + key: 'com_ui_duration_seconds', + values: { 0: seconds }, + /** 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: seconds }, + }; + } + + 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: minutes, 1: seconds }, + announcedKey: + announcedMinutes === 1 + ? 'com_ui_duration_announced_minutes_one' + : 'com_ui_duration_announced_minutes', + announcedValues: { count: announcedMinutes }, + }; +} diff --git a/packages/api/src/stream/implementations/RedisJobStore.ts b/packages/api/src/stream/implementations/RedisJobStore.ts index c8a03669c05..750460dbb54 100644 --- a/packages/api/src/stream/implementations/RedisJobStore.ts +++ b/packages/api/src/stream/implementations/RedisJobStore.ts @@ -1,5 +1,5 @@ import { logger } from '@librechat/data-schemas'; -import { ContentTypes } from 'librechat-data-provider'; +import { ContentTypes, getReportableRunStepDurationMs } from 'librechat-data-provider'; import { createContentAggregator } from '@librechat/agents'; import type { StandardGraph } from '@librechat/agents'; import type { Agents } from 'librechat-data-provider'; @@ -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 = getReportableRunStepDurationMs(closed); + if (durationMs != null) { + part.tool_call.runStepDurationMs = durationMs; + } } continue; } diff --git a/packages/data-provider/src/index.ts b/packages/data-provider/src/index.ts index 7ecaf4ba8a2..abe2ae4aaa0 100644 --- a/packages/data-provider/src/index.ts +++ b/packages/data-provider/src/index.ts @@ -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 */ diff --git a/packages/data-provider/src/runSteps.spec.ts b/packages/data-provider/src/runSteps.spec.ts new file mode 100644 index 00000000000..b4726b7d724 --- /dev/null +++ b/packages/data-provider/src/runSteps.spec.ts @@ -0,0 +1,77 @@ +import { + getRunStepDurationMs, + isReportableRunStepDuration, + getReportableRunStepDurationMs, + 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); + }); +}); + +describe('getReportableRunStepDurationMs', () => { + it('returns the duration when it is both derivable and worth showing', () => { + expect(getReportableRunStepDurationMs({ created_at: 1000, closed_at: 4500 })).toBe(3500); + }); + + /** The distinction the callers rely on: absent means "not knowable or not + * worth reporting", never "instant". */ + it('returns undefined for a step too fast to be worth reporting', () => { + expect(getReportableRunStepDurationMs({ created_at: 1000, closed_at: 1300 })).toBeUndefined(); + }); + + it('returns undefined when the duration is not derivable at all', () => { + expect(getReportableRunStepDurationMs({ closed_at: 4500 })).toBeUndefined(); + }); +}); diff --git a/packages/data-provider/src/runSteps.ts b/packages/data-provider/src/runSteps.ts new file mode 100644 index 00000000000..5e9bbf0a898 --- /dev/null +++ b/packages/data-provider/src/runSteps.ts @@ -0,0 +1,59 @@ +import type { Agents } from './types/agents'; + +/** + * 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. + */ +export function getRunStepDurationMs(closed: { + created_at?: number; + closed_at?: number; +}): 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. */ +export function isReportableRunStepDuration(durationMs?: number): durationMs is number { + return typeof durationMs === 'number' && durationMs >= MIN_REPORTABLE_RUN_STEP_DURATION_MS; +} + +/** + * Convenience for the three sites that stamp a closure onto a content part + * (live SSE, server-side aggregation, and Redis replay reconstruction): the + * value to persist, or `undefined` when nothing should be written. + */ +export function getReportableRunStepDurationMs( + closed: Pick, +): number | undefined { + const durationMs = getRunStepDurationMs(closed); + return isReportableRunStepDuration(durationMs) ? durationMs : undefined; +} diff --git a/packages/data-provider/src/types/assistants.ts b/packages/data-provider/src/types/assistants.ts index 1a8cfecbdba..a9790a90f07 100644 --- a/packages/data-provider/src/types/assistants.ts +++ b/packages/data-provider/src/types/assistants.ts @@ -587,6 +587,15 @@ 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 + * `getReportableRunStepDurationMs`. Only written when the event carried + * both timestamps, they agree in order, and the result clears + * `MIN_REPORTABLE_RUN_STEP_DURATION_MS` — so its absence means "not worth + * reporting or not knowable", never "instant". + */ + runStepDurationMs?: number; }; /** Metadata for parallel content rendering - subset of PartMetadata */ From 3aa7292422078b52048f9112f7bdab1ef73c0113 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 14:14:28 +0000 Subject: [PATCH 02/10] =?UTF-8?q?=F0=9F=8E=A8=20style:=20Sort=20Imports=20?= =?UTF-8?q?In=20Touched=20Files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5 --- client/src/components/Chat/Messages/Content/ProgressText.tsx | 4 ++-- packages/api/src/stream/implementations/RedisJobStore.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/src/components/Chat/Messages/Content/ProgressText.tsx b/client/src/components/Chat/Messages/Content/ProgressText.tsx index f26b06c5443..58ea5073df5 100644 --- a/client/src/components/Chat/Messages/Content/ProgressText.tsx +++ b/client/src/components/Chat/Messages/Content/ProgressText.tsx @@ -2,9 +2,9 @@ import { ChevronDown } from 'lucide-react'; import { Button } from '@librechat/client'; import * as Popover from '@radix-ui/react-popover'; import { isReportableRunStepDuration } from 'librechat-data-provider'; -import { useLocalize } from '~/hooks'; -import CancelledIcon from './CancelledIcon'; import { cn, getRunStepDurationLabels } from '~/utils'; +import CancelledIcon from './CancelledIcon'; +import { useLocalize } from '~/hooks'; const wrapperClass = 'progress-text-wrapper text-token-text-secondary relative -mt-[0.75px] h-5 w-full leading-5'; diff --git a/packages/api/src/stream/implementations/RedisJobStore.ts b/packages/api/src/stream/implementations/RedisJobStore.ts index 750460dbb54..ec687367207 100644 --- a/packages/api/src/stream/implementations/RedisJobStore.ts +++ b/packages/api/src/stream/implementations/RedisJobStore.ts @@ -1,6 +1,6 @@ import { logger } from '@librechat/data-schemas'; -import { ContentTypes, getReportableRunStepDurationMs } from 'librechat-data-provider'; import { createContentAggregator } from '@librechat/agents'; +import { ContentTypes, getReportableRunStepDurationMs } from 'librechat-data-provider'; import type { StandardGraph } from '@librechat/agents'; import type { Agents } from 'librechat-data-provider'; import type { Redis, Cluster } from 'ioredis'; From f39aabdf2a7d4eb1288827e32007b96c21376004 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 14:55:57 +0000 Subject: [PATCH 03/10] =?UTF-8?q?=F0=9F=90=9B=20fix:=20Accept=20Partial=20?= =?UTF-8?q?Timestamps=20In=20Run-Step=20Duration=20Helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getReportableRunStepDurationMs` declared its parameter as `Pick`, 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 Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5 --- packages/data-provider/src/runSteps.ts | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/data-provider/src/runSteps.ts b/packages/data-provider/src/runSteps.ts index 5e9bbf0a898..ecbf7c28742 100644 --- a/packages/data-provider/src/runSteps.ts +++ b/packages/data-provider/src/runSteps.ts @@ -1,4 +1,18 @@ -import type { Agents } from './types/agents'; +/** + * 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 @@ -26,10 +40,7 @@ export const MIN_REPORTABLE_RUN_STEP_DURATION_MS = 1000; * - Non-finite input is treated as absent instead of propagating `NaN` into * rendering. */ -export function getRunStepDurationMs(closed: { - created_at?: number; - closed_at?: number; -}): number | undefined { +export function getRunStepDurationMs(closed: RunStepTimestamps): number | undefined { const { created_at: createdAt, closed_at: closedAt } = closed; if (typeof createdAt !== 'number' || typeof closedAt !== 'number') { return undefined; @@ -51,9 +62,7 @@ export function isReportableRunStepDuration(durationMs?: number): durationMs is * (live SSE, server-side aggregation, and Redis replay reconstruction): the * value to persist, or `undefined` when nothing should be written. */ -export function getReportableRunStepDurationMs( - closed: Pick, -): number | undefined { +export function getReportableRunStepDurationMs(closed: RunStepTimestamps): number | undefined { const durationMs = getRunStepDurationMs(closed); return isReportableRunStepDuration(durationMs) ? durationMs : undefined; } From 5c126eeb9bc98ba0fe4efb535f9e28b265a406fe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 15:24:08 +0000 Subject: [PATCH 04/10] =?UTF-8?q?=F0=9F=90=9B=20fix:=20Suppress=20Duration?= =?UTF-8?q?=20When=20Failure=20Arrives=20As=20errorSuffix=20Alone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5 --- .../Chat/Messages/Content/ProgressText.tsx | 13 ++++++++----- .../Content/__tests__/ProgressText.test.tsx | 13 +++++++++++-- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/client/src/components/Chat/Messages/Content/ProgressText.tsx b/client/src/components/Chat/Messages/Content/ProgressText.tsx index 58ea5073df5..ea200bdc076 100644 --- a/client/src/components/Chat/Messages/Content/ProgressText.tsx +++ b/client/src/components/Chat/Messages/Content/ProgressText.tsx @@ -89,17 +89,20 @@ export default function ProgressText({ const icon = getIcon(); const showShimmer = progress < 1 && !error; /** - * Shown only on a settled, non-error card. While the step is still running + * 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. * - * Gating on the component's own `progress`/`error` rather than on a separate - * caller-supplied flag keeps this consistent with the label beside it by - * construction; the callers only forward the number. + * 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 && isReportableRunStepDuration(durationMs) + progress >= 1 && !error && !errorSuffix && isReportableRunStepDuration(durationMs) ? getRunStepDurationLabels(durationMs) : undefined; diff --git a/client/src/components/Chat/Messages/Content/__tests__/ProgressText.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ProgressText.test.tsx index d808425b023..990c64d5d45 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/ProgressText.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/ProgressText.test.tsx @@ -55,13 +55,22 @@ describe('ProgressText duration', () => { /** * 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. + * 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 an errored or cancelled card', () => { + 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(); From a378a23f3edbb8417a9bd75571001d567e682524 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 15:24:08 +0000 Subject: [PATCH 05/10] =?UTF-8?q?=F0=9F=A7=A9=20refactor:=20Persist=20Raw?= =?UTF-8?q?=20Run-Step=20Durations,=20Threshold=20At=20Render=20Only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5 --- api/server/controllers/agents/callbacks.js | 12 +++++---- client/src/hooks/SSE/useStepHandler.ts | 4 +-- .../stream/implementations/RedisJobStore.ts | 4 +-- packages/data-provider/src/runSteps.spec.ts | 26 ++++++++----------- packages/data-provider/src/runSteps.ts | 20 +++++++------- .../data-provider/src/types/assistants.ts | 9 ++++--- 6 files changed, 36 insertions(+), 39 deletions(-) diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index de66d31d3de..cb72f2ae37d 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -8,7 +8,7 @@ const { FileContext, ErrorTypes, UsageEvents, - getReportableRunStepDurationMs, + getRunStepDurationMs, } = require('librechat-data-provider'); const { GraphEvents, @@ -462,11 +462,13 @@ function getDefaultHandlers({ if (part?.type === ContentTypes.TOOL_CALL && part.tool_call) { part.tool_call.runStepStatus = data.status; /** - * Left unset rather than zeroed when the event cannot support a - * trustworthy duration, so a reader can tell "we don't know" from - * "it was fast". + * 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 = getReportableRunStepDurationMs(data); + const durationMs = getRunStepDurationMs(data); if (durationMs != null) { part.tool_call.runStepDurationMs = durationMs; } diff --git a/client/src/hooks/SSE/useStepHandler.ts b/client/src/hooks/SSE/useStepHandler.ts index fab57787828..7107e27a9b6 100644 --- a/client/src/hooks/SSE/useStepHandler.ts +++ b/client/src/hooks/SSE/useStepHandler.ts @@ -7,7 +7,7 @@ import { ContentTypes, ToolCallTypes, getNonEmptyValue, - getReportableRunStepDurationMs, + getRunStepDurationMs, } from 'librechat-data-provider'; import type { Agents, @@ -1215,7 +1215,7 @@ export default function useStepHandler({ /** Spread conditionally so an unknowable duration leaves any value the * server already stamped in place, rather than overwriting it with * `undefined`. */ - const durationMs = getReportableRunStepDurationMs(closed); + const durationMs = getRunStepDurationMs(closed); const updatedContent = [...(response.content ?? [])]; updatedContent[currentIndex] = { ...existing, diff --git a/packages/api/src/stream/implementations/RedisJobStore.ts b/packages/api/src/stream/implementations/RedisJobStore.ts index ec687367207..c4934d9027f 100644 --- a/packages/api/src/stream/implementations/RedisJobStore.ts +++ b/packages/api/src/stream/implementations/RedisJobStore.ts @@ -1,6 +1,6 @@ import { logger } from '@librechat/data-schemas'; import { createContentAggregator } from '@librechat/agents'; -import { ContentTypes, getReportableRunStepDurationMs } from 'librechat-data-provider'; +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'; @@ -3134,7 +3134,7 @@ export class RedisJobStore implements IJobStoreV2 { 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 = getReportableRunStepDurationMs(closed); + const durationMs = getRunStepDurationMs(closed); if (durationMs != null) { part.tool_call.runStepDurationMs = durationMs; } diff --git a/packages/data-provider/src/runSteps.spec.ts b/packages/data-provider/src/runSteps.spec.ts index b4726b7d724..547e1f27e72 100644 --- a/packages/data-provider/src/runSteps.spec.ts +++ b/packages/data-provider/src/runSteps.spec.ts @@ -1,7 +1,6 @@ import { getRunStepDurationMs, isReportableRunStepDuration, - getReportableRunStepDurationMs, MIN_REPORTABLE_RUN_STEP_DURATION_MS, } from './runSteps'; @@ -60,18 +59,15 @@ describe('isReportableRunStepDuration', () => { }); }); -describe('getReportableRunStepDurationMs', () => { - it('returns the duration when it is both derivable and worth showing', () => { - expect(getReportableRunStepDurationMs({ created_at: 1000, closed_at: 4500 })).toBe(3500); - }); - - /** The distinction the callers rely on: absent means "not knowable or not - * worth reporting", never "instant". */ - it('returns undefined for a step too fast to be worth reporting', () => { - expect(getReportableRunStepDurationMs({ created_at: 1000, closed_at: 1300 })).toBeUndefined(); - }); - - it('returns undefined when the duration is not derivable at all', () => { - expect(getReportableRunStepDurationMs({ closed_at: 4500 })).toBeUndefined(); - }); +/** + * 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); }); diff --git a/packages/data-provider/src/runSteps.ts b/packages/data-provider/src/runSteps.ts index ecbf7c28742..5fa2217d7b1 100644 --- a/packages/data-provider/src/runSteps.ts +++ b/packages/data-provider/src/runSteps.ts @@ -52,17 +52,15 @@ export function getRunStepDurationMs(closed: RunStepTimestamps): number | undefi return durationMs >= 0 ? durationMs : undefined; } -/** Whether a derived duration is worth showing to the reader. */ -export function isReportableRunStepDuration(durationMs?: number): durationMs is number { - return typeof durationMs === 'number' && durationMs >= MIN_REPORTABLE_RUN_STEP_DURATION_MS; -} - /** - * Convenience for the three sites that stamp a closure onto a content part - * (live SSE, server-side aggregation, and Redis replay reconstruction): the - * value to persist, or `undefined` when nothing should be written. + * 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 getReportableRunStepDurationMs(closed: RunStepTimestamps): number | undefined { - const durationMs = getRunStepDurationMs(closed); - return isReportableRunStepDuration(durationMs) ? durationMs : undefined; +export function isReportableRunStepDuration(durationMs?: number): durationMs is number { + return typeof durationMs === 'number' && durationMs >= MIN_REPORTABLE_RUN_STEP_DURATION_MS; } diff --git a/packages/data-provider/src/types/assistants.ts b/packages/data-provider/src/types/assistants.ts index a9790a90f07..3654857c76c 100644 --- a/packages/data-provider/src/types/assistants.ts +++ b/packages/data-provider/src/types/assistants.ts @@ -590,10 +590,11 @@ export type PartMetadata = { /** * Wall-clock milliseconds the run step took, derived from the same * `on_run_step_closed` event as {@link runStepStatus} via - * `getReportableRunStepDurationMs`. Only written when the event carried - * both timestamps, they agree in order, and the result clears - * `MIN_REPORTABLE_RUN_STEP_DURATION_MS` — so its absence means "not worth - * reporting or not knowable", never "instant". + * `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; }; From 0c0946e12a380da5b71af8cd48a93acbb93e6d73 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 15:41:00 +0000 Subject: [PATCH 06/10] =?UTF-8?q?=F0=9F=90=9B=20fix:=20Suppress=20Duration?= =?UTF-8?q?=20On=20Backgrounded=20Bash=20And=20Code=20Cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5 --- .../src/components/Chat/Messages/Content/Parts/BashCall.tsx | 6 +++++- .../components/Chat/Messages/Content/Parts/ExecuteCode.tsx | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx b/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx index ae1309746b9..39294240c2d 100644 --- a/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx @@ -110,7 +110,11 @@ export default function BashCall({ ? localize('com_ui_cancelled') : (backgroundFinishedText ?? intent ?? localize('com_ui_command_finished')) } - durationMs={runStepDurationMs} + /** A backgrounded call's run step closes when dispatch returns the + * handle, so its duration is the dispatch time — showing it beside + * "Running/Finished in background" would misstate a detached + * task's runtime as seconds. */ + durationMs={backgroundHandle == null ? runStepDurationMs : undefined} errorSuffix={ (hasError && !cancelled) || backgroundFailed ? localize('com_ui_tool_failed') diff --git a/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx b/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx index 627789ac710..7f9c3eb1509 100644 --- a/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx @@ -122,7 +122,11 @@ export default function ExecuteCode({ ? localize('com_ui_cancelled') : (backgroundFinishedText ?? intent ?? localize('com_ui_analyzing_finished')) } - durationMs={runStepDurationMs} + /** A backgrounded call's run step closes when dispatch returns the + * handle, so its duration is the dispatch time — showing it beside + * "Running/Finished in background" would misstate a detached + * task's runtime as seconds. */ + durationMs={backgroundHandle == null ? runStepDurationMs : undefined} errorSuffix={ (hasError && !cancelled) || backgroundFailed ? localize('com_ui_tool_failed') From 62b97e8a0f6321f7ba9e60061849c46953bd1263 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 15:41:01 +0000 Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=8C=8D=20fix:=20Format=20The=20Sub-?= =?UTF-8?q?10s=20Decimal=20For=20The=20Active=20Locale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5 --- .../Chat/Messages/Content/ProgressText.tsx | 5 +++- .../Content/__tests__/ProgressText.test.tsx | 4 +++ .../utils/__tests__/runStepDuration.spec.ts | 23 +++++++++++---- client/src/utils/runStepDuration.ts | 28 +++++++++++++++++-- packages/data-provider/src/runSteps.ts | 8 ++++++ 5 files changed, 58 insertions(+), 10 deletions(-) diff --git a/client/src/components/Chat/Messages/Content/ProgressText.tsx b/client/src/components/Chat/Messages/Content/ProgressText.tsx index ea200bdc076..5ff5a79ba86 100644 --- a/client/src/components/Chat/Messages/Content/ProgressText.tsx +++ b/client/src/components/Chat/Messages/Content/ProgressText.tsx @@ -1,5 +1,6 @@ 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'; @@ -68,6 +69,8 @@ export default function ProgressText({ 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; @@ -103,7 +106,7 @@ export default function ProgressText({ */ const duration = progress >= 1 && !error && !errorSuffix && isReportableRunStepDuration(durationMs) - ? getRunStepDurationLabels(durationMs) + ? getRunStepDurationLabels(durationMs, i18n.language) : undefined; return ( diff --git a/client/src/components/Chat/Messages/Content/__tests__/ProgressText.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ProgressText.test.tsx index 990c64d5d45..56ce5a9b3b6 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/ProgressText.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/ProgressText.test.tsx @@ -18,6 +18,10 @@ jest.mock('~/hooks', () => ({ }, })); +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ i18n: { language: 'en' } }), +})); + jest.mock('../CancelledIcon', () => ({ __esModule: true, default: () => , diff --git a/client/src/utils/__tests__/runStepDuration.spec.ts b/client/src/utils/__tests__/runStepDuration.spec.ts index 29e299d576e..d6c7f5dc368 100644 --- a/client/src/utils/__tests__/runStepDuration.spec.ts +++ b/client/src/utils/__tests__/runStepDuration.spec.ts @@ -3,14 +3,25 @@ import { getRunStepDurationLabels } from '../runStepDuration'; describe('getRunStepDurationLabels', () => { describe('under ten seconds', () => { it('keeps one decimal, where the tenth still distinguishes two durations', () => { - expect(getRunStepDurationLabels(1400)).toMatchObject({ + expect(getRunStepDurationLabels(1400, 'en')).toMatchObject({ key: 'com_ui_duration_seconds', - values: { 0: 1.4 }, + values: { 0: '1.4' }, }); }); it('drops a trailing zero rather than rendering "1.0s"', () => { - expect(getRunStepDurationLabels(1000).values).toEqual({ 0: 1 }); + 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', () => { @@ -23,11 +34,11 @@ describe('getRunStepDurationLabels', () => { describe('ten seconds to a minute', () => { it('rounds to whole seconds, where the tenth is only jitter', () => { - expect(getRunStepDurationLabels(12_400)).toMatchObject({ + expect(getRunStepDurationLabels(12_400, 'en')).toMatchObject({ key: 'com_ui_duration_seconds', - values: { 0: 12 }, + values: { 0: '12' }, }); - expect(getRunStepDurationLabels(12_600).values).toEqual({ 0: 13 }); + expect(getRunStepDurationLabels(12_600, 'en').values).toEqual({ 0: '13' }); }); }); diff --git a/client/src/utils/runStepDuration.ts b/client/src/utils/runStepDuration.ts index dd7e447d149..ead07253a8b 100644 --- a/client/src/utils/runStepDuration.ts +++ b/client/src/utils/runStepDuration.ts @@ -17,6 +17,22 @@ export interface RunStepDurationLabels { announcedValues: Record; } +/** + * The sub-10s value is the only fractional number this feature renders, and + * interpolating it raw would hardcode the en-US decimal point into every + * locale ("1.4s" where the convention is "1,4 s"). 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 formatSecondsValue(seconds: number, language?: string): string { + try { + return new Intl.NumberFormat(language, { maximumFractionDigits: 1 }).format(seconds); + } catch { + return String(seconds); + } +} + /** * Resolve the localization keys and interpolation values for a run-step * duration. @@ -31,7 +47,10 @@ export interface RunStepDurationLabels { * the precise value stays on the button, which assistive technology reads * when the reader navigates to it. */ -export function getRunStepDurationLabels(durationMs: number): RunStepDurationLabels { +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 @@ -41,9 +60,12 @@ export function getRunStepDurationLabels(durationMs: number): RunStepDurationLab 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 = formatSecondsValue(seconds, language); return { key: 'com_ui_duration_seconds', - values: { 0: 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. */ @@ -51,7 +73,7 @@ export function getRunStepDurationLabels(durationMs: number): RunStepDurationLab seconds === 1 ? 'com_ui_duration_announced_seconds_one' : 'com_ui_duration_announced_seconds', - announcedValues: { count: seconds }, + announcedValues: { count: formatted }, }; } diff --git a/packages/data-provider/src/runSteps.ts b/packages/data-provider/src/runSteps.ts index 5fa2217d7b1..445b14f2960 100644 --- a/packages/data-provider/src/runSteps.ts +++ b/packages/data-provider/src/runSteps.ts @@ -39,6 +39,14 @@ export const MIN_REPORTABLE_RUN_STEP_DURATION_MS = 1000; * 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; From 7f960c1dd0cd60c221161f4746ae27f8edbac159 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 15:53:46 +0000 Subject: [PATCH 08/10] =?UTF-8?q?=F0=9F=90=9B=20fix:=20Persist=20A=20Durab?= =?UTF-8?q?le=20`backgrounded`=20Marker=20Through=20Harvest;=20Localize=20?= =?UTF-8?q?Minute=20Digits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5 --- .../components/Chat/Messages/Content/Part.tsx | 3 ++ .../Chat/Messages/Content/Parts/BashCall.tsx | 14 ++++++--- .../Messages/Content/Parts/ExecuteCode.tsx | 14 ++++++--- .../utils/__tests__/runStepDuration.spec.ts | 27 ++++++++++------- client/src/utils/runStepDuration.ts | 29 +++++++++++-------- packages/api/src/agents/harvest.ts | 8 +++++ .../data-provider/src/types/assistants.ts | 9 ++++++ packages/data-schemas/src/methods/message.ts | 18 +++++++++++- 8 files changed, 91 insertions(+), 31 deletions(-) diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx index c57c1861c5e..75883409cea 100644 --- a/client/src/components/Chat/Messages/Content/Part.tsx +++ b/client/src/components/Chat/Messages/Content/Part.tsx @@ -193,6 +193,7 @@ const Part = memo(function Part({ isSubmitting={isSubmitting} runStepStatus={toolCall.runStepStatus} runStepDurationMs={toolCall.runStepDurationMs} + backgrounded={toolCall.backgrounded} attachments={attachments} commandField="code" hideAttachments={hideAttachments} @@ -211,6 +212,7 @@ const Part = memo(function Part({ isSubmitting={isSubmitting} runStepStatus={toolCall.runStepStatus} runStepDurationMs={toolCall.runStepDurationMs} + backgrounded={toolCall.backgrounded} output={toolCall.output ?? ''} initialProgress={toolCall.progress ?? 0.1} args={toolCall.args} @@ -326,6 +328,7 @@ const Part = memo(function Part({ isSubmitting={isSubmitting} runStepStatus={toolCall.runStepStatus} runStepDurationMs={toolCall.runStepDurationMs} + backgrounded={toolCall.backgrounded} attachments={attachments} hideAttachments={hideAttachments} onExpand={onToolExpand} diff --git a/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx b/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx index 39294240c2d..d87dc0b67e2 100644 --- a/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx @@ -20,6 +20,7 @@ export default function BashCall({ isSubmitting, runStepStatus, runStepDurationMs, + backgrounded, initialProgress = 0.1, args, output = '', @@ -33,6 +34,7 @@ export default function BashCall({ isSubmitting: boolean; runStepStatus?: PartMetadata['runStepStatus']; runStepDurationMs?: PartMetadata['runStepDurationMs']; + backgrounded?: PartMetadata['backgrounded']; args?: string | Record; output?: string; attachments?: TAttachment[]; @@ -111,10 +113,14 @@ export default function BashCall({ : (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 beside - * "Running/Finished in background" would misstate a detached - * task's runtime as seconds. */ - durationMs={backgroundHandle == null ? runStepDurationMs : undefined} + * 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') diff --git a/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx b/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx index 7f9c3eb1509..94d7523e93d 100644 --- a/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx @@ -58,6 +58,7 @@ export default function ExecuteCode({ isSubmitting, runStepStatus, runStepDurationMs, + backgrounded, initialProgress = 0.1, args, output = '', @@ -70,6 +71,7 @@ export default function ExecuteCode({ isSubmitting: boolean; runStepStatus?: PartMetadata['runStepStatus']; runStepDurationMs?: PartMetadata['runStepDurationMs']; + backgrounded?: PartMetadata['backgrounded']; args?: string | Record; output?: string; attachments?: TAttachment[]; @@ -123,10 +125,14 @@ export default function ExecuteCode({ : (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 beside - * "Running/Finished in background" would misstate a detached - * task's runtime as seconds. */ - durationMs={backgroundHandle == null ? runStepDurationMs : undefined} + * 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') diff --git a/client/src/utils/__tests__/runStepDuration.spec.ts b/client/src/utils/__tests__/runStepDuration.spec.ts index d6c7f5dc368..c4af8e3c5f6 100644 --- a/client/src/utils/__tests__/runStepDuration.spec.ts +++ b/client/src/utils/__tests__/runStepDuration.spec.ts @@ -44,34 +44,41 @@ describe('getRunStepDurationLabels', () => { describe('a minute and over', () => { it('splits into minutes and seconds', () => { - expect(getRunStepDurationLabels(65_000)).toMatchObject({ + expect(getRunStepDurationLabels(65_000, 'en')).toMatchObject({ key: 'com_ui_duration_minutes', - values: { 0: 1, 1: 5 }, + values: { 0: '1', 1: '5' }, }); - expect(getRunStepDurationLabels(723_000).values).toEqual({ 0: 12, 1: 3 }); + expect(getRunStepDurationLabels(723_000, 'en').values).toEqual({ 0: '12', 1: '3' }); }); it('renders an exact minute without a stray remainder', () => { - expect(getRunStepDurationLabels(60_000).values).toEqual({ 0: 1, 1: 0 }); + 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)).toMatchObject({ + expect(getRunStepDurationLabels(59_600, 'en')).toMatchObject({ key: 'com_ui_duration_minutes', - values: { 0: 1, 1: 0 }, + values: { 0: '1', 1: '0' }, }); }); it('announces whole minutes, leaving the precise value on the button', () => { - expect(getRunStepDurationLabels(65_000)).toMatchObject({ + expect(getRunStepDurationLabels(65_000, 'en')).toMatchObject({ announcedKey: 'com_ui_duration_announced_minutes_one', - announcedValues: { count: 1 }, + announcedValues: { count: '1' }, }); - expect(getRunStepDurationLabels(150_000)).toMatchObject({ + expect(getRunStepDurationLabels(150_000, 'en')).toMatchObject({ announcedKey: 'com_ui_duration_announced_minutes', - announcedValues: { count: 3 }, + 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: '٥' }); + }); }); }); diff --git a/client/src/utils/runStepDuration.ts b/client/src/utils/runStepDuration.ts index ead07253a8b..964fff1c852 100644 --- a/client/src/utils/runStepDuration.ts +++ b/client/src/utils/runStepDuration.ts @@ -18,18 +18,20 @@ export interface RunStepDurationLabels { } /** - * The sub-10s value is the only fractional number this feature renders, and - * interpolating it raw would hardcode the en-US decimal point into every - * locale ("1.4s" where the convention is "1,4 s"). 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. + * 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 formatSecondsValue(seconds: number, language?: string): string { +function formatDurationValue(value: number, language?: string): string { try { - return new Intl.NumberFormat(language, { maximumFractionDigits: 1 }).format(seconds); + return new Intl.NumberFormat(language, { maximumFractionDigits: 1 }).format(value); } catch { - return String(seconds); + return String(value); } } @@ -62,7 +64,7 @@ export function getRunStepDurationLabels( : Math.round(totalSeconds); /** Plural selection stays on the numeric value; only the interpolated * text is locale-formatted. */ - const formatted = formatSecondsValue(seconds, language); + const formatted = formatDurationValue(seconds, language); return { key: 'com_ui_duration_seconds', values: { 0: formatted }, @@ -83,11 +85,14 @@ export function getRunStepDurationLabels( const announcedMinutes = Math.round(totalSeconds / SECONDS_PER_MINUTE); return { key: 'com_ui_duration_minutes', - values: { 0: minutes, 1: seconds }, + 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: announcedMinutes }, + announcedValues: { count: formatDurationValue(announcedMinutes, language) }, }; } diff --git a/packages/api/src/agents/harvest.ts b/packages/api/src/agents/harvest.ts index a25846c1bad..b3d59611b63 100644 --- a/packages/api/src/agents/harvest.ts +++ b/packages/api/src/agents/harvest.ts @@ -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): diff --git a/packages/data-provider/src/types/assistants.ts b/packages/data-provider/src/types/assistants.ts index 3654857c76c..16f6415da75 100644 --- a/packages/data-provider/src/types/assistants.ts +++ b/packages/data-provider/src/types/assistants.ts @@ -597,6 +597,15 @@ export type PartMetadata = { * 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 */ diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index e596e6e5e77..ca1928a9073 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -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[] = []; 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 } : {}), + }, + ], }, }, ], From dc89c1c335284a9d3667a73a8886a1f47dfa1fb2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 16:01:41 +0000 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=A7=AA=20test:=20Assert=20The=20`ma?= =?UTF-8?q?rkBackgrounded`=20Stamp=20In=20Harvest=20Expectations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5 --- api/server/controllers/agents/callbacks.background.spec.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/server/controllers/agents/callbacks.background.spec.js b/api/server/controllers/agents/callbacks.background.spec.js index a5eb86e380b..57275972136 100644 --- a/api/server/controllers/agents/callbacks.background.spec.js +++ b/api/server/controllers/agents/callbacks.background.spec.js @@ -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,7 @@ 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 +181,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' }] }); From fc1c04b0f1d46237ddc82655e460de517e1030f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 16:02:18 +0000 Subject: [PATCH 10/10] =?UTF-8?q?=F0=9F=8E=A8=20style:=20Wrap=20Harvest=20?= =?UTF-8?q?Spec=20Expectation=20Per=20Prettier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5 --- api/server/controllers/agents/callbacks.background.spec.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/server/controllers/agents/callbacks.background.spec.js b/api/server/controllers/agents/callbacks.background.spec.js index 57275972136..a327da87d9d 100644 --- a/api/server/controllers/agents/callbacks.background.spec.js +++ b/api/server/controllers/agents/callbacks.background.spec.js @@ -157,7 +157,11 @@ describe('createBackgroundCodeResultHandler', () => { const result = await handler(baseParams); expect(updateToolCallResult).toHaveBeenCalledWith( - expect.objectContaining({ output: 'stdout:\nhello', attachments: [], markBackgrounded: true }), + expect.objectContaining({ + output: 'stdout:\nhello', + attachments: [], + markBackgrounded: true, + }), ); expect(result).toEqual({ attachments: [] }); });