mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-22 22:18:15 +00:00
fix(frontend): stop rendering reasoning twice for reasoning+answer messages (#3870)
A reasoning+content AI message with no tool calls was grouped into both an `assistant:processing` group (the ChainOfThought panel above the bubble) and an `assistant` group (the bubble's own Reasoning collapsible), so its reasoning rendered twice. Such a message now becomes only the assistant bubble; the ChainOfThought panel keeps handling intermediate reasoning and tool steps. Closes #3868
This commit is contained in:
parent
442248dd06
commit
b431053ef0
@ -106,6 +106,16 @@ export function getMessageGroups(messages: Message[]): MessageGroup[] {
|
||||
}
|
||||
|
||||
if (message.type === "ai") {
|
||||
// A message with answer content and no tool calls becomes its own
|
||||
// assistant bubble below, which already renders the message's
|
||||
// reasoning_content inside the bubble's <Reasoning> collapsible. Such a
|
||||
// message must NOT also feed the processing group, or the ChainOfThought
|
||||
// panel above the bubble paints the identical reasoning a second time
|
||||
// (#3868). Intermediate reasoning (no content) and tool-calling steps
|
||||
// still belong in the processing group.
|
||||
const becomesAssistantBubble =
|
||||
hasContent(message) && !hasToolCalls(message);
|
||||
|
||||
if (hasPresentFiles(message)) {
|
||||
groups.push({
|
||||
id: message.id,
|
||||
@ -118,7 +128,10 @@ export function getMessageGroups(messages: Message[]): MessageGroup[] {
|
||||
type: "assistant:subagent",
|
||||
messages: [message],
|
||||
});
|
||||
} else if (hasReasoning(message) || hasToolCalls(message)) {
|
||||
} else if (
|
||||
!becomesAssistantBubble &&
|
||||
(hasReasoning(message) || hasToolCalls(message))
|
||||
) {
|
||||
const lastGroup = groups[groups.length - 1];
|
||||
// Accumulate consecutive intermediate AI messages into one processing group.
|
||||
if (lastGroup?.type !== "assistant:processing") {
|
||||
@ -132,9 +145,7 @@ export function getMessageGroups(messages: Message[]): MessageGroup[] {
|
||||
}
|
||||
}
|
||||
|
||||
// Not an else-if: a message with reasoning + content (but no tool calls) goes
|
||||
// into the processing group above AND gets its own assistant bubble here.
|
||||
if (hasContent(message) && !hasToolCalls(message)) {
|
||||
if (becomesAssistantBubble) {
|
||||
groups.push({ id: message.id, type: "assistant", messages: [message] });
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,7 +42,13 @@ test("counts later usage-bearing snapshots for the same AI message id", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps header and per-turn aggregation consistent for duplicated UI groups", () => {
|
||||
test("keeps header and per-turn aggregation consistent for a reasoning+answer message", () => {
|
||||
// A single AI message carrying both reasoning (here via inline <think>) and
|
||||
// answer text now lands in exactly one assistant group (#3868), so its usage
|
||||
// is counted once both in the per-turn aggregation and against the header
|
||||
// total. The by-id dedupe (see "accumulates each AI message usage only once")
|
||||
// remains the defence-in-depth guard if any future grouping reintroduces a
|
||||
// duplicate.
|
||||
const messages = [
|
||||
{
|
||||
id: "human-1",
|
||||
@ -61,15 +67,8 @@ test("keeps header and per-turn aggregation consistent for duplicated UI groups"
|
||||
const usageMessagesByGroupIndex = getAssistantTurnUsageMessages(groups);
|
||||
const turnUsageMessages = usageMessagesByGroupIndex.at(-1);
|
||||
|
||||
expect(groups.map((group) => group.type)).toEqual([
|
||||
"human",
|
||||
"assistant:processing",
|
||||
"assistant",
|
||||
]);
|
||||
expect(turnUsageMessages?.map((message) => message.id)).toEqual([
|
||||
"ai-1",
|
||||
"ai-1",
|
||||
]);
|
||||
expect(groups.map((group) => group.type)).toEqual(["human", "assistant"]);
|
||||
expect(turnUsageMessages?.map((message) => message.id)).toEqual(["ai-1"]);
|
||||
expect(accumulateUsage(messages)).toEqual(
|
||||
accumulateUsage(turnUsageMessages!),
|
||||
);
|
||||
|
||||
@ -81,6 +81,76 @@ test("aggregates token usage messages once per assistant turn", () => {
|
||||
).toEqual([null, null, ["ai-1", "ai-2"], null, ["ai-3"]]);
|
||||
});
|
||||
|
||||
test("reasoning + content (no tool calls) yields a single assistant bubble, not a duplicate processing group", () => {
|
||||
// Regression for #3868: in thinking/pro/ultra modes the final assistant
|
||||
// message carries both reasoning_content and answer text. It must surface its
|
||||
// reasoning exactly once — inside the assistant bubble's <Reasoning>
|
||||
// collapsible. Routing the same message into a processing group as well makes
|
||||
// the ChainOfThought panel above the bubble paint the identical reasoning a
|
||||
// second time.
|
||||
const messages = [
|
||||
{ id: "human-1", type: "human", content: "Why is the sky blue?" },
|
||||
{
|
||||
id: "ai-1",
|
||||
type: "ai",
|
||||
content: "Rayleigh scattering makes the sky blue.",
|
||||
additional_kwargs: { reasoning_content: "Recall Rayleigh scattering." },
|
||||
},
|
||||
] as Message[];
|
||||
|
||||
const groups = getMessageGroups(messages);
|
||||
|
||||
expect(groups.map((group) => group.type)).toEqual(["human", "assistant"]);
|
||||
|
||||
// The reasoning-bearing message lands in exactly one group, so turn-usage
|
||||
// aggregation never double-counts it (see #2770).
|
||||
const turnUsage = getAssistantTurnUsageMessages(groups);
|
||||
expect(turnUsage.at(-1)?.map((message) => message.id)).toEqual(["ai-1"]);
|
||||
});
|
||||
|
||||
test("keeps tool-call reasoning in the processing group while the final answer's reasoning rides its own bubble", () => {
|
||||
// Companion to #3868: only the message that also becomes an assistant bubble
|
||||
// (content, no tool calls) is pulled out of the processing group. Reasoning
|
||||
// attached to an intermediate tool-calling step still belongs above, with its
|
||||
// tool steps.
|
||||
const messages = [
|
||||
{ id: "human-1", type: "human", content: "Search and summarize" },
|
||||
{
|
||||
id: "ai-1",
|
||||
type: "ai",
|
||||
content: "",
|
||||
additional_kwargs: { reasoning_content: "I should search first." },
|
||||
tool_calls: [{ id: "tool-1", name: "web_search", args: { query: "x" } }],
|
||||
},
|
||||
{
|
||||
id: "tool-1-result",
|
||||
type: "tool",
|
||||
name: "web_search",
|
||||
tool_call_id: "tool-1",
|
||||
content: "[]",
|
||||
},
|
||||
{
|
||||
id: "ai-2",
|
||||
type: "ai",
|
||||
content: "Here is the summary.",
|
||||
additional_kwargs: { reasoning_content: "Synthesize the findings." },
|
||||
},
|
||||
] as Message[];
|
||||
|
||||
const groups = getMessageGroups(messages);
|
||||
|
||||
expect(groups.map((group) => group.type)).toEqual([
|
||||
"human",
|
||||
"assistant:processing",
|
||||
"assistant",
|
||||
]);
|
||||
expect(groups[1]?.messages.map((message) => message.id)).toEqual([
|
||||
"ai-1",
|
||||
"tool-1-result",
|
||||
]);
|
||||
expect(groups[2]?.messages.map((message) => message.id)).toEqual(["ai-2"]);
|
||||
});
|
||||
|
||||
describe("inline <think> tag splitting", () => {
|
||||
test("strips a fully closed <think> block from AI content", () => {
|
||||
const message = aiMessage("<think>internal reasoning</think>final answer");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user