From 730aeab6c5baddaf9b448a23a015836dcc68b7d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 23 Aug 2026 00:54:32 +0000 Subject: [PATCH] the finished message wins over the buffer that was streaming it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reply rendered as "ipped." — first two characters gone — while the durable log held it complete. Nothing was lost; the client discarded it. The streaming buffer is an accumulation of deltas and is only as good as the socket. The `assistant:text` event that ends the stream carries the whole message and arrives right after. Committing the buffer and ignoring that text means a single dropped frame is permanent, even though a correct copy came in immediately behind it. Losing a frame is not exotic: restarting the server mid-turn does it, which is how this was found — three times tonight, by me. Co-Authored-By: Claude Opus 5 --- src/workspaces/officerdev/src/hooks/useChat.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/workspaces/officerdev/src/hooks/useChat.ts b/src/workspaces/officerdev/src/hooks/useChat.ts index 1b2c821b..7e642249 100644 --- a/src/workspaces/officerdev/src/hooks/useChat.ts +++ b/src/workspaces/officerdev/src/hooks/useChat.ts @@ -301,9 +301,19 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, }); } - function commitStreaming() { - if (!streamingRef.current) return; - const text = streamingRef.current; + /** + * Turn the in-flight streaming buffer into a committed message. + * + * `authoritative` is the complete text from the `assistant:text` event that ends the stream, and it + * wins whenever it is present. The buffer is an accumulation of deltas and is only ever as good as the + * socket: lose one frame — a restart mid-turn will do it — and it is short by whatever that frame + * carried, while the event carries the whole message. Committing the buffer threw away a correct copy + * in favour of a damaged one, which is how a reply rendered as "ipped." with its first two characters + * missing while the durable log held it intact. + */ + function commitStreaming(authoritative?: string) { + if (!streamingRef.current && !authoritative) return; + const text = authoritative ?? streamingRef.current; streamingRef.current = ''; setStreamingText(''); setMessages((prev) => [...prev, { role: 'assistant', id: randomId(), text }]); @@ -427,7 +437,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, break; } if (streamingRef.current) { - commitStreaming(); + commitStreaming(msg.text); } else { setMessages((prev) => [...prev, { role: 'assistant', id: randomId(), text: msg.text }]); }