fix(webui): keep reasoning scoped to the current user turn

The post-hoc reasoning fix allowed late reasoning frames to attach back to
the nearest assistant message, but the scan crossed a newer user message.
That made the next turn's Thinking bubble render above the previous
assistant reply.

Treat the latest user message as a hard boundary: reasoning after it must
start a new assistant placeholder and can no longer attach to earlier
assistant turns. Add a regression covering previous assistant -> new user
-> reasoning_delta.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Xubin Ren 2026-05-13 07:28:54 +00:00
parent 9829cf66d2
commit 0033a8a185
2 changed files with 41 additions and 0 deletions

View File

@ -30,6 +30,9 @@ interface StreamBuffer {
function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
for (let i = prev.length - 1; i >= 0; i -= 1) {
const candidate = prev[i];
// A user turn is a hard boundary: reasoning after it belongs to the new
// assistant turn, never to an earlier assistant reply.
if (candidate.role === "user") break;
if (candidate.role !== "assistant" || candidate.kind === "trace") continue;
const hasAnswer = candidate.content.length > 0;
if (

View File

@ -238,6 +238,44 @@ describe("useNanobotStream", () => {
expect(result.current.messages[0].reasoningStreaming).toBe(false);
});
it("does not attach a new turn's reasoning across the latest user boundary", () => {
const fake = fakeClient();
const initialMessages = [
{
id: "a-prev",
role: "assistant" as const,
content: "Previous answer.",
reasoning: "Previous thought.",
createdAt: Date.now(),
},
{
id: "u-next",
role: "user" as const,
content: "Next question",
createdAt: Date.now(),
},
];
const { result } = renderHook(
() => useNanobotStream("chat-r6", initialMessages),
{ wrapper: wrap(fake.client) },
);
act(() => {
fake.emit("chat-r6", {
event: "reasoning_delta",
chat_id: "chat-r6",
text: "New turn thinking.",
});
});
expect(result.current.messages).toHaveLength(3);
expect(result.current.messages[0].reasoning).toBe("Previous thought.");
expect(result.current.messages[2].role).toBe("assistant");
expect(result.current.messages[2].content).toBe("");
expect(result.current.messages[2].reasoning).toBe("New turn thinking.");
expect(result.current.messages[2].reasoningStreaming).toBe(true);
});
it("attaches assistant media_urls to complete messages", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-m", EMPTY_MESSAGES), {