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(); + }); +});