diff --git a/frontend/src/core/messages/utils.ts b/frontend/src/core/messages/utils.ts index f2ea7f3eb..1024a0f50 100644 --- a/frontend/src/core/messages/utils.ts +++ b/frontend/src/core/messages/utils.ts @@ -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 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] }); } } diff --git a/frontend/tests/unit/core/messages/usage.test.ts b/frontend/tests/unit/core/messages/usage.test.ts index f400a7b98..90be6c6a8 100644 --- a/frontend/tests/unit/core/messages/usage.test.ts +++ b/frontend/tests/unit/core/messages/usage.test.ts @@ -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 ) 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!), ); diff --git a/frontend/tests/unit/core/messages/utils.test.ts b/frontend/tests/unit/core/messages/utils.test.ts index dde79e7ca..3c9ef6e0b 100644 --- a/frontend/tests/unit/core/messages/utils.test.ts +++ b/frontend/tests/unit/core/messages/utils.test.ts @@ -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 + // 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 tag splitting", () => { test("strips a fully closed block from AI content", () => { const message = aiMessage("internal reasoningfinal answer");