|
260 | 260 | } |
261 | 261 | const currentTime = new Date(); |
262 | 262 |
|
| 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 | +
|
263 | 276 | if (update.type === MessageUpdateType.Stream && !$settings.disableStream) { |
264 | 277 | buffer += update.token; |
265 | 278 | // Check if this is the first update or if enough time has passed |
|
269 | 282 | lastUpdateTime = currentTime; |
270 | 283 | } |
271 | 284 | 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 | + } |
272 | 332 | } else if ( |
273 | 333 | update.type === MessageUpdateType.Status && |
274 | 334 | update.status === MessageUpdateStatus.Error |
|
0 commit comments