🩺 fix: Render Stopped Run Steps From Explicit Status (#14871)

* 🩺 fix: Render Stopped Run Steps From Explicit Status

Tool calls decided "still running" vs "stopped" with a whole-message
heuristic:

  const cancelled = !isSubmitting && progress < 1 && !hasError;

That inference cannot tell which step actually stopped. An aborted step
keeps spinning while `isSubmitting` is still true, and when submitting
ends, every unfinished part flips to "Cancelled" at once regardless of
which one died.

`@librechat/agents` v3.4.6+ emits `on_run_step_closed`, a terminal
per-step signal carrying `status` and timestamps — including for steps
swept at end-of-run because the caller aborted. The pinned 3.5.1 already
ships it; nothing consumed it.

- `StepEvents.ON_RUN_STEP_CLOSED` plus `RunStepClosedEvent` /
  `RunStepStatus` types mirroring the SDK payload.
- `PartMetadata.runStepStatus` — a dedicated field, since `status` is
  already claimed by activity-label and question-form parts.
- Server handler forwards the event without the visibility gating the
  other step handlers apply: a step whose open reached the client must
  get its close, or the client is left inferring again.
- `useStepHandler` writes the terminal status onto the tool call part.
- Both decision points (`ToolCall`, the shared `useToolCallState`) prefer
  explicit status, keeping the heuristic as fallback for messages saved
  before this and endpoints that do not emit the event.

Threaded through the five cards sharing `useToolCallState`.

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

* 🩹 fix: Address Codex Review On Run Step Closure Rendering

- Persist the terminal status server-side. The handler emitted the
  closure without folding it into `contentParts`, so the status existed
  only on the live React message: a reload or resumable reconnect
  dropped it and fell back to the very heuristic this fixes. Now stamped
  onto the aggregated tool-call part (via `stepMap`, falling back to the
  event's own index) before forwarding.

- Honor terminal status independently of output parsing. Gating on
  `hasError` meant a `failed` step with unparseable output rendered as
  "cancelled", while a `failed`/`cancelled` step whose output did parse
  as an error was not terminal at all and shimmered indefinitely when no
  completion event arrived. A closed step now forces progress complete
  and reports `failed` as an error state on its own authority.

- Pass the status to the second `BashCall` branch, which rendered the
  same updated component without it.

- Reuse `Agents.RunStepClosedStatus` in `PartMetadata` instead of
  redeclaring the union, so a future SDK status cannot diverge between
  the event and the persisted part.

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

*  fix: Replay Closed Status On Redis Resume, Announce Failures

- Apply closure events during Redis reconstruction. The stamp added in
  the previous commit mutates only the originating process's in-memory
  `contentParts`; a resumable reconnect landing on another replica
  rebuilds from `RedisJobStore.getContentParts`, whose allowlist omits
  `on_run_step_closed`. The status was therefore absent from the sync
  snapshot and, being snapshot-covered, never redelivered as pending —
  so multi-replica resume fell back to the whole-message heuristic.
  Handled as a host-authored event alongside `on_steer_applied` and
  `on_activity_label`, since the SDK aggregator has no notion of it.

- Announce terminal failures in the live region. Forcing terminal
  progress for a closed step meant a `failed` tool reached the
  `aria-live` region through `getFinishedText()`, which only special-
  cased cancellation and otherwise announced "completed function" —
  telling screen-reader users the opposite of what the card showed. A
  regression introduced by the previous commit; error states now
  announce failure before any completion string.

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

* 🎯 fix: Resolve Closed Steps By ID, Never By Index

The steer and HITL offset wrappers clone and shift only `ON_RUN_STEP`
and `ON_AGENT_UPDATE`; every other event passes through untouched. A
stored `on_run_step_closed` therefore carries the SDK's unshifted index,
while the part it belongs to was rebuilt at the shifted one. Any run
containing a steer insertion or HITL resume would stamp the status onto
an earlier tool card, or none — leaving the real card on the fallback
heuristic while mislabeling a different one.

- Redis reconstruction builds a step ID -> index map from the replayed
  `on_run_step` payloads (which carry the shifted index) and resolves
  closures against it, mirroring what the live callback does via
  `stepMap`.

- The live handler drops its `?? data.index` fallback for the same
  reason. Skipping is the safe failure: a missing status degrades to the
  old heuristic, whereas a misplaced one actively mislabels the wrong
  card.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Danny Avila 2026-08-15 10:14:16 -04:00 committed by GitHub
parent cd4511038d
commit 88747f0ad8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 266 additions and 17 deletions

View file

@ -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.

View file

@ -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({
<ExecuteCode
attachments={attachments}
isSubmitting={isSubmitting}
runStepStatus={toolCall.runStepStatus}
output={toolCall.output ?? ''}
initialProgress={toolCall.progress ?? 0.1}
args={toolCall.args}
@ -251,6 +253,7 @@ const Part = memo(function Part({
output={toolCall.output ?? ''}
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
runStepStatus={toolCall.runStepStatus}
attachments={attachments}
hideAttachments={hideAttachments}
onExpand={onToolExpand}
@ -287,6 +290,7 @@ const Part = memo(function Part({
output={toolCall.output ?? ''}
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
runStepStatus={toolCall.runStepStatus}
attachments={attachments}
hideAttachments={hideAttachments}
onExpand={onToolExpand}
@ -300,6 +304,7 @@ const Part = memo(function Part({
output={toolCall.output ?? ''}
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
runStepStatus={toolCall.runStepStatus}
attachments={attachments}
hideAttachments={hideAttachments}
onExpand={onToolExpand}
@ -312,6 +317,7 @@ const Part = memo(function Part({
output={toolCall.output ?? ''}
initialProgress={toolCall.progress ?? 0.1}
isSubmitting={isSubmitting}
runStepStatus={toolCall.runStepStatus}
attachments={attachments}
hideAttachments={hideAttachments}
onExpand={onToolExpand}
@ -357,6 +363,7 @@ const Part = memo(function Part({
isLast={isLast}
hideAttachments={hideAttachments}
onExpand={onToolExpand}
runStepStatus={toolCall.runStepStatus}
/>
);
})();

View file

@ -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<string, unknown>;
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]);

View file

@ -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<string, unknown>;
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]);

View file

@ -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<string, unknown>;
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;

View file

@ -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<string, unknown>;
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);

View file

@ -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<string, unknown>;
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 (
<>

View file

@ -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,

View file

@ -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;
}

View file

@ -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) {

View file

@ -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<string, number>();
// 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 });

View file

@ -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<RunStepStatus, 'in_progress'>;
/**
* 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 */

View file

@ -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 */

View file

@ -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',