mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🩺 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:
parent
cd4511038d
commit
88747f0ad8
14 changed files with 266 additions and 17 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue