🐛 fix: add handling for content_part and reasoning_part events in fetchSSE (#10470)

feat: add handling for content_part and reasoning_part events in fetchSSE
This commit is contained in:
sxjeru
2025-11-28 16:50:44 +08:00
committed by GitHub
parent e4ca75acf9
commit 8aff3ab70c
2 changed files with 149 additions and 1 deletions
@@ -235,6 +235,135 @@ describe('fetchSSE', () => {
});
});
describe('content_part and reasoning_part', () => {
it('should handle content_part event with text and accumulate output', async () => {
const mockOnMessageHandle = vi.fn();
const mockOnFinish = vi.fn();
(fetchEventSource as any).mockImplementationOnce(
async (url: string, options: FetchEventSourceInit) => {
options.onopen!({ clone: () => ({ ok: true, headers: new Headers() }) } as any);
options.onmessage!({
event: 'content_part',
data: JSON.stringify({ content: 'Hello', partType: 'text' }),
} as any);
options.onmessage!({
event: 'content_part',
data: JSON.stringify({ content: ' World', partType: 'text' }),
} as any);
},
);
await fetchSSE('/', {
onMessageHandle: mockOnMessageHandle,
onFinish: mockOnFinish,
responseAnimation: 'none',
});
expect(mockOnMessageHandle).toHaveBeenNthCalledWith(1, {
content: 'Hello',
mimeType: undefined,
partType: 'text',
thoughtSignature: undefined,
type: 'content_part',
});
expect(mockOnMessageHandle).toHaveBeenNthCalledWith(2, {
content: ' World',
mimeType: undefined,
partType: 'text',
thoughtSignature: undefined,
type: 'content_part',
});
// Verify output is accumulated correctly
expect(mockOnFinish).toHaveBeenCalledWith('Hello World', {
observationId: null,
toolCalls: undefined,
traceId: null,
type: 'done',
});
});
it('should handle reasoning_part event with text and accumulate thinking', async () => {
const mockOnMessageHandle = vi.fn();
const mockOnFinish = vi.fn();
(fetchEventSource as any).mockImplementationOnce(
async (url: string, options: FetchEventSourceInit) => {
options.onopen!({ clone: () => ({ ok: true, headers: new Headers() }) } as any);
options.onmessage!({
event: 'reasoning_part',
data: JSON.stringify({ content: 'Thinking:', partType: 'text' }),
} as any);
options.onmessage!({
event: 'reasoning_part',
data: JSON.stringify({ content: ' step 1', partType: 'text' }),
} as any);
options.onmessage!({
event: 'content_part',
data: JSON.stringify({ content: 'Final answer', partType: 'text' }),
} as any);
},
);
await fetchSSE('/', {
onMessageHandle: mockOnMessageHandle,
onFinish: mockOnFinish,
responseAnimation: 'none',
});
// Verify reasoning is accumulated correctly
expect(mockOnFinish).toHaveBeenCalledWith('Final answer', {
observationId: null,
reasoning: { content: 'Thinking: step 1' },
toolCalls: undefined,
traceId: null,
type: 'done',
});
});
it('should not accumulate output for non-text content_part (e.g., image)', async () => {
const mockOnMessageHandle = vi.fn();
const mockOnFinish = vi.fn();
(fetchEventSource as any).mockImplementationOnce(
async (url: string, options: FetchEventSourceInit) => {
options.onopen!({ clone: () => ({ ok: true, headers: new Headers() }) } as any);
options.onmessage!({
event: 'content_part',
data: JSON.stringify({
content: 'base64imagedata',
partType: 'image',
mimeType: 'image/png',
}),
} as any);
},
);
await fetchSSE('/', {
onMessageHandle: mockOnMessageHandle,
onFinish: mockOnFinish,
responseAnimation: 'none',
});
expect(mockOnMessageHandle).toHaveBeenCalledWith({
content: 'base64imagedata',
mimeType: 'image/png',
partType: 'image',
thoughtSignature: undefined,
type: 'content_part',
});
// Output should be empty since image content is not accumulated
expect(mockOnFinish).toHaveBeenCalledWith('', {
observationId: null,
toolCalls: undefined,
traceId: null,
type: 'done',
});
});
});
it('should handle grounding event', async () => {
const mockOnMessageHandle = vi.fn();
const mockOnFinish = vi.fn();
+20 -1
View File
@@ -438,8 +438,27 @@ export const fetchSSE = async (url: string, options: RequestInit & FetchSSEOptio
break;
}
case 'reasoning_part':
case 'reasoning_part': {
// For reasoning_part, accumulate thinking content
if (data.partType === 'text' && data.content) {
thinking += data.content;
}
options.onMessageHandle?.({
content: data.content,
mimeType: data.mimeType,
partType: data.partType,
thoughtSignature: data.thoughtSignature,
type: ev.event,
});
break;
}
case 'content_part': {
// For content_part, accumulate text content to output
// This is critical for Gemini 2.5 models which use content_part instead of text events
if (data.partType === 'text' && data.content) {
output += data.content;
}
options.onMessageHandle?.({
content: data.content,
mimeType: data.mimeType,