mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 10:56:02 +00:00
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:
parent
063d62c3c3
commit
c066819f69
@ -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.
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
180
frontend/tests/e2e/streaming-reasoning-order.spec.ts
Normal file
180
frontend/tests/e2e/streaming-reasoning-order.spec.ts
Normal 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),
|
||||
);
|
||||
});
|
||||
@ -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"> = {},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user