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 }]); }