Skip to content

Commit 85164b0

Browse files
committed
feat(ai): refactor encodeStreamPart and encodeVercelDataStream for improved handling of finish events
1 parent 8ea2b20 commit 85164b0

1 file changed

Lines changed: 22 additions & 22 deletions

File tree

packages/services/service-ai/src/stream/vercel-stream-encoder.ts

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,9 @@ function sse(data: object): string {
3333
*/
3434
export function encodeStreamPart(part: TextStreamPart<ToolSet>): string {
3535
switch (part.type) {
36-
// ── Text ──────────────────────────────────────────────────
3736
case 'text-delta':
3837
return sse({ type: 'text-delta', id: '0', delta: part.text });
3938

40-
// ── Tool calling ─────────────────────────────────────────
4139
case 'tool-input-start':
4240
return sse({
4341
type: 'tool-input-start',
@@ -67,24 +65,13 @@ export function encodeStreamPart(part: TextStreamPart<ToolSet>): string {
6765
output: part.output,
6866
});
6967

70-
// ── Finish / Step ────────────────────────────────────────
71-
case 'finish-step':
72-
return sse({ type: 'finish-step' });
73-
74-
case 'finish':
75-
return sse({
76-
type: 'finish',
77-
finishReason: part.finishReason,
78-
});
79-
80-
// ── Error ────────────────────────────────────────────────
8168
case 'error':
8269
return sse({
8370
type: 'error',
8471
errorText: String(part.error),
8572
});
8673

87-
// ── Unhandled types (silently skip) ──────────────────────
74+
// finish-step and finish are handled by the generator, not here
8875
default:
8976
return '';
9077
}
@@ -94,8 +81,8 @@ export function encodeStreamPart(part: TextStreamPart<ToolSet>): string {
9481
* Transform an `AsyncIterable<TextStreamPart>` into an `AsyncIterable<string>`
9582
* where each yielded string is an SSE-formatted UI Message Stream chunk.
9683
*
97-
* Emits the required `start`, `start-step`, `text-start` preamble and
98-
* `text-end`, `finish-step`, `finish`, `[DONE]` postamble automatically.
84+
* Lifecycle order required by the client:
85+
* start → start-step → text-start → text-delta* → text-endfinish-stepfinish[DONE]
9986
*/
10087
export async function* encodeVercelDataStream(
10188
events: AsyncIterable<TextStreamPart<ToolSet>>,
@@ -105,24 +92,37 @@ export async function* encodeVercelDataStream(
10592
yield sse({ type: 'start-step' });
10693
yield sse({ type: 'text-start', id: '0' });
10794

95+
let textOpen = true;
10896
let finishReason = 'stop';
10997

11098
for await (const part of events) {
99+
// Capture finish reason
111100
if (part.type === 'finish') {
112101
finishReason = part.finishReason ?? 'stop';
113102
}
103+
104+
// Before finish-step/finish, close the text part first
105+
if (part.type === 'finish-step' || part.type === 'finish') {
106+
if (textOpen) {
107+
yield sse({ type: 'text-end', id: '0' });
108+
textOpen = false;
109+
}
110+
// Don't emit these via encodeStreamPart — we handle them in postamble
111+
continue;
112+
}
113+
114114
const frame = encodeStreamPart(part);
115115
if (frame) {
116116
yield frame;
117117
}
118118
}
119119

120-
// Postamble — text-end + finish-step + finish are already emitted by
121-
// encodeStreamPart when the corresponding parts arrive from the LLM.
122-
// However, we always need text-end and the [DONE] sentinel.
123-
yield sse({ type: 'text-end', id: '0' });
124-
// finish-step and finish may have already been emitted; emit them
125-
// again only as safeguards — the client handles duplicates gracefully.
120+
// Close text if still open (safety)
121+
if (textOpen) {
122+
yield sse({ type: 'text-end', id: '0' });
123+
}
124+
125+
// Postamble
126126
yield sse({ type: 'finish-step' });
127127
yield sse({ type: 'finish', finishReason });
128128
yield 'data: [DONE]\n\n';

0 commit comments

Comments
 (0)