test(frontend): add integration tests for DecomposeGoal, StepItem, and ChatMessagesContainer helpers

Agent-Logs-Url: https://github.com/Significant-Gravitas/AutoGPT/sessions/fbc0ab75-9d2e-4ea5-b9c7-c53fb8605913

Co-authored-by: ntindle <8845353+ntindle@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-04-15 17:53:53 +00:00
committed by GitHub
parent 35bca7c7ad
commit 225bdfb543
3 changed files with 777 additions and 0 deletions

View File

@@ -0,0 +1,316 @@
import { describe, expect, it } from "vitest";
import {
buildRenderSegments,
isCompletedToolPart,
isInteractiveToolPart,
parseSpecialMarkers,
splitReasoningAndResponse,
} from "../helpers";
import type { MessagePart, RenderSegment } from "../helpers";
function textPart(text: string): MessagePart {
return { type: "text", text } as MessagePart;
}
function toolPart(
toolName: string,
state: string,
output?: unknown,
): MessagePart {
return {
type: `tool-${toolName}`,
toolCallId: `call_${toolName}`,
toolName,
state,
output,
} as unknown as MessagePart;
}
describe("isCompletedToolPart", () => {
it("returns true for output-available tool part", () => {
const part = toolPart("some_tool", "output-available");
expect(isCompletedToolPart(part)).toBe(true);
});
it("returns true for output-error tool part", () => {
const part = toolPart("some_tool", "output-error");
expect(isCompletedToolPart(part)).toBe(true);
});
it("returns false for input-streaming tool part", () => {
const part = toolPart("some_tool", "input-streaming");
expect(isCompletedToolPart(part)).toBe(false);
});
it("returns false for text part", () => {
const part = textPart("hello");
expect(isCompletedToolPart(part)).toBe(false);
});
});
describe("isInteractiveToolPart", () => {
it("returns true for task_decomposition type", () => {
const part = toolPart("decompose_goal", "output-available", {
type: "task_decomposition",
message: "Plan",
goal: "Build agent",
steps: [],
step_count: 0,
requires_approval: true,
});
expect(isInteractiveToolPart(part)).toBe(true);
});
it("returns true for setup_requirements type", () => {
const part = toolPart("run_mcp_tool", "output-available", {
type: "setup_requirements",
message: "Setup needed",
});
expect(isInteractiveToolPart(part)).toBe(true);
});
it("returns true for agent_details type", () => {
const part = toolPart("find_agent", "output-available", {
type: "agent_details",
});
expect(isInteractiveToolPart(part)).toBe(true);
});
it("returns false for non-interactive output type", () => {
const part = toolPart("some_tool", "output-available", {
type: "generic_output",
});
expect(isInteractiveToolPart(part)).toBe(false);
});
it("returns false when state is not output-available", () => {
const part = toolPart("decompose_goal", "input-streaming", {
type: "task_decomposition",
});
expect(isInteractiveToolPart(part)).toBe(false);
});
it("returns false for non-tool parts", () => {
const part = textPart("hello");
expect(isInteractiveToolPart(part)).toBe(false);
});
it("returns false when output is null", () => {
const part = toolPart("decompose_goal", "output-available", null);
expect(isInteractiveToolPart(part)).toBe(false);
});
it("handles JSON-encoded string output", () => {
const part = toolPart(
"decompose_goal",
"output-available",
JSON.stringify({ type: "task_decomposition" }),
);
expect(isInteractiveToolPart(part)).toBe(true);
});
it("returns false for invalid JSON string output", () => {
const part = toolPart(
"decompose_goal",
"output-available",
"not valid json",
);
expect(isInteractiveToolPart(part)).toBe(false);
});
});
describe("buildRenderSegments", () => {
it("returns individual segments for custom tool types", () => {
const parts = [
toolPart("decompose_goal", "output-available", {
type: "task_decomposition",
}),
];
const segments = buildRenderSegments(parts);
expect(segments).toHaveLength(1);
expect(segments[0].kind).toBe("part");
});
it("collapses consecutive generic completed tool parts", () => {
const parts = [
toolPart("unknown_tool_a", "output-available"),
toolPart("unknown_tool_b", "output-available"),
];
const segments = buildRenderSegments(parts);
expect(segments).toHaveLength(1);
expect(segments[0].kind).toBe("collapsed-group");
if (segments[0].kind === "collapsed-group") {
expect(segments[0].parts).toHaveLength(2);
}
});
it("does not collapse custom tool types into groups", () => {
const parts = [
toolPart("decompose_goal", "output-available", {
type: "task_decomposition",
}),
toolPart("create_agent", "output-available"),
];
const segments = buildRenderSegments(parts);
expect(segments).toHaveLength(2);
expect(segments[0].kind).toBe("part");
expect(segments[1].kind).toBe("part");
});
it("renders text parts individually", () => {
const parts = [textPart("Hello"), textPart("World")];
const segments = buildRenderSegments(parts);
expect(segments).toHaveLength(2);
expect(segments.every((s) => s.kind === "part")).toBe(true);
});
it("handles mixed custom tools, generic tools, and text", () => {
const parts = [
textPart("Plan:"),
toolPart("decompose_goal", "output-available"),
toolPart("generic_a", "output-available"),
toolPart("generic_b", "output-available"),
textPart("Done"),
];
const segments = buildRenderSegments(parts);
expect(segments[0].kind).toBe("part");
expect(segments[1].kind).toBe("part");
expect(segments[2].kind).toBe("collapsed-group");
expect(segments[3].kind).toBe("part");
});
it("does not collapse a single generic tool part", () => {
const parts = [
toolPart("generic_a", "output-available"),
];
const segments = buildRenderSegments(parts);
expect(segments).toHaveLength(1);
expect(segments[0].kind).toBe("part");
});
it("preserves baseIndex offset in part segments", () => {
const parts = [textPart("Hello")];
const segments = buildRenderSegments(parts, 5);
expect(segments).toHaveLength(1);
if (segments[0].kind === "part") {
expect(segments[0].index).toBe(5);
}
});
});
describe("splitReasoningAndResponse", () => {
it("returns all parts as response when no tools are present", () => {
const parts = [textPart("Hello"), textPart("World")];
const { reasoning, response } = splitReasoningAndResponse(parts);
expect(reasoning).toHaveLength(0);
expect(response).toHaveLength(2);
});
it("returns all parts as response when no text follows the last tool", () => {
const parts = [
textPart("Thinking..."),
toolPart("decompose_goal", "output-available", {
type: "task_decomposition",
}),
];
const { reasoning, response } = splitReasoningAndResponse(parts);
expect(reasoning).toHaveLength(0);
expect(response).toHaveLength(2);
});
it("splits reasoning and response when text follows the last tool", () => {
const parts = [
textPart("Let me plan this..."),
toolPart("decompose_goal", "output-available", {
type: "task_decomposition",
}),
textPart("Here is the plan."),
];
const { reasoning, response } = splitReasoningAndResponse(parts);
expect(reasoning).toHaveLength(1);
expect(response).toHaveLength(2);
});
it("pins interactive tool parts to response section", () => {
const interactiveTool = toolPart(
"decompose_goal",
"output-available",
{ type: "task_decomposition" },
);
const parts = [
textPart("Thinking..."),
interactiveTool,
textPart("Here is the summary."),
];
const { reasoning, response } = splitReasoningAndResponse(parts);
expect(reasoning).toHaveLength(1);
expect(reasoning[0]).toBe(parts[0]);
expect(response).toHaveLength(2);
expect(response[0]).toBe(interactiveTool);
expect(response[1]).toBe(parts[2]);
});
it("keeps non-interactive tool parts in reasoning", () => {
const genericTool = toolPart("find_block", "output-available", {
type: "block_list",
});
const parts = [
textPart("Looking for blocks..."),
genericTool,
textPart("Found them."),
];
const { reasoning, response } = splitReasoningAndResponse(parts);
expect(reasoning).toHaveLength(2);
expect(reasoning[1]).toBe(genericTool);
expect(response).toHaveLength(1);
});
});
describe("parseSpecialMarkers", () => {
it("returns null marker for plain text", () => {
const result = parseSpecialMarkers("Hello world");
expect(result.markerType).toBeNull();
expect(result.cleanText).toBe("Hello world");
});
it("detects error marker", () => {
const result = parseSpecialMarkers(
"Some preamble [__COPILOT_ERROR_f7a1__] Something went wrong",
);
expect(result.markerType).toBe("error");
expect(result.markerText).toBe("Something went wrong");
});
it("detects retryable error marker", () => {
const result = parseSpecialMarkers(
"[__COPILOT_RETRYABLE_ERROR_a9c2__] Timeout reached",
);
expect(result.markerType).toBe("retryable_error");
expect(result.markerText).toBe("Timeout reached");
});
it("detects system marker", () => {
const result = parseSpecialMarkers(
"[__COPILOT_SYSTEM_e3b0__] Session expired",
);
expect(result.markerType).toBe("system");
expect(result.markerText).toBe("Session expired");
});
it("retryable takes precedence over regular error when both present", () => {
const text =
"[__COPILOT_RETRYABLE_ERROR_a9c2__] Retryable issue [__COPILOT_ERROR_f7a1__] Also error";
const result = parseSpecialMarkers(text);
expect(result.markerType).toBe("retryable_error");
});
it("strips marker from cleanText", () => {
const result = parseSpecialMarkers(
"Preamble text [__COPILOT_SYSTEM_e3b0__] System message",
);
expect(result.cleanText).toBe("Preamble text");
});
});

