import type { Message } from "@langchain/langgraph-sdk";
import { afterEach, describe, expect, it, rs } from "@rstest/core";
import { createElement, type ComponentProps } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { MessageGroup } from "@/components/workspace/messages/message-group";
import { I18nContext } from "@/core/i18n/context";
import { enUS } from "@/core/i18n/locales/en-US";
const artifactsMockState = rs.hoisted(() => ({
autoOpen: false,
autoSelect: false,
}));
rs.mock("@/components/workspace/artifacts", () => ({
useArtifacts: () => ({
artifacts: [],
setArtifacts: () => undefined,
selectedArtifact: null,
autoSelect: artifactsMockState.autoSelect,
select: () => undefined,
deselect: () => undefined,
open: false,
autoOpen: artifactsMockState.autoOpen,
setOpen: () => undefined,
}),
}));
afterEach(() => {
artifactsMockState.autoOpen = false;
artifactsMockState.autoSelect = false;
rs.restoreAllMocks();
});
describe("MessageGroup", () => {
it("renders unresolved streaming assistant text before a tool call arrives", () => {
const html = renderGroup(
[
{
id: "ai-1",
type: "ai",
content: "I will inspect the source material first.",
} as Message,
],
{ isLoading: true },
);
expect(html).toContain(">inspect");
expect(html).toContain(">source");
expect(html).toContain(">first.");
});
it("renders assistant text attached to a tool-calling processing message", () => {
const html = renderGroup([
{
id: "ai-1",
type: "ai",
content: "The browser action failed, so I will try another approach.",
tool_calls: [
{
id: "call-1",
name: "web_search",
args: { query: "DeerFlow issue 4027" },
},
],
} as Message,
]);
expect(html).toContain(
"The browser action failed, so I will try another approach.",
);
expect(html).toContain("DeerFlow issue 4027");
});
it("keeps assistant text visible while older tool steps stay collapsed", () => {
const html = renderGroup([
{
id: "ai-1",
type: "ai",
content: "The first tool failed; I will try a narrower search.",
tool_calls: [
{
id: "call-1",
name: "web_search",
args: { query: "first hidden query" },
},
],
} as Message,
{
id: "tool-1",
type: "tool",
name: "web_search",
tool_call_id: "call-1",
content: "[]",
} as Message,
{
id: "ai-2",
type: "ai",
content: "The second approach should reveal the missing context.",
tool_calls: [
{
id: "call-2",
name: "bash",
args: {
description: "Inspect message rendering",
command: "rg assistantText frontend/src",
},
},
],
} as Message,
]);
expect(html).toContain(
"The first tool failed; I will try a narrower search.",
);
expect(html).toContain(
"The second approach should reveal the missing context.",
);
expect(html).not.toContain("first hidden query");
expect(html).toContain("Inspect message rendering");
expect(html).toContain("1 more step");
});
it("keeps content-only assistant text visible after a tool call while streaming", () => {
const html = renderGroup(
[
{
id: "ai-1",
type: "ai",
content: "I will inspect the current implementation.",
tool_calls: [
{
id: "call-1",
name: "read_file",
args: { path: "message-group.tsx" },
},
],
} as Message,
{
id: "tool-1",
type: "tool",
name: "read_file",
tool_call_id: "call-1",
content: "file contents",
} as Message,
{
id: "ai-2",
type: "ai",
content: "Here is the final streamed answer.",
} as Message,
],
{ isLoading: true },
);
expect(html).toContain(">final");
expect(html).toContain(">streamed");
expect(html).toContain(">answer.");
});
it("does not schedule artifact auto-open during render", () => {
artifactsMockState.autoOpen = true;
artifactsMockState.autoSelect = true;
const timeoutSpy = rs.spyOn(globalThis, "setTimeout");
const html = renderGroup(
[
{
id: "ai-write",
type: "ai",
content: "",
tool_calls: [
{
id: "call-write",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/report.md",
content: "# Report",
},
},
],
} as Message,
],
{ isLoading: true },
);
expect(html).toContain("/mnt/user-data/outputs/report.md");
expect(timeoutSpy).not.toHaveBeenCalled();
});
it("renders streaming reasoning above the answer text of the same message", () => {
const html = renderGroup(
[
{
id: "ai-1",
type: "ai",
content: "Zephyr answer body.",
additional_kwargs: {
reasoning_content: "The user asked who I am, so I will summarize.",
},
} as Message,
],
{ isLoading: true },
);
expectRenderedInOrder(html, ["Thinking", ">Zephyr"]);
});
it("renders streaming inline think reasoning above the answer text", () => {
const html = renderGroup(
[
{
id: "ai-1",
type: "ai",
content:
"\nThe user only said hello, so I will greet back.\n\n\nZephyr answer body.",
} as Message,
],
{ isLoading: true },
);
expectRenderedInOrder(html, ["Thinking", ">Zephyr"]);
});
it("renders trailing reasoning above the answer text that follows a tool call", () => {
const html = renderGroup(
[
{
id: "ai-1",
type: "ai",
content: "",
tool_calls: [
{
id: "call-1",
name: "read_file",
args: { path: "message-group.tsx" },
},
],
} as Message,
{
id: "tool-1",
type: "tool",
name: "read_file",
tool_call_id: "call-1",
content: "file contents",
} as Message,
{
id: "ai-2",
type: "ai",
content: "Zephyr answer body.",
additional_kwargs: {
reasoning_content: "The file confirms the renderer order.",
},
} as Message,
],
{ isLoading: true },
);
expectRenderedInOrder(html, [
"message-group.tsx",
"Thinking",
">Zephyr",
]);
});
it("keeps assistant text emitted before the trailing reasoning above it", () => {
const html = renderGroup(
[
{
id: "ai-1",
type: "ai",
content: "Quartz interim note.",
} as Message,
{
id: "ai-2",
type: "ai",
content: "Zephyr answer body.",
additional_kwargs: {
reasoning_content: "Now I can write the final answer.",
},
} as Message,
],
{ isLoading: true },
);
expectRenderedInOrder(html, [
">Quartz",
"Thinking",
">Zephyr",
]);
});
it("keeps tool-calling assistant text visible when reasoning is also present", () => {
const html = renderGroup([
{
id: "ai-1",
type: "ai",
content: "I found a likely cause, so I will inspect the renderer next.",
additional_kwargs: {
reasoning_content: "Check how processing groups convert messages.",
},
tool_calls: [
{
id: "call-1",
name: "bash",
args: {
description: "Inspect renderer conversion",
command: "sed -n '720,780p' message-group.tsx",
},
},
],
} as Message,
]);
expect(html).toContain(
"I found a likely cause, so I will inspect the renderer next.",
);
expect(html).toContain("Inspect renderer conversion");
expect(html).toContain("1 more step");
expect(html).not.toContain("Check how processing groups convert messages.");
});
it("defers browser screenshot previews while the thread is loading", () => {
const messages = [
{
id: "ai-1",
type: "ai",
content: "",
tool_calls: [
{
id: "call-1",
name: "browser_navigate",
args: { url: "https://github.com/bytedance/deer-flow" },
},
],
} as Message,
{
id: "tool-1",
type: "tool",
name: "browser_navigate",
tool_call_id: "call-1",
content: "Opened",
additional_kwargs: {
browser_view: {
screenshot: "/mnt/user-data/outputs/browser.png",
url: "https://github.com/bytedance/deer-flow",
},
},
} as Message,
];
const visibleHtml = renderGroup(messages, {
threadId: "thread-1",
deferBrowserPreviews: false,
});
const deferredHtml = renderGroup(messages, {
threadId: "thread-1",
deferBrowserPreviews: true,
});
expect(visibleHtml).toContain("
{
const html = renderGroup([
{
id: "ai-1",
type: "ai",
content: "",
tool_calls: [
{
id: "call-fetch",
name: "web_fetch",
args: { url: "https://example.com" },
},
{
id: "call-task",
name: "task",
args: { description: "Do not render this subagent call" },
},
],
} as Message,
{
id: "tool-empty",
type: "tool",
name: "web_fetch",
tool_call_id: "call-fetch",
content: "",
} as Message,
{
id: "tool-first",
type: "tool",
name: "web_fetch",
tool_call_id: "call-fetch",
content: "# First fetched title\n\nFirst result.",
} as Message,
{
id: "tool-later",
type: "tool",
name: "web_fetch",
tool_call_id: "call-fetch",
content: "# Later fetched title\n\nLater result.",
} as Message,
]);
expect(html).toContain("First fetched title");
expect(html).not.toContain("Later fetched title");
expect(html).not.toContain("Do not render this subagent call");
});
it("keeps the first browser view that includes a screenshot", () => {
const html = renderGroup(
[
{
id: "ai-1",
type: "ai",
content: "",
tool_calls: [
{
id: "call-browser",
name: "browser_navigate",
args: { url: "https://example.com" },
},
],
} as Message,
{
id: "tool-without-shot",
type: "tool",
name: "browser_navigate",
tool_call_id: "call-browser",
content: "Opened without a preview.",
additional_kwargs: {
browser_view: { url: "https://example.com" },
},
} as Message,
{
id: "tool-first-shot",
type: "tool",
name: "browser_navigate",
tool_call_id: "call-browser",
content: "Opened with the first preview.",
additional_kwargs: {
browser_view: {
screenshot: "/mnt/user-data/outputs/first-browser.png",
url: "https://example.com/first",
},
},
} as Message,
{
id: "tool-later-shot",
type: "tool",
name: "browser_navigate",
tool_call_id: "call-browser",
content: "Opened with a later preview.",
additional_kwargs: {
browser_view: {
screenshot: "/mnt/user-data/outputs/later-browser.png",
url: "https://example.com/later",
},
},
} as Message,
],
{ threadId: "thread-1" },
);
expect(html).toContain("first-browser.png");
expect(html).not.toContain("later-browser.png");
expect(html).toContain("https://example.com/first");
});
it("renders the earliest JSON tool result after an empty streamed update", () => {
const html = renderGroup([
{
id: "ai-1",
type: "ai",
content: "",
tool_calls: [
{
id: "call-search",
name: "web_search",
args: { query: "DeerFlow" },
},
],
} as Message,
{
id: "tool-empty",
type: "tool",
name: "web_search",
tool_call_id: "call-search",
content: "",
} as Message,
{
id: "tool-first",
type: "tool",
name: "web_search",
tool_call_id: "call-search",
content: JSON.stringify([
{ title: "First source", url: "https://first.example" },
]),
} as Message,
{
id: "tool-later",
type: "tool",
name: "web_search",
tool_call_id: "call-search",
content: JSON.stringify([
{ title: "Later source", url: "https://later.example" },
]),
} as Message,
]);
expect(html).toContain("First source");
expect(html).toContain("https://first.example");
expect(html).not.toContain("Later source");
expect(html).not.toContain("https://later.example");
});
});
// Tool args come from the model and results from search providers, so a
// prompt-injected URL must not become a navigable anchor. React only rewrites
// javascript: hrefs; local and OS-handler schemes would otherwise pass through.
// A blocked URL keeps the markdown path's "Unsafe link omitted" marker.
describe("MessageGroup tool links", () => {
const unsafeUrls = [
"javascript:alert(1)",
"file:///etc/passwd",
"ms-msdt:/id PCWDiagnostic",
"vscode://file/etc/passwd",
];
it.each(unsafeUrls)("marks a web_fetch URL of %s as omitted", (url) => {
const html = renderToolCall("web_fetch", { url });
expect(html).toContain(`>${url}`);
expect(html).toContain(`title="Unsafe link scheme in ${url}"`);
expect(unsafeMarkerCount(html)).toBe(1);
expect(html).not.toContain(" {
const html = renderToolCall(
"web_search",
{ query: "DeerFlow" },
JSON.stringify([
{ title: "Safe source", url: "https://safe.example" },
{ title: "Injected source", url },
]),
);
expect(html).toContain('href="https://safe.example"');
expect(html).toContain(">Injected source");
expect(unsafeMarkerCount(html)).toBe(1);
expect(anchorCount(html)).toBe(1);
});
it.each(unsafeUrls)(
"marks an image_search source at %s as omitted",
(url) => {
const html = renderToolCall(
"image_search",
{ query: "DeerFlow" },
JSON.stringify({
results: [
{
title: "Injected image",
source_url: url,
thumbnail_url: "https://images.example/thumb.png",
image_url: "https://images.example/full.png",
},
],
}),
);
expect(html).toContain('src="https://images.example/thumb.png"');
expect(unsafeMarkerCount(html)).toBe(1);
expect(html).not.toContain(" {
const fetchHtml = renderToolCall("web_fetch", {
url: "https://example.com/page",
});
const imageHtml = renderToolCall(
"image_search",
{ query: "DeerFlow" },
JSON.stringify({
results: [
{
title: "Image",
source_url: "https://example.com/source",
thumbnail_url: "https://images.example/thumb.png",
image_url: "https://images.example/full.png",
},
],
}),
);
expect(fetchHtml).toContain('href="https://example.com/page"');
expect(imageHtml).toContain('href="https://example.com/source"');
expect(unsafeMarkerCount(fetchHtml + imageHtml)).toBe(0);
});
// Models occasionally emit non-string args, and the step renders mid-stream;
// an object reaching the JSX would throw and take down the message list.
it("renders a web_fetch step whose url arg is not a string", () => {
const html = renderToolCall("web_fetch", {
url: { href: "https://example.com/page" },
});
expect(html).toContain("View web page");
expect(html).not.toContain(" {
for (const args of [undefined, null]) {
const render = () =>
renderGroup([
{
id: "ai-1",
type: "ai",
content: "",
tool_calls: [{ id: "call-1", name, args }],
} as unknown as Message,
]);
expect(render).not.toThrow();
}
});
});
function renderToolCall(
name: string,
args: Record,
content?: string,
) {
const messages: Message[] = [
{
id: "ai-1",
type: "ai",
content: "",
tool_calls: [{ id: "call-1", name, args }],
} as Message,
];
if (content !== undefined) {
messages.push({
id: "tool-1",
type: "tool",
name,
tool_call_id: "call-1",
content,
} as Message);
}
return renderGroup(messages);
}
function unsafeMarkerCount(html: string) {
return html.split('aria-label="Unsafe link omitted"').length - 1;
}
function anchorCount(html: string) {
return html.match(/ html.indexOf(needle));
for (const index of indices) {
expect(index).toBeGreaterThan(-1);
}
expect(indices).toStrictEqual([...indices].sort((a, b) => a - b));
}
function renderGroup(
messages: Message[],
props: Omit, "messages"> = {},
) {
return renderToStaticMarkup(
createElement(
I18nContext.Provider,
{
value: {
locale: "en-US",
setLocale: () => undefined,
t: enUS,
},
},
createElement(MessageGroup, { ...props, messages }),
),
);
}