From c066819f6943426361a0307ee7fcd64b665f1f5b Mon Sep 17 00:00:00 2001 From: Aari Date: Thu, 30 Jul 2026 13:55:08 +0800 Subject: [PATCH] fix(frontend): keep streaming reasoning above the answer text (#4578) A reasoning model's turn showed its thinking below its answer while streaming, then flipped to thinking-above-answer once the turn settled. One message is rendered by two components with opposite ordering rules: while streaming, an AI message with content and reasoning but no tool calls yet is deliberately held out of the terminal bubble (#4304) and rendered by MessageGroup's chain-of-thought panel, which pinned the trailing reasoning disclosure to the bottom; the settled bubble paints its disclosure above the content. Render the trailing reasoning disclosure before the assistant text that follows it, and emit a message's reasoning step before its content step in convertToSteps -- the step list was content-first, so ordering by step position alone could not fix it. Assistant text emitted before that reasoning keeps its earlier position. This also covers two cases the report does not mention: tool-using turns reversed the same way, and expanding "N more steps" showed a message's answer above its own thinking. --- frontend/AGENTS.md | 1 + .../workspace/messages/message-group.tsx | 60 ++++-- .../e2e/streaming-reasoning-order.spec.ts | 180 ++++++++++++++++++ .../workspace/messages/message-group.test.ts | 111 +++++++++++ 4 files changed, 340 insertions(+), 12 deletions(-) create mode 100644 frontend/tests/e2e/streaming-reasoning-order.spec.ts diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index c0bc87efc..97af44bc9 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -88,6 +88,7 @@ Human input requests are a structured message protocol layered on normal chat hi Tool-calling AI messages can contain user-visible text as well as `tool_calls`. `core/messages/utils.ts` keeps these turns in an `assistant:processing` group, and `components/workspace/messages/message-group.tsx` must render the visible text as a processing step instead of treating the message as only tool metadata. This preserves provider text such as error explanations or "trying another approach" notes during tool-heavy runs. While the current turn is still loading, a content-only AI message after the latest visible human input also stays in that processing group until the turn settles: a provider may append tool-call chunks to the same message later, and classifying it as a final assistant bubble too early makes the text jump into the steps panel. `MessageGroup` therefore renders processing text even before the first tool call arrives. The same rule applies after an earlier tool call: a later content-only AI message remains visible after the current last tool-call step while streaming, because that message may itself gain another tool call before the turn settles. +Because the same message is rendered by two different components over its lifetime, reasoning must sit above the answer text in both. `MessageListItem` paints the settled bubble's `` disclosure above its content, so `MessageGroup` puts the trailing reasoning disclosure above the assistant text that follows it and `convertToSteps` emits a message's reasoning step before its content step — otherwise the two swap places the instant the turn settles (#4576). Assistant text emitted _before_ that reasoning keeps its earlier position; only the answer the reasoning produced moves below it. Edit-and-rerun is deliberately latest-turn-only. `core/messages/utils.ts::getLatestEditableTurn()` exposes a human turn only when the transcript is idle and the most recent visible turn ends in a terminal assistant message. `core/threads/hooks.ts::editAndRegenerateMessage()` calls `POST /api/threads/{id}/runs/edit-regenerate/prepare`, submits the returned replacement message/checkpoint/metadata through the same LangGraph stream path as regenerate, optimistically hides the superseded message ids, and clears the optimistic replacement once the persisted replacement arrives. diff --git a/frontend/src/components/workspace/messages/message-group.tsx b/frontend/src/components/workspace/messages/message-group.tsx index bc55194be..59cb969c7 100644 --- a/frontend/src/components/workspace/messages/message-group.tsx +++ b/frontend/src/components/workspace/messages/message-group.tsx @@ -132,6 +132,25 @@ function MessageGroupComponent({ return filteredSteps[filteredSteps.length - 1]; } }, [lastToolCallStep, steps]); + // Assistant text emitted after the trailing reasoning is the answer that + // reasoning produced, so it renders below the reasoning disclosure. The + // settled assistant bubble always paints reasoning above content, and the + // streaming processing group has to agree or the two swap places the moment + // the turn ends (#4576). Text emitted before that reasoning keeps its + // earlier position. + const belowLastReasoningAssistantTextSteps = useMemo(() => { + if (!lastReasoningStep) { + return []; + } + const index = steps.indexOf(lastReasoningStep); + return steps + .slice(index + 1) + .filter((step) => step.type === "assistantText"); + }, [lastReasoningStep, steps]); + const belowLastReasoningSteps = useMemo( + () => new Set(belowLastReasoningAssistantTextSteps), + [belowLastReasoningAssistantTextSteps], + ); const firstEligibleDebugSummaryStepIndexByMessageId = useMemo(() => { const firstIndices = new Map(); @@ -324,7 +343,10 @@ function MessageGroupComponent({ )} {(lastToolCallStep ?? - steps.some((step) => step.type === "assistantText")) && ( + steps.some( + (step) => + step.type === "assistantText" && !belowLastReasoningSteps.has(step), + )) && ( {(lastToolCallStep ? showAbove @@ -332,7 +354,11 @@ function MessageGroupComponent({ : aboveLastToolCallSteps.filter( (step) => step.type === "assistantText", ) - : steps.filter((step) => step.type === "assistantText") + : steps.filter( + (step) => + step.type === "assistantText" && + !belowLastReasoningSteps.has(step), + ) ).flatMap(renderStep)} {lastToolCallStep && ( <> @@ -343,7 +369,9 @@ function MessageGroupComponent({ {renderToolCall(lastToolCallStep, { isLast: true })} - {afterLastToolCallAssistantTextSteps.flatMap(renderStep)} + {afterLastToolCallAssistantTextSteps + .filter((step) => !belowLastReasoningSteps.has(step)) + .flatMap(renderStep)} )} @@ -404,6 +432,11 @@ function MessageGroupComponent({ > )} + {belowLastReasoningAssistantTextSteps.length > 0 && ( + + {belowLastReasoningAssistantTextSteps.flatMap(renderStep)} + + )} )} @@ -947,15 +980,9 @@ function convertToSteps(messages: Message[]): CoTStep[] { const { browserViews, toolCallResults } = indexToolCallData(messages); for (const [messageIndex, message] of messages.entries()) { if (message.type === "ai") { - const content = extractContentFromMessage(message); - if (content) { - steps.push({ - id: `${message.id ?? `ai-${messageIndex}`}-content`, - messageId: message.id, - type: "assistantText", - content, - }); - } + // Reasoning precedes the answer text it produced, so it is pushed first: + // step order is what the group renders in, and a message carrying both + // would otherwise paint its answer above its own thinking (#4576). const reasoning = extractReasoningContentFromMessage(message); if (reasoning) { const step: CoTReasoningStep = { @@ -966,6 +993,15 @@ function convertToSteps(messages: Message[]): CoTStep[] { }; steps.push(step); } + const content = extractContentFromMessage(message); + if (content) { + steps.push({ + id: `${message.id ?? `ai-${messageIndex}`}-content`, + messageId: message.id, + type: "assistantText", + content, + }); + } for (const tool_call of message.tool_calls ?? []) { if (tool_call.name === "task") { continue; diff --git a/frontend/tests/e2e/streaming-reasoning-order.spec.ts b/frontend/tests/e2e/streaming-reasoning-order.spec.ts new file mode 100644 index 000000000..5902a2ad6 --- /dev/null +++ b/frontend/tests/e2e/streaming-reasoning-order.spec.ts @@ -0,0 +1,180 @@ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; + +import { expect, test, type Locator } from "@playwright/test"; + +import { mockLangGraphAPI } from "./utils/mock-api"; + +const STREAMING_THREAD_ID = "00000000-0000-0000-0000-000000004576"; +const SETTLED_THREAD_ID = "00000000-0000-0000-0000-000000004577"; +const RUN_ID = "00000000-0000-0000-0000-000000004578"; + +const REASONING_TEXT = + "The user asked who I am, so I will list the core capabilities."; +const ANSWER_TEXT = "I am DeerFlow, an open-source super agent."; + +const INITIAL_MESSAGES = [ + { + type: "human", + id: "msg-human-4576", + content: [{ type: "text", text: "Who are you?" }], + }, +]; + +const SETTLED_AI_MESSAGE = { + type: "ai", + id: "msg-ai-4576-settled", + content: ANSWER_TEXT, + additional_kwargs: { reasoning_content: REASONING_TEXT }, +}; + +/** + * One AI chunk carrying both reasoning and answer text, which is the state the + * bubble is in while a reasoning model streams its answer. + */ +function reasoningStreamFrames() { + const events = [ + { + event: "metadata", + data: { run_id: RUN_ID, thread_id: STREAMING_THREAD_ID }, + }, + { + event: "values", + data: { + messages: [ + ...INITIAL_MESSAGES, + { + type: "human", + id: "msg-human-4576-follow-up", + content: [{ type: "text", text: "Summarize that briefly" }], + }, + ], + }, + }, + { + event: "messages", + data: [ + { + content: ANSWER_TEXT, + additional_kwargs: { reasoning_content: REASONING_TEXT }, + response_metadata: {}, + type: "AIMessageChunk", + name: null, + id: "msg-ai-4576-streaming", + tool_calls: [], + invalid_tool_calls: [], + usage_metadata: null, + tool_call_chunks: [], + chunk_position: null, + }, + {}, + ], + }, + ]; + + return events.map( + (event) => `event: ${event.event}\ndata: ${JSON.stringify(event.data)}\n\n`, + ); +} + +/** Holds the SSE connection open so the turn stays in its streaming state. */ +async function startHeldOpenStreamServer() { + const frames = reasoningStreamFrames(); + const server = createServer((_request, response) => { + response.writeHead(200, { + "Access-Control-Allow-Origin": "*", + "Cache-Control": "no-cache", + "Content-Type": "text/event-stream", + }); + response.write(frames.join("")); + }); + + await new Promise((resolve, reject) => { + const handleError = (error: Error) => reject(error); + server.once("error", handleError); + server.listen(0, "127.0.0.1", () => { + server.off("error", handleError); + resolve(); + }); + }); + + const { port } = server.address() as AddressInfo; + return { + url: `http://127.0.0.1:${port}/runs/stream`, + async close() { + server.closeAllConnections(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + }, + }; +} + +async function expectRenderedAbove(upper: Locator, lower: Locator) { + await expect(upper).toBeVisible(); + await expect(lower).toBeVisible(); + const upperBox = await upper.boundingBox(); + const lowerBox = await lower.boundingBox(); + expect(upperBox).not.toBeNull(); + expect(lowerBox).not.toBeNull(); + expect(upperBox!.y).toBeLessThan(lowerBox!.y); +} + +test("renders reasoning above the answer text while the turn is streaming", async ({ + page, +}) => { + const streamServer = await startHeldOpenStreamServer(); + mockLangGraphAPI(page, { + threads: [ + { + thread_id: STREAMING_THREAD_ID, + title: "Streaming reasoning order", + messages: INITIAL_MESSAGES, + }, + ], + }); + await page.route("**/api/langgraph/threads/*/runs/stream", (route) => + route.continue({ url: streamServer.url }), + ); + + try { + await page.goto(`/workspace/chats/${STREAMING_THREAD_ID}`); + + const textarea = page.getByPlaceholder(/how can i assist you/i); + await expect(textarea).toBeVisible({ timeout: 15_000 }); + await textarea.fill("Summarize that briefly"); + await textarea.press("Enter"); + + // The streaming turn renders inside the chain-of-thought panel, whose + // reasoning disclosure is labelled "Thinking". + await expectRenderedAbove( + page.getByText("Thinking", { exact: true }), + page.getByText(ANSWER_TEXT), + ); + } finally { + await streamServer.close(); + } +}); + +test("renders reasoning above the answer text after the turn settles", async ({ + page, +}) => { + mockLangGraphAPI(page, { + threads: [ + { + thread_id: SETTLED_THREAD_ID, + title: "Settled reasoning order", + messages: [...INITIAL_MESSAGES, SETTLED_AI_MESSAGE], + }, + ], + }); + + await page.goto(`/workspace/chats/${SETTLED_THREAD_ID}`); + + // The settled turn renders as an assistant bubble, whose reasoning + // disclosure is labelled "Reasoning". + await expectRenderedAbove( + page.getByText("Reasoning", { exact: true }), + page.getByText(ANSWER_TEXT), + ); +}); diff --git a/frontend/tests/unit/components/workspace/messages/message-group.test.ts b/frontend/tests/unit/components/workspace/messages/message-group.test.ts index 3746438b2..85ff47678 100644 --- a/frontend/tests/unit/components/workspace/messages/message-group.test.ts +++ b/frontend/tests/unit/components/workspace/messages/message-group.test.ts @@ -185,6 +185,108 @@ describe("MessageGroup", () => { expect(timeoutSpy).not.toHaveBeenCalled(); }); + it("renders streaming reasoning above the answer text of the same message", () => { + const html = renderGroup( + [ + { + id: "ai-1", + type: "ai", + content: "Zephyr answer body.", + additional_kwargs: { + reasoning_content: "The user asked who I am, so I will summarize.", + }, + } as Message, + ], + { isLoading: true }, + ); + + expectRenderedInOrder(html, ["Thinking", ">Zephyr"]); + }); + + it("renders streaming inline think reasoning above the answer text", () => { + const html = renderGroup( + [ + { + id: "ai-1", + type: "ai", + content: + "\nThe user only said hello, so I will greet back.\n\n\nZephyr answer body.", + } as Message, + ], + { isLoading: true }, + ); + + expectRenderedInOrder(html, ["Thinking", ">Zephyr"]); + }); + + it("renders trailing reasoning above the answer text that follows a tool call", () => { + const html = renderGroup( + [ + { + id: "ai-1", + type: "ai", + content: "", + tool_calls: [ + { + id: "call-1", + name: "read_file", + args: { path: "message-group.tsx" }, + }, + ], + } as Message, + { + id: "tool-1", + type: "tool", + name: "read_file", + tool_call_id: "call-1", + content: "file contents", + } as Message, + { + id: "ai-2", + type: "ai", + content: "Zephyr answer body.", + additional_kwargs: { + reasoning_content: "The file confirms the renderer order.", + }, + } as Message, + ], + { isLoading: true }, + ); + + expectRenderedInOrder(html, [ + "message-group.tsx", + "Thinking", + ">Zephyr", + ]); + }); + + it("keeps assistant text emitted before the trailing reasoning above it", () => { + const html = renderGroup( + [ + { + id: "ai-1", + type: "ai", + content: "Quartz interim note.", + } as Message, + { + id: "ai-2", + type: "ai", + content: "Zephyr answer body.", + additional_kwargs: { + reasoning_content: "Now I can write the final answer.", + }, + } as Message, + ], + { isLoading: true }, + ); + + expectRenderedInOrder(html, [ + ">Quartz", + "Thinking", + ">Zephyr", + ]); + }); + it("keeps tool-calling assistant text visible when reasoning is also present", () => { const html = renderGroup([ { @@ -413,6 +515,15 @@ describe("MessageGroup", () => { }); }); +/** Asserts every needle is present and that they appear in the given order. */ +function expectRenderedInOrder(html: string, needles: string[]) { + const indices = needles.map((needle) => html.indexOf(needle)); + for (const index of indices) { + expect(index).toBeGreaterThan(-1); + } + expect(indices).toStrictEqual([...indices].sort((a, b) => a - b)); +} + function renderGroup( messages: Message[], props: Omit, "messages"> = {},