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:
Xinmin Zeng 2026-07-01 23:35:48 +08:00 committed by GitHub
parent 442248dd06
commit b431053ef0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 94 additions and 14 deletions

View File

@ -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] });
}
}

View File

@ -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!),
);

View File

@ -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");