Skip to content

Commit 8aff3ab

Browse files
authored
🐛 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
1 parent e4ca75a commit 8aff3ab

File tree

2 files changed

+149
-1
lines changed

2 files changed

+149
-1
lines changed

packages/fetch-sse/src/__tests__/fetchSSE.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,135 @@ describe('fetchSSE', () => {
235235
});
236236
});
237237

238+
describe('content_part and reasoning_part', () => {
239+
it('should handle content_part event with text and accumulate output', async () => {
240+
const mockOnMessageHandle = vi.fn();
241+
const mockOnFinish = vi.fn();
242+
243+
(fetchEventSource as any).mockImplementationOnce(
244+
async (url: string, options: FetchEventSourceInit) => {
245+
options.onopen!({ clone: () => ({ ok: true, headers: new Headers() }) } as any);
246+
options.onmessage!({
247+
event: 'content_part',
248+
data: JSON.stringify({ content: 'Hello', partType: 'text' }),
249+
} as any);
250+
options.onmessage!({
251+
event: 'content_part',
252+
data: JSON.stringify({ content: ' World', partType: 'text' }),
253+
} as any);
254+
},
255+
);
256+
257+
await fetchSSE('/', {
258+
onMessageHandle: mockOnMessageHandle,
259+
onFinish: mockOnFinish,
260+
responseAnimation: 'none',
261+
});
262+
263+
expect(mockOnMessageHandle).toHaveBeenNthCalledWith(1, {
264+
content: 'Hello',
265+
mimeType: undefined,
266+
partType: 'text',
267+
thoughtSignature: undefined,
268+
type: 'content_part',
269+
});
270+
expect(mockOnMessageHandle).toHaveBeenNthCalledWith(2, {
271+
content: ' World',
272+
mimeType: undefined,
273+
partType: 'text',
274+
thoughtSignature: undefined,
275+
type: 'content_part',
276+
});
277+
278+
// Verify output is accumulated correctly
279+
expect(mockOnFinish).toHaveBeenCalledWith('Hello World', {
280+
observationId: null,
281+
toolCalls: undefined,
282+
traceId: null,
283+
type: 'done',
284+
});
285+
});
286+
287+
it('should handle reasoning_part event with text and accumulate thinking', async () => {
288+
const mockOnMessageHandle = vi.fn();
289+
const mockOnFinish = vi.fn();
290+
291+
(fetchEventSource as any).mockImplementationOnce(
292+
async (url: string, options: FetchEventSourceInit) => {
293+
options.onopen!({ clone: () => ({ ok: true, headers: new Headers() }) } as any);
294+
options.onmessage!({
295+
event: 'reasoning_part',
296+
data: JSON.stringify({ content: 'Thinking:', partType: 'text' }),
297+
} as any);
298+
options.onmessage!({
299+
event: 'reasoning_part',
300+
data: JSON.stringify({ content: ' step 1', partType: 'text' }),
301+
} as any);
302+
options.onmessage!({
303+
event: 'content_part',
304+
data: JSON.stringify({ content: 'Final answer', partType: 'text' }),
305+
} as any);
306+
},
307+
);
308+
309+
await fetchSSE('/', {
310+
onMessageHandle: mockOnMessageHandle,
311+
onFinish: mockOnFinish,
312+
responseAnimation: 'none',
313+
});
314+
315+
// Verify reasoning is accumulated correctly
316+
expect(mockOnFinish).toHaveBeenCalledWith('Final answer', {
317+
observationId: null,
318+
reasoning: { content: 'Thinking: step 1' },
319+
toolCalls: undefined,
320+
traceId: null,
321+
type: 'done',
322+
});
323+
});
324+
325+
it('should not accumulate output for non-text content_part (e.g., image)', async () => {
326+
const mockOnMessageHandle = vi.fn();
327+
const mockOnFinish = vi.fn();
328+
329+
(fetchEventSource as any).mockImplementationOnce(
330+
async (url: string, options: FetchEventSourceInit) => {
331+
options.onopen!({ clone: () => ({ ok: true, headers: new Headers() }) } as any);
332+
options.onmessage!({
333+
event: 'content_part',
334+
data: JSON.stringify({
335+
content: 'base64imagedata',
336+
partType: 'image',
337+
mimeType: 'image/png',
338+
}),
339+
} as any);
340+
},
341+
);
342+
343+
await fetchSSE('/', {
344+
onMessageHandle: mockOnMessageHandle,
345+
onFinish: mockOnFinish,
346+
responseAnimation: 'none',
347+
});
348+
349+
expect(mockOnMessageHandle).toHaveBeenCalledWith({
350+
content: 'base64imagedata',
351+
mimeType: 'image/png',
352+
partType: 'image',
353+
thoughtSignature: undefined,
354+
type: 'content_part',
355+
});
356+
357+
// Output should be empty since image content is not accumulated
358+
expect(mockOnFinish).toHaveBeenCalledWith('', {
359+
observationId: null,
360+
toolCalls: undefined,
361+
traceId: null,
362+
type: 'done',
363+
});
364+
});
365+
});
366+
238367
it('should handle grounding event', async () => {
239368
const mockOnMessageHandle = vi.fn();
240369
const mockOnFinish = vi.fn();

packages/fetch-sse/src/fetchSSE.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -438,8 +438,27 @@ export const fetchSSE = async (url: string, options: RequestInit & FetchSSEOptio
438438
break;
439439
}
440440

441-
case 'reasoning_part':
441+
case 'reasoning_part': {
442+
// For reasoning_part, accumulate thinking content
443+
if (data.partType === 'text' && data.content) {
444+
thinking += data.content;
445+
}
446+
options.onMessageHandle?.({
447+
content: data.content,
448+
mimeType: data.mimeType,
449+
partType: data.partType,
450+
thoughtSignature: data.thoughtSignature,
451+
type: ev.event,
452+
});
453+
break;
454+
}
455+
442456
case 'content_part': {
457+
// For content_part, accumulate text content to output
458+
// This is critical for Gemini 2.5 models which use content_part instead of text events
459+
if (data.partType === 'text' && data.content) {
460+
output += data.content;
461+
}
443462
options.onMessageHandle?.({
444463
content: data.content,
445464
mimeType: data.mimeType,

0 commit comments

Comments
 (0)