View File

@@ -0,0 +1,400 @@
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@/tests/integrations/test-utils";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DecomposeGoalTool } from "../DecomposeGoal";
import type { TaskDecompositionOutput } from "../helpers";
const mockOnSend = vi.fn();
vi.mock(
"../../../components/CopilotChatActionsProvider/useCopilotChatActions",
() => ({
useCopilotChatActions: () => ({ onSend: mockOnSend }),
}),
);
vi.mock("@/app/api/__generated__/endpoints/chat/chat", () => ({
postV2CancelAutoApproveTask: vi.fn(() => Promise.resolve()),
}));
const STEPS = [
{
step_id: "step_1",
description: "Add input block",
action: "add_input",
block_name: null,
status: "pending",
},
{
step_id: "step_2",
description: "Add AI summarizer",
action: "add_block",
block_name: "AI Text Generator",
status: "pending",
},
{
step_id: "step_3",
description: "Connect blocks",
action: "connect_blocks",
block_name: null,
status: "pending",
},
];
const DECOMPOSITION: TaskDecompositionOutput = {
type: "task_decomposition",
message: "Here's the plan (3 steps):",
goal: "Build a news summarizer",
steps: STEPS,
step_count: 3,
requires_approval: true,
auto_approve_seconds: 60,
created_at: new Date().toISOString(),
session_id: "test-session-1",
};
function makePart(
state: string,
output?: unknown,
): { type: string; toolCallId: string; toolName: string; state: string; input?: unknown; output?: unknown } {
return {
type: "tool-decompose_goal",
toolCallId: "call_1",
toolName: "decompose_goal",
state,
output,
};
}
describe("DecomposeGoalTool", () => {
afterEach(() => {
cleanup();
mockOnSend.mockClear();
});
it("renders analyzing animation during input-streaming", () => {
render(
<DecomposeGoalTool
part={makePart("input-streaming") as any}
isLastMessage
/>,
);
expect(screen.getByText(/A/)).toBeDefined();
});
it("renders error card when state is output-error", () => {
render(
<DecomposeGoalTool
part={makePart("output-error") as any}
isLastMessage
/>,
);
expect(
screen.getByText(/Failed to analyze the goal/i),
).toBeDefined();
expect(screen.getByText("Try again")).toBeDefined();
});
it("sends retry message when Try again is clicked on error", () => {
render(
<DecomposeGoalTool
part={makePart("output-error") as any}
isLastMessage
/>,
);
fireEvent.click(screen.getByText("Try again"));
expect(mockOnSend).toHaveBeenCalledWith(
"Please try decomposing the goal again.",
);
});
it("renders error card for error output object", () => {
const errorOutput = {
type: "error",
error: "missing_steps",
message: "Please provide at least one step.",
};
render(
<DecomposeGoalTool
part={makePart("output-available", errorOutput) as any}
isLastMessage
/>,
);
expect(
screen.getByText("Please provide at least one step."),
).toBeDefined();
});
it("renders the build plan accordion with steps", () => {
render(
<DecomposeGoalTool
part={makePart("output-available", DECOMPOSITION) as any}
isLastMessage
/>,
);
expect(screen.getByText(/Build Plan — 3 steps/)).toBeDefined();
expect(screen.getByText("Build a news summarizer")).toBeDefined();
expect(screen.getByText(/Here's the plan/)).toBeDefined();
expect(screen.getByText(/1\. Add input block/)).toBeDefined();
expect(screen.getByText(/2\. Add AI summarizer/)).toBeDefined();
expect(screen.getByText(/3\. Connect blocks/)).toBeDefined();
});
it("renders block name badges for steps that have them", () => {
render(
<DecomposeGoalTool
part={makePart("output-available", DECOMPOSITION) as any}
isLastMessage
/>,
);
expect(screen.getByText("AI Text Generator")).toBeDefined();
});
it("shows approve and modify buttons when requires_approval and isLastMessage", () => {
render(
<DecomposeGoalTool
part={makePart("output-available", DECOMPOSITION) as any}
isLastMessage
/>,
);
expect(screen.getByText("Modify")).toBeDefined();
expect(screen.getByText(/Starting in/)).toBeDefined();
});
it("hides action buttons when isLastMessage is false", () => {
render(
<DecomposeGoalTool
part={makePart("output-available", DECOMPOSITION) as any}
isLastMessage={false}
/>,
);
expect(screen.queryByText("Modify")).toBeNull();
expect(
screen.getByText(/Review the plan above and approve/),
).toBeDefined();
});
it("hides action buttons when requires_approval is false", () => {
const noApproval = { ...DECOMPOSITION, requires_approval: false };
render(
<DecomposeGoalTool
part={makePart("output-available", noApproval) as any}
isLastMessage
/>,
);
expect(screen.queryByText("Modify")).toBeNull();
});
it("disables buttons while message is still streaming", () => {
render(
<DecomposeGoalTool
part={makePart("output-available", DECOMPOSITION) as any}
isLastMessage
isMessageStreaming
/>,
);
const modifyBtn = screen.getByText("Modify").closest("button");
expect(modifyBtn?.disabled).toBe(true);
});
it("sends approval message when approve button is clicked", async () => {
render(
<DecomposeGoalTool
part={makePart("output-available", DECOMPOSITION) as any}
isLastMessage
/>,
);
const startBtn = screen.getByText(/Starting in/).closest("button");
expect(startBtn).toBeDefined();
fireEvent.click(startBtn!);
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledWith(
"Approved. Please build the agent.",
);
});
});
it("does not send duplicate approval on second click", async () => {
render(
<DecomposeGoalTool
part={makePart("output-available", DECOMPOSITION) as any}
isLastMessage
/>,
);
const startBtn = screen.getByText(/Starting in/).closest("button");
fireEvent.click(startBtn!);
fireEvent.click(startBtn!);
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledTimes(1);
});
});
it("enters edit mode when Modify is clicked", async () => {
render(
<DecomposeGoalTool
part={makePart("output-available", DECOMPOSITION) as any}
isLastMessage
/>,
);
fireEvent.click(screen.getByText("Modify"));
await waitFor(() => {
const textareas = screen.getAllByPlaceholderText("Step description");
expect(textareas.length).toBe(3);
});
});
it("cancels auto-approve on the server when Modify is clicked", async () => {
const { postV2CancelAutoApproveTask } = await import(
"@/app/api/__generated__/endpoints/chat/chat"
);
render(
<DecomposeGoalTool
part={makePart("output-available", DECOMPOSITION) as any}
isLastMessage
/>,
);
fireEvent.click(screen.getByText("Modify"));
await waitFor(() => {
expect(postV2CancelAutoApproveTask).toHaveBeenCalledWith(
"test-session-1",
);
});
});
it("allows editing step descriptions in edit mode", async () => {
render(
<DecomposeGoalTool
part={makePart("output-available", DECOMPOSITION) as any}
isLastMessage
/>,
);
fireEvent.click(screen.getByText("Modify"));
await waitFor(() => {
expect(screen.getAllByPlaceholderText("Step description").length).toBe(3);
});
const textareas = screen.getAllByPlaceholderText("Step description");
fireEvent.change(textareas[0], {
target: { value: "Fetch RSS feed" },
});
expect(
(screen.getAllByPlaceholderText("Step description")[0] as HTMLTextAreaElement).value,
).toBe("Fetch RSS feed");
});
it("allows deleting steps in edit mode", async () => {
render(
<DecomposeGoalTool
part={makePart("output-available", DECOMPOSITION) as any}
isLastMessage
/>,
);
fireEvent.click(screen.getByText("Modify"));
await waitFor(() => {
expect(screen.getAllByPlaceholderText("Step description").length).toBe(3);
});
const removeButtons = screen.getAllByLabelText("Remove step");
fireEvent.click(removeButtons[0]);
await waitFor(() => {
expect(screen.getAllByPlaceholderText("Step description").length).toBe(2);
});
});
it("allows inserting new steps in edit mode", async () => {
render(
<DecomposeGoalTool
part={makePart("output-available", DECOMPOSITION) as any}
isLastMessage
/>,
);
fireEvent.click(screen.getByText("Modify"));
await waitFor(() => {
expect(screen.getAllByPlaceholderText("Step description").length).toBe(3);
});
const insertButtons = screen.getAllByLabelText("Insert step here");
fireEvent.click(insertButtons[0]);
await waitFor(() => {
expect(screen.getAllByPlaceholderText("Step description").length).toBe(4);
});
});
it("sends modified steps message when approve is clicked in edit mode", async () => {
render(
<DecomposeGoalTool
part={makePart("output-available", DECOMPOSITION) as any}
isLastMessage
/>,
);
fireEvent.click(screen.getByText("Modify"));
await waitFor(() => {
expect(screen.getAllByPlaceholderText("Step description").length).toBe(3);
});
const textareas = screen.getAllByPlaceholderText("Step description");
fireEvent.change(textareas[0], {
target: { value: "Fetch RSS feed" },
});
fireEvent.click(screen.getByText("Approve"));
await waitFor(() => {
expect(mockOnSend).toHaveBeenCalledWith(
expect.stringContaining("Approved with modifications"),
);
expect(mockOnSend).toHaveBeenCalledWith(
expect.stringContaining("Fetch RSS feed"),
);
});
});
it("renders countdown timer in the approve button", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(DECOMPOSITION.created_at!));
render(
<DecomposeGoalTool
part={makePart("output-available", DECOMPOSITION) as any}
isLastMessage
/>,
);
expect(screen.getByText("60")).toBeDefined();
vi.useRealTimers();
});
it("renders nothing pending when output is not yet available", () => {
const { container } = render(
<DecomposeGoalTool
part={makePart("input-available") as any}
isLastMessage
/>,
);
expect(container.querySelector(".py-2")).toBeDefined();
});
});

