deer-flow/frontend/tests/e2e/streaming-reasoning-order.spec.ts
Aari c066819f69
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.
2026-07-30 13:55:08 +08:00

181 lines
5.0 KiB
TypeScript

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