Skip to content

Commit f646632

Browse files
committed
Flush buffered stream tokens on non-stream updates
1 parent 2072eb6 commit f646632

File tree

2 files changed

+70
-0
lines changed

2 files changed

+70
-0
lines changed

src/lib/utils/messageUpdates.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,16 @@ async function* streamMessageUpdatesToFullWords(
147147

148148
for await (const messageUpdate of iterator) {
149149
if (messageUpdate.type !== "stream") {
150+
// When a non-stream update (e.g. tool/status/final answer) arrives,
151+
// flush any buffered stream tokens so the UI does not appear to
152+
// "cut" mid-sentence while tools are running.
153+
if (bufferedStreamUpdates.length > 0) {
154+
yield {
155+
type: MessageUpdateType.Stream,
156+
token: bufferedStreamUpdates.map((u) => u.token).join(""),
157+
};
158+
bufferedStreamUpdates = [];
159+
}
150160
yield messageUpdate;
151161
continue;
152162
}

src/routes/conversation/[id]/+page.svelte

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,19 @@
260260
}
261261
const currentTime = new Date();
262262
263+
// If we receive a non-stream update (e.g. tool/status/final answer),
264+
// flush any buffered stream tokens so the UI doesn't appear to cut
265+
// mid-sentence while tools are running or the final answer arrives.
266+
if (
267+
update.type !== MessageUpdateType.Stream &&
268+
!$settings.disableStream &&
269+
buffer.length > 0
270+
) {
271+
messageToWriteTo.content += buffer;
272+
buffer = "";
273+
lastUpdateTime = currentTime;
274+
}
275+
263276
if (update.type === MessageUpdateType.Stream && !$settings.disableStream) {
264277
buffer += update.token;
265278
// Check if this is the first update or if enough time has passed
@@ -269,6 +282,53 @@
269282
lastUpdateTime = currentTime;
270283
}
271284
pending = false;
285+
} else if (update.type === MessageUpdateType.FinalAnswer) {
286+
// Mirror server-side merge behavior so the UI reflects the
287+
// final text once tools complete, while preserving any
288+
// pre‑tool streamed content when appropriate.
289+
const hadTools =
290+
messageToWriteTo.updates?.some(
291+
(u) => u.type === MessageUpdateType.Tool
292+
) ?? false;
293+
294+
if (hadTools) {
295+
const existing = messageToWriteTo.content;
296+
const finalText = update.text ?? "";
297+
const trimmedExistingSuffix = existing.replace(/\s+$/, "");
298+
const trimmedFinalPrefix = finalText.replace(/^\s+/, "");
299+
const alreadyStreamed =
300+
finalText &&
301+
(existing.endsWith(finalText) ||
302+
(trimmedFinalPrefix.length > 0 &&
303+
trimmedExistingSuffix.endsWith(trimmedFinalPrefix)));
304+
305+
if (existing && existing.length > 0) {
306+
if (alreadyStreamed) {
307+
// A. Already streamed the same final text; keep as-is.
308+
messageToWriteTo.content = existing;
309+
} else if (
310+
finalText &&
311+
(finalText.startsWith(existing) ||
312+
(trimmedExistingSuffix.length > 0 &&
313+
trimmedFinalPrefix.startsWith(trimmedExistingSuffix)))
314+
) {
315+
// B. Final text already includes streamed prefix; use it verbatim.
316+
messageToWriteTo.content = finalText;
317+
} else {
318+
// C. Merge with a paragraph break for readability.
319+
const needsGap =
320+
!/\n\n$/.test(existing) && !/^\n/.test(finalText ?? "");
321+
messageToWriteTo.content =
322+
existing + (needsGap ? "\n\n" : "") + finalText;
323+
}
324+
} else {
325+
messageToWriteTo.content = finalText;
326+
}
327+
} else {
328+
// No tools: final answer replaces streamed content so
329+
// the provider's final text is authoritative.
330+
messageToWriteTo.content = update.text ?? "";
331+
}
272332
} else if (
273333
update.type === MessageUpdateType.Status &&
274334
update.status === MessageUpdateStatus.Error

0 commit comments

Comments
 (0)