diff --git a/client/src/hooks/SSE/__tests__/steps.spec.ts b/client/src/hooks/SSE/__tests__/steps.spec.ts index 247fd2385c3..00279a944a1 100644 --- a/client/src/hooks/SSE/__tests__/steps.spec.ts +++ b/client/src/hooks/SSE/__tests__/steps.spec.ts @@ -435,6 +435,28 @@ describe('steps', () => { expect(reannounced.content).toEqual(streamed.content); }); + it('keeps a completed call intact when its step is announced again', () => { + const { message } = runToolCall(createResponse()); + const closed = applyRunStepClosed( + message, + search, + { + id: search.id, + index: 1, + type: StepTypes.TOOL_CALLS, + status: 'completed', + created_at: 1_000, + closed_at: 1_250, + }, + 0, + ) as TMessage; + + const replayed = applyToolCallsStep(closed, search, 0); + + expect(replayed.toolCallId).toBe('call-1'); + expect(replayed.message.content).toEqual(closed.content); + }); + it('keeps streamed args when the completion omits them', () => { const opened = applyToolCallsStep(createResponse(), search, 0).message; const streamed = applyToolCallDelta(opened, search, argsDelta(search.id, '{}'), 'call-1', 0); @@ -569,6 +591,23 @@ describe('steps', () => { expect(collided).toBe(result); warn.mockRestore(); }); + + it('writes the URL of the first streamed image part into a new slot', () => { + const result = updateContent(createResponse(), 0, { + type: ContentTypes.IMAGE_URL, + image_url: 'https://x/first.png', + } as Agents.MessageContentComplex); + const repeated = updateContent(result, 0, { + type: ContentTypes.IMAGE_URL, + image_url: 'https://x/other.png', + } as Agents.MessageContentComplex); + + expect(result.content?.[0]).toEqual({ + type: ContentTypes.IMAGE_URL, + image_url: 'https://x/first.png', + }); + expect(repeated.content).toEqual(result.content); + }); }); describe('agent updates', () => { @@ -645,6 +684,45 @@ describe('steps', () => { ).toBeUndefined(); }); + it('carries agent and group metadata onto the finalized summary', () => { + const parallel = messageStep('step-summary-p', 1, { + agentId: 'agent-b', + groupId: 2, + summary: summarize.summary, + }); + const opened = applySummaryStep(createResponse(), parallel, 0); + + const settled = finalizeSummaries( + opened, + { + id: parallel.id, + agentId: 'agent-b', + summary: { + type: ContentTypes.SUMMARY, + content: [{ type: ContentTypes.TEXT, text: 'done' }], + } as SummaryContentPart, + }, + 1, + ) as TMessage; + const reattributed = finalizeSummaries( + applySummaryStep(createResponse(), parallel, 0), + { + id: parallel.id, + agentId: 'agent-b', + summary: { type: ContentTypes.SUMMARY, content: [], agentId: 'agent-c', groupId: 3 }, + } as Agents.SummarizeCompleteEvent, + 1, + ) as TMessage; + + expect(settled.content?.[1]).toMatchObject({ + summarizing: false, + agentId: 'agent-b', + groupId: 2, + content: [{ type: ContentTypes.TEXT, text: 'done' }], + }); + expect(reattributed.content?.[1]).toMatchObject({ agentId: 'agent-c', groupId: 3 }); + }); + it('keeps a failed round in its slot instead of splicing it out', () => { const text = messageStep('step-text', 2); const streamed = streamText(streamSummary(), text, ['after']); diff --git a/client/src/hooks/SSE/__tests__/useStepHandler.spec.ts b/client/src/hooks/SSE/__tests__/useStepHandler.spec.ts index 7e0df6cdaf1..39e8a2ba2b4 100644 --- a/client/src/hooks/SSE/__tests__/useStepHandler.spec.ts +++ b/client/src/hooks/SSE/__tests__/useStepHandler.spec.ts @@ -2070,24 +2070,25 @@ describe('useStepHandler', () => { expect(mockOnSkillAuthoringComplete).not.toHaveBeenCalled(); }); - it('should warn when step not found for completed event', () => { + it('buffers a completion that arrives before its run step and applies it once the step lands', () => { const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + mockGetMessages.mockReturnValue([createResponseMessage()]); const { result } = renderHook(() => useStepHandler(createHookParams())); - + const submission = createSubmission(); const completedEvent = { result: { - id: 'nonexistent-step', + id: 'step-tool-1', index: 0, tool_call: { id: 'tool-call-1', - name: 'test_tool', - args: '{}', + name: 'create_file', + args: JSON.stringify({ file_path: 'skills/demo/SKILL.md' }), + output: 'early output', type: ToolCallTypes.TOOL_CALL, }, }, }; - const submission = createSubmission(); act(() => { result.current.stepHandler( @@ -2099,9 +2100,24 @@ describe('useStepHandler', () => { ); }); - expect(consoleSpy).toHaveBeenCalledWith( - 'No run step or runId found for completed tool call event', - ); + expect(mockOnSkillAuthoringComplete).not.toHaveBeenCalled(); + + act(() => { + result.current.stepHandler( + { event: StepEvents.ON_RUN_STEP, data: createToolCallRunStep() }, + submission, + ); + }); + + const lastCall = mockSetMessages.mock.calls[mockSetMessages.mock.calls.length - 1][0]; + const responseMsg = lastCall.find((m: TMessage) => !m.isCreatedByUser); + expect(responseMsg?.content?.[0]?.tool_call).toMatchObject({ + id: 'tool-call-1', + output: 'early output', + progress: 1, + }); + expect(mockOnSkillAuthoringComplete).toHaveBeenCalledTimes(1); + expect(consoleSpy).not.toHaveBeenCalled(); consoleSpy.mockRestore(); }); diff --git a/client/src/hooks/SSE/steps/content.ts b/client/src/hooks/SSE/steps/content.ts index 25415d5ad39..fc2b7cd311c 100644 --- a/client/src/hooks/SSE/steps/content.ts +++ b/client/src/hooks/SSE/steps/content.ts @@ -284,10 +284,11 @@ export function updateContent( } else if (contentType === ContentTypes.IMAGE_URL && 'image_url' in contentPart) { const currentContent = updatedContent[index] as { type: ContentTypes.IMAGE_URL; - image_url: string; + image_url?: string; }; updatedContent[index] = { ...currentContent, + image_url: currentContent.image_url ?? contentPart.image_url, }; } else if (contentType === ContentTypes.SUMMARY) { const currentSummary = updatedContent[index] as SummaryContentPart | undefined; diff --git a/client/src/hooks/SSE/steps/text.ts b/client/src/hooks/SSE/steps/text.ts index 24bf5aedc53..32e7cae06b3 100644 --- a/client/src/hooks/SSE/steps/text.ts +++ b/client/src/hooks/SSE/steps/text.ts @@ -196,7 +196,14 @@ export function finalizeSummaries( } didFinalize = true; if (!event.error && event.summary) { - return { ...event.summary, summarizing: false } as SummaryContentPart; + /** The completed summary may omit the step metadata the in-flight part was opened with. */ + const { agentId, groupId } = part as ContentMetadata; + return { + ...(agentId != null && { agentId }), + ...(groupId != null && { groupId }), + ...event.summary, + summarizing: false, + } as SummaryContentPart; } if (event.error) { return { ...part, summarizing: false, failed: true } as SummaryContentPart; diff --git a/client/src/hooks/SSE/steps/tools.ts b/client/src/hooks/SSE/steps/tools.ts index 815285da668..09462d23bd8 100644 --- a/client/src/hooks/SSE/steps/tools.ts +++ b/client/src/hooks/SSE/steps/tools.ts @@ -1,5 +1,5 @@ import { StepTypes, ContentTypes, getRunStepDurationMs } from 'librechat-data-provider'; -import type { Agents, TMessage } from 'librechat-data-provider'; +import type { Agents, TMessage, PartMetadata } from 'librechat-data-provider'; import { getStepMetadata, updateContent } from './content'; /** Mirrors `SKILL_FILE_PREFIX` in `@librechat/api` file-authoring handlers. */ @@ -30,10 +30,23 @@ export function isSkillAuthoringToolCall(toolCall?: Agents.ToolCall): boolean { return typeof filePath === 'string' && filePath.startsWith(SKILL_FILE_PREFIX); } +/** True when the slot already holds this call settled: an output or full progress. */ +const isSettledToolCall = (message: TMessage, index: number, id: string): boolean => { + const part = message.content?.[index]; + if (!id || part?.type !== ContentTypes.TOOL_CALL) { + return false; + } + const toolCall = part[ContentTypes.TOOL_CALL] as (Agents.ToolCall & PartMetadata) | undefined; + return toolCall?.id === id && (toolCall.output != null || toolCall.progress === 1); +}; + /** * Opens the tool-call parts a `tool_calls` run step announces, all at the step's slot. * AI SDK: `tool-input-start`. * + * A step announced again after its call settled, as the resume path does for every known run + * step, leaves the settled part alone: reopening it would drop its output and completion fields. + * * Returns the next message and the tool-call id to record for the step, so later argument deltas, * which carry only the step id, can be attributed. The last non-empty id wins, matching a step * that announces a single call. @@ -55,6 +68,9 @@ export function applyToolCallsStep( if ('id' in toolCall && id) { toolCallId = id; } + if (isSettledToolCall(next, index, id)) { + continue; + } next = updateContent( next, index, diff --git a/client/src/hooks/SSE/useStepHandler.ts b/client/src/hooks/SSE/useStepHandler.ts index c57dc8ca013..800cb149636 100644 --- a/client/src/hooks/SSE/useStepHandler.ts +++ b/client/src/hooks/SSE/useStepHandler.ts @@ -757,7 +757,9 @@ export default function useStepHandler({ } if (!runStep || !responseMessageId) { - console.warn('No run step or runId found for completed tool call event'); + const buffer = pendingDeltaBuffer.current.get(stepId) ?? []; + buffer.push({ event: StepEvents.ON_RUN_STEP_COMPLETED, data: stepEvent.data }); + pendingDeltaBuffer.current.set(stepId, buffer); return; }