View File

@@ -0,0 +1,61 @@
import { render, screen } from "@/tests/integrations/test-utils";
import { describe, expect, it } from "vitest";
import { StepItem } from "../StepItem";
describe("StepItem", () => {
it("renders step number and description", () => {
render(
<StepItem index={0} description="Add input block" status="pending" />,
);
expect(screen.getByText("1. Add input block")).toBeDefined();
});
it("renders block name when provided", () => {
render(
<StepItem
index={1}
description="Add AI summarizer"
blockName="AI Text Generator"
status="pending"
/>,
);
expect(screen.getByText("AI Text Generator")).toBeDefined();
});
it("does not render block name when null", () => {
render(
<StepItem
index={0}
description="Connect blocks"
blockName={null}
status="pending"
/>,
);
expect(screen.queryByText("AI Text Generator")).toBeNull();
});
it("renders pending icon by default", () => {
render(<StepItem index={0} description="Step" status="pending" />);
expect(screen.getByLabelText("pending")).toBeDefined();
});
it("renders completed icon for completed status", () => {
render(<StepItem index={0} description="Step" status="completed" />);
expect(screen.getByLabelText("completed")).toBeDefined();
});
it("renders in-progress icon for in_progress status", () => {
render(<StepItem index={0} description="Step" status="in_progress" />);
expect(screen.getByLabelText("in progress")).toBeDefined();
});
it("renders failed icon for failed status", () => {
render(<StepItem index={0} description="Step" status="failed" />);
expect(screen.getByLabelText("failed")).toBeDefined();
});
it("uses zero-based index to render 1-based step number", () => {
render(<StepItem index={4} description="Fifth step" status="pending" />);
expect(screen.getByText("5. Fifth step")).toBeDefined();
});
});