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 <Reasoning> 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.
This commit is contained in:
Aari 2026-07-30 13:55:08 +08:00 committed by GitHub
parent 063d62c3c3
commit c066819f69
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 340 additions and 12 deletions

View File

@ -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 `<Reasoning>` 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.

View File

@ -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<CoTStep>(belowLastReasoningAssistantTextSteps),
[belowLastReasoningAssistantTextSteps],
);
const firstEligibleDebugSummaryStepIndexByMessageId = useMemo(() => {
const firstIndices = new Map<string, number>();
@ -324,7 +343,10 @@ function MessageGroupComponent({
</Button>
)}
{(lastToolCallStep ??
steps.some((step) => step.type === "assistantText")) && (
steps.some(
(step) =>
step.type === "assistantText" && !belowLastReasoningSteps.has(step),
)) && (
<ChainOfThoughtContent className="px-4 pb-2">
{(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({
<FlipDisplay uniqueKey={lastToolCallStep.id ?? ""}>
{renderToolCall(lastToolCallStep, { isLast: true })}
</FlipDisplay>
{afterLastToolCallAssistantTextSteps.flatMap(renderStep)}
{afterLastToolCallAssistantTextSteps
.filter((step) => !belowLastReasoningSteps.has(step))
.flatMap(renderStep)}
</>
)}
</ChainOfThoughtContent>
@ -404,6 +432,11 @@ function MessageGroupComponent({
></ChainOfThoughtStep>
</ChainOfThoughtContent>
)}
{belowLastReasoningAssistantTextSteps.length > 0 && (
<ChainOfThoughtContent className="px-4 pb-2">
{belowLastReasoningAssistantTextSteps.flatMap(renderStep)}
</ChainOfThoughtContent>
)}
</>
)}
</ChainOfThought>
@ -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;

View File

@ -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<void>((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<void>((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),
);
});

View File

@ -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</span>"]);
});
it("renders streaming inline think reasoning above the answer text", () => {
const html = renderGroup(
[
{
id: "ai-1",
type: "ai",
content:
"<think>\nThe user only said hello, so I will greet back.\n</think>\n\nZephyr answer body.",
} as Message,
],
{ isLoading: true },
);
expectRenderedInOrder(html, ["Thinking", ">Zephyr</span>"]);
});
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</span>",
]);
});
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</span>",
"Thinking",
">Zephyr</span>",
]);
});
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<ComponentProps<typeof MessageGroup>, "messages"> = {},