fix(frontend): tool titles are not displayed for v1 conversations (#12317)

This commit is contained in:
Hiep Le
2026-01-09 10:45:42 +07:00
committed by GitHub
parent 9b50d0cb7d
commit d4cf1d4590
6 changed files with 594 additions and 10 deletions

View File

@@ -0,0 +1,551 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { ToolItem } from "#/components/features/conversation-panel/system-message-modal/tool-item";
import type { ChatCompletionToolParam } from "#/types/v1/core";
describe("ToolItem", () => {
const user = userEvent.setup();
const onToggleMock = vi.fn();
afterEach(() => {
vi.clearAllMocks();
});
describe("Name/Title Extraction", () => {
it("should display name from V0 format function.name", () => {
// Arrange
const v0Tool: ChatCompletionToolParam = {
type: "function",
function: {
name: "test_function",
description: "Test description",
parameters: {},
},
};
// Act
renderWithProviders(
<ToolItem
tool={v0Tool}
index={0}
isExpanded={false}
onToggle={onToggleMock}
/>,
);
// Assert
const toggleButton = screen.getByTestId("toggle-button");
expect(toggleButton).toHaveTextContent("test_function");
});
it("should display title from V1 format root level", () => {
// Arrange
const v1Tool = {
title: "V1 Tool Title",
description: "V1 description",
parameters: { type: "object" },
};
// Act
renderWithProviders(
<ToolItem
tool={v1Tool}
index={0}
isExpanded={false}
onToggle={onToggleMock}
/>,
);
// Assert
const toggleButton = screen.getByTestId("toggle-button");
expect(toggleButton).toHaveTextContent("V1 Tool Title");
});
it("should prioritize root title over annotations.title in V1 format", () => {
// Arrange
const v1Tool = {
title: "Root Title",
annotations: {
title: "Annotations Title",
},
description: "Description",
};
// Act
renderWithProviders(
<ToolItem
tool={v1Tool}
index={0}
isExpanded={false}
onToggle={onToggleMock}
/>,
);
// Assert
const toggleButton = screen.getByTestId("toggle-button");
expect(toggleButton).toHaveTextContent("Root Title");
});
it("should fallback to annotations.title when root title is missing", () => {
// Arrange
const v1Tool = {
annotations: {
title: "Annotations Title",
},
description: "Description",
};
// Act
renderWithProviders(
<ToolItem
tool={v1Tool}
index={0}
isExpanded={false}
onToggle={onToggleMock}
/>,
);
// Assert
const toggleButton = screen.getByTestId("toggle-button");
expect(toggleButton).toHaveTextContent("Annotations Title");
});
it("should display empty string when no name or title is available", () => {
// Arrange
const toolWithoutName = {
description: "Description only",
};
// Act
renderWithProviders(
<ToolItem
tool={toolWithoutName}
index={0}
isExpanded={false}
onToggle={onToggleMock}
/>,
);
// Assert
const toggleButton = screen.getByTestId("toggle-button");
expect(toggleButton).toHaveTextContent("");
});
});
describe("Description Extraction", () => {
it("should display description from V0 format function.description when expanded", () => {
// Arrange
const v0Tool: ChatCompletionToolParam = {
type: "function",
function: {
name: "test_function",
description: "V0 function description",
parameters: {},
},
};
// Act
renderWithProviders(
<ToolItem
tool={v0Tool}
index={0}
isExpanded={true}
onToggle={onToggleMock}
/>,
);
// Assert
const markdownRenderer = screen.getByTestId("markdown-renderer");
expect(markdownRenderer).toHaveTextContent("V0 function description");
});
it("should display description from V1 format root level when expanded", () => {
// Arrange
const v1Tool = {
title: "V1 Tool",
description: "V1 root description",
parameters: {},
};
// Act
renderWithProviders(
<ToolItem
tool={v1Tool}
index={0}
isExpanded={true}
onToggle={onToggleMock}
/>,
);
// Assert
const markdownRenderer = screen.getByTestId("markdown-renderer");
expect(markdownRenderer).toHaveTextContent("V1 root description");
});
it("should prioritize root description over function.description in V1 format", () => {
// Arrange
const v1Tool = {
title: "V1 Tool",
description: "Root description",
function: {
description: "Function description",
},
};
// Act
renderWithProviders(
<ToolItem
tool={v1Tool}
index={0}
isExpanded={true}
onToggle={onToggleMock}
/>,
);
// Assert
const markdownRenderer = screen.getByTestId("markdown-renderer");
expect(markdownRenderer).toHaveTextContent("Root description");
});
it("should display empty string when no description is available", () => {
// Arrange
const toolWithoutDescription = {
title: "Tool Name",
};
// Act
renderWithProviders(
<ToolItem
tool={toolWithoutDescription}
index={0}
isExpanded={true}
onToggle={onToggleMock}
/>,
);
// Assert
const markdownRenderer = screen.getByTestId("markdown-renderer");
expect(markdownRenderer).toHaveTextContent("");
});
it("should not display description when collapsed", () => {
// Arrange
const v0Tool: ChatCompletionToolParam = {
type: "function",
function: {
name: "test_function",
description: "Should not be visible",
parameters: {},
},
};
// Act
renderWithProviders(
<ToolItem
tool={v0Tool}
index={0}
isExpanded={false}
onToggle={onToggleMock}
/>,
);
// Assert
expect(screen.queryByTestId("markdown-renderer")).not.toBeInTheDocument();
});
});
describe("Parameters Extraction", () => {
it("should display parameters from V0 format function.parameters when expanded", () => {
// Arrange
const v0Tool: ChatCompletionToolParam = {
type: "function",
function: {
name: "test_function",
description: "Description",
parameters: {
type: "object",
properties: {
param1: { type: "string" },
},
},
},
};
// Act
renderWithProviders(
<ToolItem
tool={v0Tool}
index={0}
isExpanded={true}
onToggle={onToggleMock}
/>,
);
// Assert
const toolParameters = screen.getByTestId("tool-parameters");
expect(toolParameters).toBeInTheDocument();
// Verify that the parameters are rendered (ReactJsonView will render the JSON)
expect(toolParameters).toHaveTextContent("param1");
});
it("should display parameters from V1 format root level when expanded", () => {
// Arrange
const v1Tool = {
title: "V1 Tool",
description: "Description",
parameters: {
type: "object",
properties: {
param2: { type: "number" },
},
},
};
// Act
renderWithProviders(
<ToolItem
tool={v1Tool}
index={0}
isExpanded={true}
onToggle={onToggleMock}
/>,
);
// Assert
const toolParameters = screen.getByTestId("tool-parameters");
expect(toolParameters).toBeInTheDocument();
// Verify that the parameters are rendered (ReactJsonView will render the JSON)
expect(toolParameters).toHaveTextContent("param2");
});
it("should prioritize function.parameters over root parameters in V0 format", () => {
// Arrange
const v0Tool = {
type: "function",
function: {
name: "test_function",
description: "Description",
parameters: {
type: "object",
source: "function",
},
},
parameters: {
type: "object",
source: "root",
},
};
// Act
renderWithProviders(
<ToolItem
tool={v0Tool}
index={0}
isExpanded={true}
onToggle={onToggleMock}
/>,
);
// Assert
const toolParameters = screen.getByTestId("tool-parameters");
expect(toolParameters).toBeInTheDocument();
// Verify that function parameters are used (not root parameters)
expect(toolParameters).toHaveTextContent("function");
expect(toolParameters).not.toHaveTextContent("root");
});
it("should not display parameters when they are null", () => {
// Arrange
const toolWithoutParameters = {
title: "Tool Name",
description: "Description",
};
// Act
renderWithProviders(
<ToolItem
tool={toolWithoutParameters}
index={0}
isExpanded={true}
onToggle={onToggleMock}
/>,
);
// Assert
expect(screen.queryByTestId("tool-parameters")).not.toBeInTheDocument();
});
it("should not display parameters when collapsed", () => {
// Arrange
const v0Tool: ChatCompletionToolParam = {
type: "function",
function: {
name: "test_function",
description: "Description",
parameters: {
type: "object",
},
},
};
// Act
renderWithProviders(
<ToolItem
tool={v0Tool}
index={0}
isExpanded={false}
onToggle={onToggleMock}
/>,
);
// Assert
expect(screen.queryByTestId("tool-parameters")).not.toBeInTheDocument();
});
});
describe("Toggle Functionality", () => {
it("should call onToggle with correct index when toggle button is clicked", async () => {
// Arrange
const v0Tool: ChatCompletionToolParam = {
type: "function",
function: {
name: "test_function",
description: "Description",
parameters: {},
},
};
// Act
renderWithProviders(
<ToolItem
tool={v0Tool}
index={2}
isExpanded={false}
onToggle={onToggleMock}
/>,
);
const toggleButton = screen.getByTestId("toggle-button");
await user.click(toggleButton);
// Assert
expect(onToggleMock).toHaveBeenCalledOnce();
expect(onToggleMock).toHaveBeenCalledWith(2);
});
it("should show expanded content when isExpanded is true", () => {
// Arrange
const v0Tool: ChatCompletionToolParam = {
type: "function",
function: {
name: "test_function",
description: "Test description",
parameters: { type: "object" },
},
};
// Act
renderWithProviders(
<ToolItem
tool={v0Tool}
index={0}
isExpanded={true}
onToggle={onToggleMock}
/>,
);
// Assert
expect(screen.getByTestId("markdown-renderer")).toBeInTheDocument();
expect(screen.getByTestId("tool-parameters")).toBeInTheDocument();
});
it("should hide expanded content when isExpanded is false", () => {
// Arrange
const v0Tool: ChatCompletionToolParam = {
type: "function",
function: {
name: "test_function",
description: "Test description",
parameters: { type: "object" },
},
};
// Act
renderWithProviders(
<ToolItem
tool={v0Tool}
index={0}
isExpanded={false}
onToggle={onToggleMock}
/>,
);
// Assert
expect(screen.queryByTestId("markdown-renderer")).not.toBeInTheDocument();
expect(screen.queryByTestId("tool-parameters")).not.toBeInTheDocument();
});
});
describe("Complex Scenarios", () => {
it("should handle V0 format with type field correctly", () => {
// Arrange
const v0Tool = {
type: "function",
function: {
name: "typed_function",
description: "Typed description",
parameters: { type: "object" },
},
};
// Act
renderWithProviders(
<ToolItem
tool={v0Tool}
index={0}
isExpanded={true}
onToggle={onToggleMock}
/>,
);
// Assert
expect(screen.getByTestId("toggle-button")).toHaveTextContent(
"typed_function",
);
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent(
"Typed description",
);
expect(screen.getByTestId("tool-parameters")).toBeInTheDocument();
});
it("should handle tool data where function is at root level (fallback behavior)", () => {
// Arrange
const toolWithFunctionAtRoot = {
name: "root_function",
description: "Root function description",
parameters: { type: "object" },
};
// Act
renderWithProviders(
<ToolItem
tool={toolWithFunctionAtRoot}
index={0}
isExpanded={true}
onToggle={onToggleMock}
/>,
);
// Assert
expect(screen.getByTestId("toggle-button")).toHaveTextContent(
"root_function",
);
expect(screen.getByTestId("markdown-renderer")).toHaveTextContent(
"Root function description",
);
expect(screen.getByTestId("tool-parameters")).toBeInTheDocument();
});
});
});

View File

@@ -1,6 +1,7 @@
import { useTranslation } from "react-i18next";
import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/base-modal";
import { Typography } from "#/ui/typography";
import { cn } from "#/utils/utils";
interface SystemMessageHeaderProps {
agentClass: string | null;
@@ -14,7 +15,12 @@ export function SystemMessageHeader({
const { t } = useTranslation();
return (
<div className="flex flex-col gap-6 w-full">
<div
className={cn(
"flex flex-col gap-6 w-full",
!!agentClass && !!openhandsVersion ? "gap-6" : "gap-0",
)}
>
<BaseModalTitle title={t("SYSTEM_MESSAGE_MODAL$TITLE")} />
<div className="flex flex-col gap-2">
{agentClass && (

View File

@@ -17,6 +17,7 @@ export function ToggleButton({
return (
<button
type="button"
data-testid="toggle-button"
onClick={onClick}
className={`w-full py-3 px-2 text-left flex items-center justify-between hover:bg-gray-700 transition-colors ${className || ""}`}
>

View File

@@ -1,7 +1,7 @@
import { Typography } from "#/ui/typography";
import { ToolParameters } from "./tool-parameters";
import { ToggleButton } from "./toggle-button";
import { ChatCompletionToolParam } from "#/types/v1/core";
import { MarkdownRenderer } from "../../markdown/markdown-renderer";
interface FunctionData {
name?: string;
@@ -10,11 +10,18 @@ interface FunctionData {
}
interface ToolData {
// V0/OpenAI format
type?: string;
function?: FunctionData;
name?: string;
description?: string;
parameters?: Record<string, unknown>;
// V1 format
title?: string;
kind?: string;
annotations?: {
title?: string;
};
}
interface ToolItemProps {
@@ -28,17 +35,33 @@ export function ToolItem({ tool, index, isExpanded, onToggle }: ToolItemProps) {
// Extract function data from the nested structure
const toolData = tool as ToolData;
const functionData = toolData.function || toolData;
// Extract tool name/title - support both V0 and V1 formats
const name =
// V1 format: check for title field (root level or in annotations)
toolData.title ||
toolData.annotations?.title ||
// V0 format: check for function.name or name
functionData.name ||
(toolData.type === "function" && toolData.function?.name) ||
"";
// Extract description - support both V0 and V1 formats
const description =
// V1 format: description at root level
toolData.description ||
// V0 format: description in function object
functionData.description ||
(toolData.type === "function" && toolData.function?.description) ||
"";
// Extract parameters - support both V0 and V1 formats
const parameters =
// V0 format: parameters in function object
functionData.parameters ||
(toolData.type === "function" && toolData.function?.parameters) ||
// V1 format: parameters at root level (if present)
toolData.parameters ||
null;
return (
@@ -51,10 +74,8 @@ export function ToolItem({ tool, index, isExpanded, onToggle }: ToolItemProps) {
{isExpanded && (
<div className="px-2 pb-3 pt-1">
<div className="mt-2 mb-3">
<Typography.Text className="text-sm whitespace-pre-wrap text-gray-300 leading-relaxed">
{String(description)}
</Typography.Text>
<div className="mt-2 mb-3 text-sm text-gray-300 leading-relaxed">
<MarkdownRenderer>{String(description)}</MarkdownRenderer>
</div>
{/* Parameters section */}

View File

@@ -11,7 +11,7 @@ export function ToolParameters({ parameters }: ToolParametersProps) {
const { t } = useTranslation();
return (
<div className="mt-2">
<div className="mt-2" data-testid="tool-parameters">
<Typography.Text className="text-sm font-semibold text-gray-300">
{t("SYSTEM_MESSAGE_MODAL$PARAMETERS")}
</Typography.Text>

View File

@@ -73,8 +73,13 @@ export function MarkdownRenderer({
const markdownContent = content ?? children ?? "";
return (
<Markdown components={components} remarkPlugins={[remarkGfm, remarkBreaks]}>
{markdownContent}
</Markdown>
<div data-testid="markdown-renderer">
<Markdown
components={components}
remarkPlugins={[remarkGfm, remarkBreaks]}
>
{markdownContent}
</Markdown>
</div>
);
}