From fb8ae881cf17a564d14a159f2809ab0df0c6c71d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 16 Aug 2026 16:17:02 -0400 Subject: [PATCH] =?UTF-8?q?=E2=8F=B1=EF=B8=8F=20feat:=20Show=20Run-Step=20?= =?UTF-8?q?Durations=20On=20Tool=20Cards=20(#14892)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * โฑ๏ธ 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 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 Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5 * ๐Ÿ› fix: Accept Partial Timestamps In Run-Step Duration Helper `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 * ๐Ÿ› 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 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 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 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 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 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 Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5 * ๐ŸŽจ style: Wrap Harvest Spec Expectation Per Prettier Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5 --------- Co-authored-by: Claude --- .../agents/callbacks.background.spec.js | 10 +- api/server/controllers/agents/callbacks.js | 12 ++ .../components/Chat/Messages/Content/Part.tsx | 12 ++ .../Chat/Messages/Content/Parts/BashCall.tsx | 13 ++ .../Messages/Content/Parts/ExecuteCode.tsx | 13 ++ .../Content/Parts/FileAuthoringCall.tsx | 3 + .../Messages/Content/Parts/ReadFileCall.tsx | 3 + .../Chat/Messages/Content/Parts/SkillCall.tsx | 3 + .../Chat/Messages/Content/ProgressText.tsx | 44 ++++++- .../Chat/Messages/Content/RetrievalCall.tsx | 3 + .../Chat/Messages/Content/ToolCall.tsx | 3 + .../Content/__tests__/ProgressText.test.tsx | 113 ++++++++++++++++++ client/src/hooks/SSE/useStepHandler.ts | 6 + client/src/locales/en/translation.json | 6 + .../utils/__tests__/runStepDuration.spec.ts | 84 +++++++++++++ client/src/utils/index.ts | 1 + client/src/utils/runStepDuration.ts | 98 +++++++++++++++ packages/api/src/agents/harvest.ts | 8 ++ .../stream/implementations/RedisJobStore.ts | 8 +- packages/data-provider/src/index.ts | 2 + packages/data-provider/src/runSteps.spec.ts | 73 +++++++++++ packages/data-provider/src/runSteps.ts | 74 ++++++++++++ .../data-provider/src/types/assistants.ts | 19 +++ packages/data-schemas/src/methods/message.ts | 18 ++- 24 files changed, 625 insertions(+), 4 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.background.spec.js b/api/server/controllers/agents/callbacks.background.spec.js index a5eb86e380..a327da87d9 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,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' }] }); diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index a43b387d73..cb72f2ae37 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -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 }); diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx index 6c26dcd32a..75883409ce 100644 --- a/client/src/components/Chat/Messages/Content/Part.tsx +++ b/client/src/components/Chat/Messages/Content/Part.tsx @@ -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} diff --git a/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx b/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx index 41c2f34189..d87dc0b67e 100644 --- a/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx @@ -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; 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') diff --git a/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx b/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx index 6af4c79982..94d7523e93 100644 --- a/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx @@ -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; 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') diff --git a/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx b/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx index e36987c1e4..bb531054e7 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 +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 ( @@ -105,6 +131,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 0be8054663..862b397846 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('react-i18next', () => ({ + useTranslation: () => ({ i18n: { language: 'en' } }), +})); + +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. 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'); + }); + }); +}); diff --git a/client/src/hooks/SSE/useStepHandler.ts b/client/src/hooks/SSE/useStepHandler.ts index 0b703648e1..7107e27a9b 100644 --- a/client/src/hooks/SSE/useStepHandler.ts +++ b/client/src/hooks/SSE/useStepHandler.ts @@ -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 }), }, }; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 1c9c545ecc..d9c6391a20 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -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", diff --git a/client/src/utils/__tests__/runStepDuration.spec.ts b/client/src/utils/__tests__/runStepDuration.spec.ts new file mode 100644 index 0000000000..c4af8e3c5f --- /dev/null +++ b/client/src/utils/__tests__/runStepDuration.spec.ts @@ -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: 'ูฅ' }); + }); + }); +}); diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts index 91f47f129a..f6a3fb0d3c 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 0000000000..964fff1c85 --- /dev/null +++ b/client/src/utils/runStepDuration.ts @@ -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; + /** Spoken form for assistive technology, e.g. "took 1.4 seconds". */ + announcedKey: TranslationKeys; + announcedValues: Record; +} + +/** + * 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) }, + }; +} diff --git a/packages/api/src/agents/harvest.ts b/packages/api/src/agents/harvest.ts index a25846c1ba..b3d59611b6 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/api/src/stream/implementations/RedisJobStore.ts b/packages/api/src/stream/implementations/RedisJobStore.ts index c8a03669c0..c4934d9027 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 } 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; } diff --git a/packages/data-provider/src/index.ts b/packages/data-provider/src/index.ts index 7ecaf4ba8a..abe2ae4aaa 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 0000000000..547e1f27e7 --- /dev/null +++ b/packages/data-provider/src/runSteps.spec.ts @@ -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); +}); diff --git a/packages/data-provider/src/runSteps.ts b/packages/data-provider/src/runSteps.ts new file mode 100644 index 0000000000..445b14f296 --- /dev/null +++ b/packages/data-provider/src/runSteps.ts @@ -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; +} diff --git a/packages/data-provider/src/types/assistants.ts b/packages/data-provider/src/types/assistants.ts index 1a8cfecbdb..16f6415da7 100644 --- a/packages/data-provider/src/types/assistants.ts +++ b/packages/data-provider/src/types/assistants.ts @@ -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 */ diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index e596e6e5e7..ca1928a907 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 } : {}), + }, + ], }, }, ],