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