Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions client/src/hooks/SSE/__tests__/steps.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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']);
Expand Down
34 changes: 25 additions & 9 deletions client/src/hooks/SSE/__tests__/useStepHandler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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();
});

Expand Down
3 changes: 2 additions & 1 deletion client/src/hooks/SSE/steps/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 8 additions & 1 deletion client/src/hooks/SSE/steps/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 17 additions & 1 deletion client/src/hooks/SSE/steps/tools.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -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.
Expand All @@ -55,6 +68,9 @@ export function applyToolCallsStep(
if ('id' in toolCall && id) {
toolCallId = id;
}
if (isSettledToolCall(next, index, id)) {
continue;
}
next = updateContent(
next,
index,
Expand Down
4 changes: 3 additions & 1 deletion client/src/hooks/SSE/useStepHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Loading