mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-09 23:08:04 -05:00
fix(frontend): tool titles are not displayed for v1 conversations (#12317)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 || ""}`}
|
||||
>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user