diff --git a/frontend/src/core/messages/utils.ts b/frontend/src/core/messages/utils.ts
index f1bbe4d075..adbcbd4510 100644
--- a/frontend/src/core/messages/utils.ts
+++ b/frontend/src/core/messages/utils.ts
@@ -90,19 +90,43 @@ export function getMessageGroups(messages: Message[]): MessageGroup[] {
}
if (message.type === "ai") {
+ const messageHasContent = hasContent(message);
+ const messageHasToolCalls = hasToolCalls(message);
+ const messageHasReasoning = hasReasoning(message);
+ const needsProcessing = messageHasReasoning || messageHasToolCalls;
+
+ // The present-files renderer shows visible text and the file-list panel
+ // from this single group, so do not also create an assistant bubble.
if (hasPresentFiles(message)) {
groups.push({
id: message.id,
type: "assistant:present-files",
messages: [message],
});
- } else if (hasSubagent(message)) {
+ continue;
+ }
+
+ if (hasSubagent(message)) {
+ // Render visible text in a regular assistant bubble so the user
+ // sees "Launching a subagent…" before the subagent panel unfolds.
+ if (messageHasContent) {
+ groups.push({
+ id: message.id,
+ type: "assistant",
+ messages: [message],
+ });
+ }
+
groups.push({
id: message.id,
type: "assistant:subagent",
messages: [message],
});
- } else if (hasReasoning(message) || hasToolCalls(message)) {
+
+ continue;
+ }
+
+ if (needsProcessing) {
const lastGroup = groups[groups.length - 1];
// Accumulate consecutive intermediate AI messages into one processing group.
if (lastGroup?.type !== "assistant:processing") {
@@ -116,10 +140,29 @@ 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)) {
- groups.push({ id: message.id, type: "assistant", messages: [message] });
+ // Not an else-if: a message with reasoning + content goes into the
+ // processing group above AND gets its own assistant bubble. This
+ // includes messages with tool calls — their text content was previously
+ // hidden behind the Chain of Thought panel.
+ //
+ // When *this message* triggered a processing group, insert the
+ // assistant bubble BEFORE it so lastOpenGroup() still finds the
+ // processing group for subsequent tool messages. Otherwise push
+ // normally at the end (plain final answer).
+ if (messageHasContent) {
+ const authorGroup = {
+ id: message.id,
+ type: "assistant" as const,
+ messages: [message],
+ };
+ if (
+ messageHasToolCalls &&
+ groups[groups.length - 1]?.type === "assistant:processing"
+ ) {
+ groups.splice(groups.length - 1, 0, authorGroup);
+ } else {
+ groups.push(authorGroup);
+ }
}
}
}
diff --git a/frontend/tests/unit/core/messages/utils-regression.test.ts b/frontend/tests/unit/core/messages/utils-regression.test.ts
new file mode 100644
index 0000000000..8e0ade9518
--- /dev/null
+++ b/frontend/tests/unit/core/messages/utils-regression.test.ts
@@ -0,0 +1,280 @@
+import type { AIMessage, Message } from "@langchain/langgraph-sdk";
+import { describe, expect, test } from "vitest";
+
+import {
+ extractPresentFilesFromMessage,
+ getMessageGroups,
+} from "@/core/messages/utils";
+
+describe("regression: tool-call messages must not swallow text/reasoning content", () => {
+ test("AI message with tool_calls + text content generates both processing and assistant bubble", () => {
+ const messages = [
+ {
+ id: "human-1",
+ type: "human",
+ content: "Search for deer",
+ },
+ {
+ id: "ai-1",
+ type: "ai",
+ content: "Let me search that for you",
+ tool_calls: [
+ { id: "tc-1", name: "web_search", args: { query: "deer" } },
+ ],
+ },
+ {
+ id: "tool-1",
+ type: "tool",
+ name: "web_search",
+ tool_call_id: "tc-1",
+ content: "Results about deer",
+ },
+ {
+ id: "ai-2",
+ type: "ai",
+ content: "Here is what I found",
+ },
+ ] as Message[];
+
+ const groups = getMessageGroups(messages);
+
+ expect(groups.map((g) => g.type)).toEqual([
+ "human",
+ "assistant",
+ "assistant:processing",
+ "assistant",
+ ]);
+
+ // The assistant bubble with the tool-call message text must exist
+ // and contain the visible content.
+ const assistantGroups = groups.filter((g) => g.type === "assistant");
+ expect(assistantGroups).toHaveLength(2);
+ expect(assistantGroups[0]!.messages).toHaveLength(1);
+ expect(assistantGroups[0]!.messages[0]!.content).toBe(
+ "Let me search that for you",
+ );
+
+ // The processing group must contain the tool-call AI + tool result.
+ const processingGroup = groups.find(
+ (g) => g.type === "assistant:processing",
+ );
+ expect(processingGroup).toBeDefined();
+ expect(processingGroup!.messages.map((m) => m.id)).toEqual([
+ "ai-1",
+ "tool-1",
+ ]);
+ });
+
+ test("AI message with reasoning + tool_calls + text content produces all three", () => {
+ const messages = [
+ {
+ id: "human-1",
+ type: "human",
+ content: "Hello",
+ },
+ {
+ id: "ai-1",
+ type: "ai",
+ content: "need to searchLet me check",
+ tool_calls: [{ id: "tc-1", name: "web_search", args: {} }],
+ },
+ ] as Message[];
+
+ const groups = getMessageGroups(messages);
+
+ // Order: human, assistant bubble (before processing), processing
+ expect(groups.map((g) => g.type)).toEqual([
+ "human",
+ "assistant",
+ "assistant:processing",
+ ]);
+
+ const assistantGroup = groups.find((g) => g.type === "assistant");
+ expect(assistantGroup).toBeDefined();
+ // The visible content after stripping should be "Let me check"
+ expect(assistantGroup!.messages[0]!.content).toContain("Let me check");
+
+ // The processing group must be the last group (after the assistant bubble)
+ // and contain both reasoning and tool_calls.
+ const processingGroup = groups.at(-1);
+ expect(processingGroup?.type).toBe("assistant:processing");
+ expect(processingGroup!.messages).toHaveLength(1);
+
+ const processingMessage = processingGroup!.messages[0]! as AIMessage;
+ expect(processingMessage.id).toBe("ai-1");
+ // Both reasoning () and tool_calls must be present
+ expect(processingMessage.content).toContain("");
+ expect(processingMessage.tool_calls).toBeDefined();
+ expect(processingMessage.tool_calls).toHaveLength(1);
+ });
+
+ test("plain AI answer without tool_calls produces only an assistant bubble", () => {
+ const messages = [
+ { id: "human-1", type: "human", content: "Hi" },
+ { id: "ai-1", type: "ai", content: "Hello there" },
+ ] as Message[];
+
+ const groups = getMessageGroups(messages);
+ expect(groups.map((g) => g.type)).toEqual(["human", "assistant"]);
+ });
+
+ test("AI message with files presented will not be displayed twice in processing and assistant bubble", () => {
+ const messages = [
+ { id: "human-1", type: "human", content: "Hi" },
+ {
+ id: "ai-1",
+ type: "ai",
+ content: "Here are the files you requested",
+ tool_calls: [
+ {
+ id: "tc-1",
+ name: "present_files",
+ args: {
+ filepaths: ["/path/to/file1.txt", "test.txt"],
+ },
+ },
+ ],
+ },
+ ] as Message[];
+
+ const groups = getMessageGroups(messages);
+ expect(groups.map((g) => g.type)).toEqual([
+ "human",
+ "assistant:present-files",
+ ]);
+ expect(groups[1]!.messages).toHaveLength(1);
+ expect(groups[1]!.messages[0]!.content).toBe(
+ "Here are the files you requested",
+ );
+ expect(extractPresentFilesFromMessage(groups[1]!.messages[0]!)).toEqual([
+ "/path/to/file1.txt",
+ "test.txt",
+ ]);
+ });
+
+ test("present_files tool result attaches to the present-files group", () => {
+ const messages = [
+ { id: "human-1", type: "human", content: "Show me the report" },
+ {
+ id: "ai-1",
+ type: "ai",
+ content: "Here is the report.",
+ tool_calls: [
+ {
+ id: "tc-1",
+ name: "present_files",
+ args: {
+ filepaths: ["/mnt/user-data/outputs/report.md"],
+ },
+ },
+ ],
+ },
+ {
+ id: "tool-1",
+ type: "tool",
+ name: "present_files",
+ tool_call_id: "tc-1",
+ content: "Successfully presented files",
+ },
+ ] as Message[];
+
+ const groups = getMessageGroups(messages);
+
+ expect(groups.map((g) => g.type)).toEqual([
+ "human",
+ "assistant:present-files",
+ ]);
+ expect(groups[1]!.messages.map((message) => message.id)).toEqual([
+ "ai-1",
+ "tool-1",
+ ]);
+ });
+
+ test("subagent AI message with content creates assistant bubble and subagent group", () => {
+ const messages = [
+ { id: "human-1", type: "human", content: "Delegate this" },
+ {
+ id: "ai-1",
+ type: "ai",
+ content: "Launching a subagent to help.",
+ tool_calls: [
+ {
+ id: "tc-1",
+ name: "task",
+ args: {
+ subagent_type: "general-purpose",
+ description: "Inspect the issue",
+ prompt: "Inspect the issue",
+ },
+ },
+ ],
+ },
+ ] as Message[];
+
+ const groups = getMessageGroups(messages);
+
+ expect(groups.map((g) => g.type)).toEqual([
+ "human",
+ "assistant",
+ "assistant:subagent",
+ ]);
+ expect(groups[1]!.messages.map((message) => message.id)).toEqual(["ai-1"]);
+ expect(groups[1]!.messages[0]!.content).toBe(
+ "Launching a subagent to help.",
+ );
+ expect(groups[2]!.messages.map((message) => message.id)).toEqual(["ai-1"]);
+ expect(groups[2]!.messages[0]!.type).toBe("ai");
+ expect(groups[2]!.messages[0]!.tool_calls).toEqual([
+ {
+ id: "tc-1",
+ name: "task",
+ args: {
+ subagent_type: "general-purpose",
+ description: "Inspect the issue",
+ prompt: "Inspect the issue",
+ },
+ },
+ ]);
+ });
+
+ test("subagent tool result attaches to the subagent group", () => {
+ const messages = [
+ { id: "human-1", type: "human", content: "Delegate this" },
+ {
+ id: "ai-1",
+ type: "ai",
+ content: "Launching a subagent to help.",
+ tool_calls: [
+ {
+ id: "tc-1",
+ name: "task",
+ args: {
+ subagent_type: "general-purpose",
+ description: "Inspect the issue",
+ prompt: "Inspect the issue",
+ },
+ },
+ ],
+ },
+ {
+ id: "tool-1",
+ type: "tool",
+ name: "task",
+ tool_call_id: "tc-1",
+ content: "Done",
+ },
+ ] as Message[];
+
+ const groups = getMessageGroups(messages);
+
+ expect(groups.map((g) => g.type)).toEqual([
+ "human",
+ "assistant",
+ "assistant:subagent",
+ ]);
+ expect(groups[2]!.messages.map((message) => message.id)).toEqual([
+ "ai-1",
+ "tool-1",
+ ]);
+ });
+});