diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts index 60736b393..8e83b9eb2 100644 --- a/webui/src/hooks/useNanobotStream.ts +++ b/webui/src/hooks/useNanobotStream.ts @@ -21,17 +21,23 @@ interface StreamBuffer { /** * Append a reasoning chunk to the last open reasoning stream in ``prev``. * - * Lookup rule: find the most recent assistant turn that is either still - * streaming reasoning (``reasoningStreaming``) or has no answer text yet. - * Anything else starts a fresh streaming placeholder so a new turn's - * reasoning never bleeds into the previous answer. + * Lookup rule: prefer the most recent assistant turn in the active UI tail. + * Most providers emit reasoning before answer text, but some only expose + * ``reasoning_content`` after the answer stream completes. In that post-hoc + * case the reasoning still belongs to the same assistant turn and must render + * above the answer, not as a new row below it. */ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] { for (let i = prev.length - 1; i >= 0; i -= 1) { const candidate = prev[i]; if (candidate.role !== "assistant" || candidate.kind === "trace") continue; const hasAnswer = candidate.content.length > 0; - if (candidate.reasoningStreaming || (!hasAnswer && candidate.reasoning !== undefined)) { + if ( + candidate.reasoningStreaming + || candidate.reasoning !== undefined + || hasAnswer + || candidate.isStreaming + ) { const merged: UIMessage = { ...candidate, reasoning: (candidate.reasoning ?? "") + chunk, diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index 145d36c1c..f621437fd 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -209,6 +209,35 @@ describe("useNanobotStream", () => { expect(result.current.messages[0].reasoningStreaming).toBe(false); }); + it("attaches post-hoc reasoning to the same assistant turn above the answer", () => { + const fake = fakeClient(); + const { result } = renderHook(() => useNanobotStream("chat-r5", EMPTY_MESSAGES), { + wrapper: wrap(fake.client), + }); + + act(() => { + fake.emit("chat-r5", { + event: "delta", + chat_id: "chat-r5", + text: "hi~", + }); + fake.emit("chat-r5", { event: "stream_end", chat_id: "chat-r5" }); + fake.emit("chat-r5", { + event: "reasoning_delta", + chat_id: "chat-r5", + text: "This reasoning arrived after the answer stream.", + }); + fake.emit("chat-r5", { event: "reasoning_end", chat_id: "chat-r5" }); + }); + + expect(result.current.messages).toHaveLength(1); + expect(result.current.messages[0].content).toBe("hi~"); + expect(result.current.messages[0].reasoning).toBe( + "This reasoning arrived after the answer stream.", + ); + expect(result.current.messages[0].reasoningStreaming).toBe(false); + }); + it("attaches assistant media_urls to complete messages", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-m", EMPTY_MESSAGES), {