diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js
index f451954c5b..f72c0d61fa 100644
--- a/api/server/controllers/agents/callbacks.js
+++ b/api/server/controllers/agents/callbacks.js
@@ -4,6 +4,7 @@ const {
Tools,
StepTypes,
StepEvents,
+ ContentTypes,
FileContext,
ErrorTypes,
UsageEvents,
@@ -427,6 +428,43 @@ function getDefaultHandlers({
}
},
},
+ [GraphEvents.ON_RUN_STEP_CLOSED]: {
+ /**
+ * Handle ON_RUN_STEP_CLOSED event — the terminal signal for a run step.
+ *
+ * Stamped onto the aggregated part before it is forwarded. The SDK's
+ * `aggregateContent` has no notion of this event, so without stamping
+ * here the status would exist only on the live client message: a reload
+ * or a resumable reconnect would drop it and fall back to inferring
+ * "stopped" from `isSubmitting`, which is the behavior this fixes.
+ *
+ * Forwarded unconditionally, without the visibility gating the other
+ * step events apply — a step whose `on_run_step` reached the client must
+ * get its closure, or the client is left inferring again.
+ *
+ * @param {string} event - The event name.
+ * @param {RunStepClosedEvent} data - The event data.
+ */
+ handle: async (event, data) => {
+ const stepId = data?.id;
+ if (typeof stepId === 'string' && contentParts) {
+ /**
+ * Resolved through `stepMap` only. The event's own `index` is the
+ * SDK's, and the steer/HITL offset wrappers shift `ON_RUN_STEP` but
+ * pass closures through untouched — so falling back to it would
+ * stamp an unrelated part in any run containing an injection.
+ * Skipping is the safe failure here; a missing status degrades to
+ * the old heuristic, a misplaced one mislabels the wrong card.
+ */
+ const index = stepMap?.get(stepId)?.index;
+ const part = typeof index === 'number' ? contentParts[index] : undefined;
+ if (part?.type === ContentTypes.TOOL_CALL && part.tool_call) {
+ part.tool_call.runStepStatus = data.status;
+ }
+ }
+ await emitForJob({ event, data });
+ },
+ },
[GraphEvents.ON_RUN_STEP_DELTA]: {
/**
* Handle ON_RUN_STEP_DELTA event.
diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx
index 61f9a105d5..adb0e214cf 100644
--- a/client/src/components/Chat/Messages/Content/Part.tsx
+++ b/client/src/components/Chat/Messages/Content/Part.tsx
@@ -191,6 +191,7 @@ const Part = memo(function Part({
output={toolCall.output ?? ''}
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
+ runStepStatus={toolCall.runStepStatus}
attachments={attachments}
commandField="code"
hideAttachments={hideAttachments}
@@ -207,6 +208,7 @@ const Part = memo(function Part({
);
})();
diff --git a/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx b/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx
index cbacd2a0c1..41c2f34189 100644
--- a/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx
+++ b/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx
@@ -1,7 +1,7 @@
import { useMemo, useRef, useState, useCallback, useEffect } from 'react';
import copy from 'copy-to-clipboard';
import { useRecoilValue } from 'recoil';
-import type { TAttachment } from 'librechat-data-provider';
+import type { TAttachment, PartMetadata } from 'librechat-data-provider';
import { parseBackgroundHandle, splitBackgroundAttachments } from './handle';
import ProgressText from '~/components/Chat/Messages/Content/ProgressText';
import parseJsonField, { areToolCallArgsComplete } from './parseJsonField';
@@ -18,6 +18,7 @@ import { cn } from '~/utils';
export default function BashCall({
isSubmitting,
+ runStepStatus,
initialProgress = 0.1,
args,
output = '',
@@ -29,6 +30,7 @@ export default function BashCall({
}: {
initialProgress: number;
isSubmitting: boolean;
+ runStepStatus?: PartMetadata['runStepStatus'];
args?: string | Record;
output?: string;
attachments?: TAttachment[];
@@ -43,7 +45,7 @@ export default function BashCall({
const sandboxStarting = useRecoilValue(sandboxStartingByToolCallId(toolCallId ?? ''));
const { showCode, toggleCode, expandStyle, expandRef, progress, cancelled, hasError, hasOutput } =
- useToolCallState(initialProgress, isSubmitting, output, !!command, onExpand);
+ useToolCallState(initialProgress, isSubmitting, output, !!command, onExpand, runStepStatus);
const highlighted = useLazyHighlight(command || undefined, 'bash');
const outputHasError = useMemo(() => ERROR_PATTERNS.test(output), [output]);
diff --git a/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx b/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx
index 9eaf9776ed..6af4c79982 100644
--- a/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx
+++ b/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx
@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { SquareTerminal } from 'lucide-react';
-import type { TAttachment } from 'librechat-data-provider';
+import type { TAttachment, PartMetadata } from 'librechat-data-provider';
import { parseBackgroundHandle, splitBackgroundAttachments } from './handle';
import ProgressText from '~/components/Chat/Messages/Content/ProgressText';
import { sandboxStartingByToolCallId } from '~/store';
@@ -56,6 +56,7 @@ export const ERROR_PATTERNS = /^(Traceback|Error:|Exception:|.*Error:)/m;
export default function ExecuteCode({
isSubmitting,
+ runStepStatus,
initialProgress = 0.1,
args,
output = '',
@@ -66,6 +67,7 @@ export default function ExecuteCode({
}: {
initialProgress: number;
isSubmitting: boolean;
+ runStepStatus?: PartMetadata['runStepStatus'];
args?: string | Record;
output?: string;
attachments?: TAttachment[];
@@ -81,7 +83,7 @@ export default function ExecuteCode({
const sandboxStarting = useRecoilValue(sandboxStartingByToolCallId(toolCallId ?? ''));
const { showCode, toggleCode, expandStyle, expandRef, progress, cancelled, hasError, hasOutput } =
- useToolCallState(initialProgress, isSubmitting, output, !!code, onExpand);
+ useToolCallState(initialProgress, isSubmitting, output, !!code, onExpand, runStepStatus);
const highlighted = useLazyHighlight(code, lang);
const outputHasError = useMemo(() => ERROR_PATTERNS.test(output), [output]);
diff --git a/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx b/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx
index 28018ce94a..e36987c1e4 100644
--- a/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx
+++ b/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { FilePenLine, FilePlus2 } from 'lucide-react';
-import type { TAttachment } from 'librechat-data-provider';
+import type { TAttachment, PartMetadata } from 'librechat-data-provider';
import parseJsonField, { parseJsonFieldOccurrences } from './parseJsonField';
import ProgressText from '~/components/Chat/Messages/Content/ProgressText';
import useToolCallState from './useToolCallState';
@@ -106,6 +106,7 @@ function buildEditArgsPreview(args: ToolCallArgs): string {
export default function FileAuthoringCall({
toolName,
isSubmitting,
+ runStepStatus,
initialProgress = 0.1,
args,
output = '',
@@ -116,6 +117,7 @@ export default function FileAuthoringCall({
toolName: FileAuthoringToolName;
initialProgress: number;
isSubmitting: boolean;
+ runStepStatus?: PartMetadata['runStepStatus'];
args?: string | Record;
output?: string;
attachments?: TAttachment[];
@@ -148,7 +150,14 @@ export default function FileAuthoringCall({
}
const { showCode, toggleCode, expandStyle, expandRef, progress, cancelled, hasError } =
- useToolCallState(initialProgress, isSubmitting, output, !!filePath || !!preview, onExpand);
+ useToolCallState(
+ initialProgress,
+ isSubmitting,
+ output,
+ !!filePath || !!preview,
+ onExpand,
+ runStepStatus,
+ );
const highlighted = useLazyHighlight(preview || undefined, previewLang);
const Icon = isCreate && !overwrote ? FilePlus2 : FilePenLine;
diff --git a/client/src/components/Chat/Messages/Content/Parts/ReadFileCall.tsx b/client/src/components/Chat/Messages/Content/Parts/ReadFileCall.tsx
index 91c25cd816..ab303b3362 100644
--- a/client/src/components/Chat/Messages/Content/Parts/ReadFileCall.tsx
+++ b/client/src/components/Chat/Messages/Content/Parts/ReadFileCall.tsx
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { FileText } from 'lucide-react';
-import type { TAttachment } from 'librechat-data-provider';
+import type { TAttachment, PartMetadata } from 'librechat-data-provider';
import ProgressText from '~/components/Chat/Messages/Content/ProgressText';
import useToolCallState from './useToolCallState';
import useLazyHighlight from './useLazyHighlight';
@@ -64,6 +64,7 @@ export function langFromPath(filePath: string): string {
export default function ReadFileCall({
isSubmitting,
+ runStepStatus,
initialProgress = 0.1,
args,
output = '',
@@ -73,6 +74,7 @@ export default function ReadFileCall({
}: {
initialProgress: number;
isSubmitting: boolean;
+ runStepStatus?: PartMetadata['runStepStatus'];
args?: string | Record;
output?: string;
attachments?: TAttachment[];
@@ -86,7 +88,7 @@ export default function ReadFileCall({
const lang = useMemo(() => langFromPath(filePath), [filePath]);
const { showCode, toggleCode, expandStyle, expandRef, progress, cancelled, hasError, hasOutput } =
- useToolCallState(initialProgress, isSubmitting, output, !!filePath, onExpand);
+ useToolCallState(initialProgress, isSubmitting, output, !!filePath, onExpand, runStepStatus);
const highlighted = useLazyHighlight(hasOutput ? output : undefined, lang);
diff --git a/client/src/components/Chat/Messages/Content/Parts/SkillCall.tsx b/client/src/components/Chat/Messages/Content/Parts/SkillCall.tsx
index 83aadd7464..5a16991928 100644
--- a/client/src/components/Chat/Messages/Content/Parts/SkillCall.tsx
+++ b/client/src/components/Chat/Messages/Content/Parts/SkillCall.tsx
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { ScrollText } from 'lucide-react';
-import type { TAttachment } from 'librechat-data-provider';
+import type { TAttachment, PartMetadata } from 'librechat-data-provider';
import ProgressText from '~/components/Chat/Messages/Content/ProgressText';
import useToolCallState from './useToolCallState';
import { AttachmentGroup } from './Attachment';
@@ -12,6 +12,7 @@ import { cn } from '~/utils';
export default function SkillCall({
isSubmitting,
+ runStepStatus,
initialProgress = 0.1,
args,
output = '',
@@ -21,6 +22,7 @@ export default function SkillCall({
}: {
initialProgress: number;
isSubmitting: boolean;
+ runStepStatus?: PartMetadata['runStepStatus'];
args?: string | Record;
output?: string;
attachments?: TAttachment[];
@@ -32,7 +34,7 @@ export default function SkillCall({
const intent = useToolCallIntent(args);
const { showCode, toggleCode, expandStyle, expandRef, progress, cancelled, hasError, hasOutput } =
- useToolCallState(initialProgress, isSubmitting, output, !!skillName, onExpand);
+ useToolCallState(initialProgress, isSubmitting, output, !!skillName, onExpand, runStepStatus);
return (
<>
diff --git a/client/src/components/Chat/Messages/Content/Parts/useToolCallState.ts b/client/src/components/Chat/Messages/Content/Parts/useToolCallState.ts
index 7a487d4b7e..8b26dc7e53 100644
--- a/client/src/components/Chat/Messages/Content/Parts/useToolCallState.ts
+++ b/client/src/components/Chat/Messages/Content/Parts/useToolCallState.ts
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { useRecoilValue } from 'recoil';
+import type { PartMetadata } from 'librechat-data-provider';
import { isError } from '~/components/Chat/Messages/Content/ToolOutput';
import { useProgress, useExpandCollapse } from '~/hooks';
import store from '~/store';
@@ -22,10 +23,15 @@ export default function useToolCallState(
output: string,
hasInput: boolean,
onExpand?: () => void,
+ runStepStatus?: PartMetadata['runStepStatus'],
): ToolCallState {
const autoExpand = useRecoilValue(store.autoExpandTools);
const hasOutput = output.length > 0;
- const hasError = hasOutput && isError(output);
+ /**
+ * A step the run closed as `failed` is an error even when its output does
+ * not look like one — the run knows something the output text does not.
+ */
+ const hasError = (hasOutput && isError(output)) || runStepStatus === 'failed';
const hasContent = hasInput || hasOutput;
const [showCode, setShowCode] = useState(() => autoExpand && hasContent);
@@ -37,7 +43,14 @@ export default function useToolCallState(
}
}, [autoExpand, hasContent]);
- const progress = useProgress(initialProgress);
+ /**
+ * A closed step is terminal by definition, so it must never animate. Forcing
+ * the bar complete keeps the shimmer from outliving a step that closed
+ * without ever emitting a completion event.
+ */
+ const isClosed = runStepStatus != null;
+ const rawProgress = useProgress(initialProgress);
+ const progress = isClosed ? 1 : rawProgress;
const toggleCode = useCallback(() => {
setShowCode((prev) => {
const next = !prev;
@@ -47,7 +60,19 @@ export default function useToolCallState(
return next;
});
}, [onExpand]);
- const cancelled = !isSubmitting && progress < 1 && !hasError;
+ /**
+ * The step's own terminal status wins when the run emitted one; the
+ * `isSubmitting` heuristic is a whole-message inference that cannot tell
+ * which step stopped. Kept as the fallback for messages saved before
+ * `on_run_step_closed` and endpoints that do not emit it.
+ *
+ * The status is authoritative on its own terms — it is deliberately not
+ * gated on `hasError`, so that parsing the output text can never demote a
+ * step the run reported as stopped back into an in-flight state.
+ */
+ const cancelled = isClosed
+ ? runStepStatus === 'cancelled'
+ : !isSubmitting && rawProgress < 1 && !hasError;
return {
showCode,
diff --git a/client/src/components/Chat/Messages/Content/ToolCall.tsx b/client/src/components/Chat/Messages/Content/ToolCall.tsx
index 6c1231920c..e6e80d86e0 100644
--- a/client/src/components/Chat/Messages/Content/ToolCall.tsx
+++ b/client/src/components/Chat/Messages/Content/ToolCall.tsx
@@ -9,7 +9,7 @@ import {
actionDomainSeparator,
splitToolCallName,
} from 'librechat-data-provider';
-import type { TAttachment } from 'librechat-data-provider';
+import type { TAttachment, PartMetadata } from 'librechat-data-provider';
import { useLocalize, useProgress, useExpandCollapse } from '~/hooks';
import { ToolIcon, getToolIconType, isError } from './ToolOutput';
import { useMCPIconMap, useMCPServerNames } from '~/hooks/MCP';
@@ -32,6 +32,7 @@ export default function ToolCall({
auth,
hideAttachments = false,
onExpand,
+ runStepStatus,
}: {
initialProgress: number;
isLast?: boolean;
@@ -44,6 +45,7 @@ export default function ToolCall({
auth?: string;
hideAttachments?: boolean;
onExpand?: () => void;
+ runStepStatus?: PartMetadata['runStepStatus'];
}) {
const localize = useLocalize();
const autoExpand = useRecoilValue(store.autoExpandTools);
@@ -137,8 +139,25 @@ export default function ToolCall({
window.open(auth, '_blank', 'noopener,noreferrer');
}, [auth, isMCPToolCall, mcpServerName, actionId]);
- const hasError = typeof output === 'string' && isError(output);
- const cancelled = !isSubmitting && initialProgress < 1 && !hasError;
+ /** A step the run closed as `failed` is an error even when its output text
+ * does not parse as one. */
+ const hasError = (typeof output === 'string' && isError(output)) || runStepStatus === 'failed';
+ /**
+ * The step's own terminal status wins when the run emitted one. The
+ * `isSubmitting` heuristic below it is a whole-message inference: it cannot
+ * tell which step actually stopped, so it holds every unfinished call in a
+ * running state until the entire response ends and then flips them all to
+ * cancelled at once. Retained as the fallback for messages saved before
+ * `on_run_step_closed` and for endpoints that do not emit it.
+ *
+ * The status is authoritative on its own terms — deliberately not gated on
+ * `hasError`, so output parsing cannot demote a stopped step back into an
+ * in-flight state.
+ */
+ const isClosed = runStepStatus != null;
+ const cancelled = isClosed
+ ? runStepStatus === 'cancelled'
+ : !isSubmitting && initialProgress < 1 && !hasError;
const errorState = hasError;
const args = useMemo(() => {
@@ -165,7 +184,9 @@ export default function ToolCall({
return parsedAuthUrl?.hostname ?? '';
}, [parsedAuthUrl]);
- const progress = useProgress(initialProgress);
+ /** A closed step is terminal, so it must never keep animating. */
+ const rawProgress = useProgress(initialProgress);
+ const progress = isClosed ? 1 : rawProgress;
const showCancelled = cancelled || (errorState && !output);
const handleToggleInfo = useCallback(() => {
@@ -197,6 +218,16 @@ export default function ToolCall({
if (cancelled) {
return localize('com_ui_cancelled');
}
+ /**
+ * Announced before the completion strings below: a terminal step that
+ * errored must not reach the live region as "completed", which would tell
+ * a screen-reader user the opposite of what the card shows.
+ */
+ if (errorState) {
+ return function_name
+ ? `${localize('com_ui_failed')}: ${function_name}`
+ : localize('com_ui_failed');
+ }
if (intent != null) {
return intent;
}
diff --git a/client/src/hooks/SSE/useStepHandler.ts b/client/src/hooks/SSE/useStepHandler.ts
index e2487ca00a..0b703648e1 100644
--- a/client/src/hooks/SSE/useStepHandler.ts
+++ b/client/src/hooks/SSE/useStepHandler.ts
@@ -53,6 +53,7 @@ type TStepEvent =
| { event: StepEvents.ON_REASONING_DELTA; data: Agents.ReasoningDeltaEvent }
| { event: StepEvents.ON_RUN_STEP_DELTA; data: Agents.RunStepDeltaEvent }
| { event: StepEvents.ON_RUN_STEP_COMPLETED; data: { result: Agents.ToolEndEvent } }
+ | { event: StepEvents.ON_RUN_STEP_CLOSED; data: Agents.RunStepClosedEvent }
| { event: StepEvents.ON_SUMMARIZE_START; data: Agents.SummarizeStartEvent }
| { event: StepEvents.ON_SUMMARIZE_DELTA; data: Agents.SummarizeDeltaEvent }
| { event: StepEvents.ON_SUMMARIZE_COMPLETE; data: Agents.SummarizeCompleteEvent }
@@ -1171,6 +1172,61 @@ export default function useStepHandler({
}),
);
}
+ } else if (stepEvent.event === StepEvents.ON_RUN_STEP_CLOSED) {
+ const closed = stepEvent.data;
+ const runStep = stepMap.current.get(closed.id);
+ let responseMessageId = runStep?.runId ?? '';
+ if (responseMessageId === Constants.USE_PRELIM_RESPONSE_MESSAGE_ID) {
+ responseMessageId = submission?.initialResponse?.messageId ?? '';
+ parentMessageId = submission?.initialResponse?.parentMessageId ?? '';
+ }
+
+ /**
+ * A closure for a step this client never saw opened is not an error
+ * worth surfacing — it happens on reconnect, where the replay may
+ * start after the step was created.
+ */
+ if (!runStep || !responseMessageId) {
+ return;
+ }
+
+ const response = messageMap.current.get(responseMessageId);
+ if (!response) {
+ return;
+ }
+
+ const currentIndex = runStep.index + editPrefixOffset;
+ const existing = response.content?.[currentIndex];
+ /**
+ * Only tool calls render a running state, so only they need the
+ * terminal status. Leaving other part types untouched keeps this from
+ * disturbing text or reasoning content.
+ */
+ if (!existing || existing.type !== ContentTypes.TOOL_CALL) {
+ return;
+ }
+
+ const existingToolCall = existing[ContentTypes.TOOL_CALL];
+ if (!existingToolCall) {
+ return;
+ }
+
+ const updatedContent = [...(response.content ?? [])];
+ updatedContent[currentIndex] = {
+ ...existing,
+ [ContentTypes.TOOL_CALL]: {
+ ...existingToolCall,
+ runStepStatus: closed.status,
+ },
+ };
+
+ const updatedResponse = { ...response, content: updatedContent };
+ messageMap.current.set(responseMessageId, updatedResponse);
+ setMessages(
+ mergeResponseMessage(messages, updatedResponse, responseMessageId, {
+ ensureUserMessage: true,
+ }),
+ );
} else if (stepEvent.event === StepEvents.ON_SANDBOX_STARTING) {
setSandboxStarting(stepEvent.data.tool_call_id);
} else if (stepEvent.event === StepEvents.ON_SUBAGENT_UPDATE) {
diff --git a/packages/api/src/stream/implementations/RedisJobStore.ts b/packages/api/src/stream/implementations/RedisJobStore.ts
index 571a9d81eb..c8a03669c0 100644
--- a/packages/api/src/stream/implementations/RedisJobStore.ts
+++ b/packages/api/src/stream/implementations/RedisJobStore.ts
@@ -1,4 +1,5 @@
import { logger } from '@librechat/data-schemas';
+import { ContentTypes } from 'librechat-data-provider';
import { createContentAggregator } from '@librechat/agents';
import type { StandardGraph } from '@librechat/agents';
import type { Agents } from 'librechat-data-provider';
@@ -3069,6 +3070,14 @@ export class RedisJobStore implements IJobStoreV2 {
// Use the same content aggregator as live streaming
const { contentParts, aggregateContent } = createContentAggregator();
+ // Step ID -> content index, rebuilt from the replayed `on_run_step`
+ // payloads. Those carry the index the offset wrappers shifted, whereas a
+ // closure event was stored unshifted (the wrappers clone only
+ // `ON_RUN_STEP`/`ON_AGENT_UPDATE`), so a closure must be resolved by ID
+ // rather than by its own index — otherwise a run containing a steer or
+ // HITL resume stamps the status onto the wrong slot.
+ const replayedStepIndices = new Map();
+
// Valid event types for content aggregation
const validEvents = new Set([
'on_run_step',
@@ -3108,10 +3117,36 @@ export class RedisJobStore implements IJobStoreV2 {
continue;
}
+ // Step closures are host-authored like steers and labels: the SDK
+ // aggregator has no notion of the event, so the terminal status is
+ // stamped onto the part the replayed steps already rebuilt. Resolved by
+ // step ID against the replayed indices, never by the closure's own
+ // index — see `replayedStepIndices`. Chronology guarantees the step's
+ // `on_run_step` was replayed first.
+ if (event.event === 'on_run_step_closed') {
+ const closed = event.data as {
+ id?: string;
+ status?: Agents.RunStepClosedStatus;
+ };
+ 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;
+ }
+ continue;
+ }
+
if (!validEvents.has(event.event)) {
continue;
}
+ if (event.event === 'on_run_step') {
+ const step = event.data as { id?: string; index?: number };
+ if (step.id != null && typeof step.index === 'number') {
+ replayedStepIndices.set(step.id, step.index);
+ }
+ }
+
// Pass event string directly - GraphEvents values are lowercase strings
// eslint-disable-next-line @typescript-eslint/no-explicit-any
aggregateContent({ event: event.event as any, data: event.data as any });
diff --git a/packages/data-provider/src/types/agents.ts b/packages/data-provider/src/types/agents.ts
index 2fa9c7b43e..ae8499c61f 100644
--- a/packages/data-provider/src/types/agents.ts
+++ b/packages/data-provider/src/types/agents.ts
@@ -204,6 +204,36 @@ export namespace Agents {
stepDetails: StepDetails;
summary?: SummaryContentPart;
usage: null | object;
+ /** Epoch ms the step was opened. Emitted by `@librechat/agents` >= 3.4.6. */
+ created_at?: number;
+ status?: RunStepStatus;
+ };
+
+ /** Lifecycle status of a run step. `in_progress` until a terminal close. */
+ export type RunStepStatus = 'in_progress' | 'completed' | 'cancelled' | 'failed';
+
+ /** Terminal status a run step can close with. */
+ export type RunStepClosedStatus = Exclude;
+
+ /**
+ * Payload of {@link StepEvents.ON_RUN_STEP_CLOSED}. Emitted once per step
+ * when it reaches a terminal state, including steps swept at end-of-run
+ * because the caller aborted — which is the only signal that distinguishes
+ * a stopped step from one still in flight.
+ */
+ export type RunStepClosedEvent = {
+ id: string;
+ index: number;
+ type: StepTypes;
+ status: RunStepClosedStatus;
+ /** Epoch ms the step was opened, when the emitter knows it. */
+ created_at?: number;
+ /** Epoch ms the step reached its terminal state. */
+ closed_at: number;
+ runId?: string;
+ agentId?: string;
+ groupId?: number;
+ stepIndex?: number;
};
/** Content part for aggregated message content */
diff --git a/packages/data-provider/src/types/assistants.ts b/packages/data-provider/src/types/assistants.ts
index 9cf1ed5272..cf875763d0 100644
--- a/packages/data-provider/src/types/assistants.ts
+++ b/packages/data-provider/src/types/assistants.ts
@@ -573,6 +573,14 @@ export type PartMetadata = {
agentId?: string;
/** Group ID for parallel content - parts with same groupId are displayed in columns */
groupId?: number;
+ /**
+ * Terminal lifecycle status of the run step that produced this part, from
+ * `on_run_step_closed`. Distinct from `status`, which is already claimed by
+ * activity-label and question-form parts. Absent on parts predating the
+ * event or from endpoints that do not emit it, in which case renderers fall
+ * back to inferring "stopped" from `progress` and `isSubmitting`.
+ */
+ runStepStatus?: Agents.RunStepClosedStatus;
};
/** Metadata for parallel content rendering - subset of PartMetadata */
diff --git a/packages/data-provider/src/types/runs.ts b/packages/data-provider/src/types/runs.ts
index dd967e8da6..91809063d9 100644
--- a/packages/data-provider/src/types/runs.ts
+++ b/packages/data-provider/src/types/runs.ts
@@ -39,6 +39,8 @@ export enum StepEvents {
ON_REASONING_DELTA = 'on_reasoning_delta',
ON_RUN_STEP_DELTA = 'on_run_step_delta',
ON_RUN_STEP_COMPLETED = 'on_run_step_completed',
+ /** Terminal signal for a run step: closed with a status and timestamps. */
+ ON_RUN_STEP_CLOSED = 'on_run_step_closed',
ON_SUMMARIZE_START = 'on_summarize_start',
ON_SUMMARIZE_DELTA = 'on_summarize_delta',
ON_SUMMARIZE_COMPLETE = 'on_summarize_complete',