From 225bdfb5437d8412bee3a7988db44316e0908fe8 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 15 Apr 2026 17:53:53 +0000
Subject: [PATCH] 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>
---
.../__tests__/helpers.test.ts | 316 ++++++++++++++
.../__tests__/DecomposeGoal.test.tsx | 400 ++++++++++++++++++
.../components/__tests__/StepItem.test.tsx | 61 +++
3 files changed, 777 insertions(+)
create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/__tests__/helpers.test.ts
create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/tools/DecomposeGoal/__tests__/DecomposeGoal.test.tsx
create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/tools/DecomposeGoal/components/__tests__/StepItem.test.tsx
diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/__tests__/helpers.test.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/__tests__/helpers.test.ts
new file mode 100644
index 0000000000..ebae176525
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/__tests__/helpers.test.ts
@@ -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");
+ });
+});
diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/tools/DecomposeGoal/__tests__/DecomposeGoal.test.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/tools/DecomposeGoal/__tests__/DecomposeGoal.test.tsx
new file mode 100644
index 0000000000..2661df66d7
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/copilot/tools/DecomposeGoal/__tests__/DecomposeGoal.test.tsx
@@ -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(
+ ,
+ );
+ expect(screen.getByText(/A/)).toBeDefined();
+ });
+
+ it("renders error card when state is output-error", () => {
+ render(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ expect(
+ screen.getByText("Please provide at least one step."),
+ ).toBeDefined();
+ });
+
+ it("renders the build plan accordion with steps", () => {
+ render(
+ ,
+ );
+ 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(
+ ,
+ );
+ expect(screen.getByText("AI Text Generator")).toBeDefined();
+ });
+
+ it("shows approve and modify buttons when requires_approval and isLastMessage", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Modify")).toBeDefined();
+ expect(screen.getByText(/Starting in/)).toBeDefined();
+ });
+
+ it("hides action buttons when isLastMessage is false", () => {
+ render(
+ ,
+ );
+ 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(
+ ,
+ );
+ expect(screen.queryByText("Modify")).toBeNull();
+ });
+
+ it("disables buttons while message is still streaming", () => {
+ render(
+ ,
+ );
+ const modifyBtn = screen.getByText("Modify").closest("button");
+ expect(modifyBtn?.disabled).toBe(true);
+ });
+
+ it("sends approval message when approve button is clicked", async () => {
+ render(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ fireEvent.click(screen.getByText("Modify"));
+
+ await waitFor(() => {
+ expect(postV2CancelAutoApproveTask).toHaveBeenCalledWith(
+ "test-session-1",
+ );
+ });
+ });
+
+ it("allows editing step descriptions in edit mode", async () => {
+ render(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ expect(screen.getByText("60")).toBeDefined();
+ vi.useRealTimers();
+ });
+
+ it("renders nothing pending when output is not yet available", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector(".py-2")).toBeDefined();
+ });
+});
diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/tools/DecomposeGoal/components/__tests__/StepItem.test.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/tools/DecomposeGoal/components/__tests__/StepItem.test.tsx
new file mode 100644
index 0000000000..fc59b3311c
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/copilot/tools/DecomposeGoal/components/__tests__/StepItem.test.tsx
@@ -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(
+ ,
+ );
+ expect(screen.getByText("1. Add input block")).toBeDefined();
+ });
+
+ it("renders block name when provided", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("AI Text Generator")).toBeDefined();
+ });
+
+ it("does not render block name when null", () => {
+ render(
+ ,
+ );
+ expect(screen.queryByText("AI Text Generator")).toBeNull();
+ });
+
+ it("renders pending icon by default", () => {
+ render();
+ expect(screen.getByLabelText("pending")).toBeDefined();
+ });
+
+ it("renders completed icon for completed status", () => {
+ render();
+ expect(screen.getByLabelText("completed")).toBeDefined();
+ });
+
+ it("renders in-progress icon for in_progress status", () => {
+ render();
+ expect(screen.getByLabelText("in progress")).toBeDefined();
+ });
+
+ it("renders failed icon for failed status", () => {
+ render();
+ expect(screen.getByLabelText("failed")).toBeDefined();
+ });
+
+ it("uses zero-based index to render 1-based step number", () => {
+ render();
+ expect(screen.getByText("5. Fifth step")).toBeDefined();
+ });
+});