mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-19 16:12:30 +00:00
fix(webui): keep late reasoning attached above the answer
Some providers only surface structured `reasoning_content` after answer text has already streamed. The WebUI was treating those late `reasoning_delta` frames as a fresh assistant placeholder, so the Thinking bubble rendered below the already-visible answer. Attach late reasoning back to the active assistant turn instead. The bubble still renders above the message content, preserving the expected Thinking -> answer order even when the provider protocol delivers the reasoning post-hoc. Added a regression test for answer-first followed by reasoning_delta/reasoning_end. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
458b4ba235
commit
9829cf66d2
@ -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,
|
||||
|
||||
@ -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), {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user