diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/BuilderChatPanel.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/BuilderChatPanel.tsx index cd763c5756..c38772a145 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/BuilderChatPanel.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/BuilderChatPanel.tsx @@ -34,6 +34,8 @@ export function BuilderChatPanel({ className, isGraphLoaded }: Props) { sessionId, nodes, parsedActions, + appliedActionKeys, + handleApplyAction, seedMessageId, } = useBuilderChatPanel({ isGraphLoaded }); @@ -91,6 +93,8 @@ export function BuilderChatPanel({ className, isGraphLoaded }: Props) { streamError={error} nodes={nodes} parsedActions={parsedActions} + appliedActionKeys={appliedActionKeys} + onApplyAction={handleApplyAction} seedMessageId={seedMessageId} messagesEndRef={messagesEndRef} /> @@ -147,6 +151,8 @@ interface MessageListProps { streamError: Error | undefined; nodes: CustomNode[]; parsedActions: GraphAction[]; + appliedActionKeys: Set; + onApplyAction: (action: GraphAction) => void; seedMessageId: string | null; messagesEndRef: React.RefObject; } @@ -158,6 +164,8 @@ function MessageList({ streamError, nodes, parsedActions, + appliedActionKeys, + onApplyAction, seedMessageId, messagesEndRef, }: MessageListProps) { @@ -246,14 +254,17 @@ function MessageList({ {parsedActions.length > 0 && (

- AI applied these changes + Suggested changes

{parsedActions.map((action) => { + const key = getActionKey(action); return ( ); })} @@ -268,9 +279,13 @@ function MessageList({ function ActionItem({ action, nodes, + isApplied, + onApply, }: { action: GraphAction; nodes: CustomNode[]; + isApplied: boolean; + onApply: (action: GraphAction) => void; }) { const nodeName = (id: string) => nodes.find((n) => n.id === id)?.data.metadata?.customized_name || @@ -285,9 +300,18 @@ function ActionItem({ return (
{label} - - Applied - + {isApplied ? ( + + Applied + + ) : ( + + )}
); } diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/__tests__/BuilderChatPanel.test.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/__tests__/BuilderChatPanel.test.tsx index 5c0401c410..64f01fb8e7 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/__tests__/BuilderChatPanel.test.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/__tests__/BuilderChatPanel.test.tsx @@ -39,6 +39,7 @@ function makeMockHook( sessionId: null, nodes: [], parsedActions: [], + appliedActionKeys: new Set(), handleApplyAction: vi.fn(), seedMessageId: null, ...overrides, @@ -119,7 +120,7 @@ describe("BuilderChatPanel", () => { expect(screen.getByText("This agent searches the web.")).toBeDefined(); }); - it("renders applied actions section when parsedActions are present", () => { + it("renders suggested changes section when parsedActions are present", () => { mockUseBuilderChatPanel.mockReturnValue( makeMockHook({ isOpen: true, @@ -134,11 +135,11 @@ describe("BuilderChatPanel", () => { }), ); render(); - expect(screen.getByText("AI applied these changes")).toBeDefined(); - expect(screen.getByText("Applied")).toBeDefined(); + expect(screen.getByText("Suggested changes")).toBeDefined(); + expect(screen.getByText("Apply")).toBeDefined(); }); - it("shows applied badge for actions", () => { + it("shows Apply button for unapplied actions and Applied badge for applied actions", () => { const action = { type: "update_node_input" as const, nodeId: "1", @@ -149,10 +150,32 @@ describe("BuilderChatPanel", () => { makeMockHook({ isOpen: true, parsedActions: [action], + appliedActionKeys: new Set(["1:query"]), }), ); render(); expect(screen.getByText("Applied")).toBeDefined(); + expect(screen.queryByText("Apply")).toBeNull(); + }); + + it("calls handleApplyAction when Apply button is clicked", () => { + const handleApplyAction = vi.fn(); + const action = { + type: "update_node_input" as const, + nodeId: "1", + key: "query", + value: "AI news", + }; + mockUseBuilderChatPanel.mockReturnValue( + makeMockHook({ + isOpen: true, + parsedActions: [action], + handleApplyAction, + }), + ); + render(); + fireEvent.click(screen.getByText("Apply")); + expect(handleApplyAction).toHaveBeenCalledWith(action); }); it("calls sendMessage when the user submits a message", () => { diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/__tests__/helpers.test.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/__tests__/helpers.test.ts new file mode 100644 index 0000000000..373971cd5b --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/__tests__/helpers.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { + buildSeedPrompt, + extractTextFromParts, + serializeGraphForChat, +} from "../helpers"; +import type { CustomNode } from "../../FlowEditor/nodes/CustomNode/CustomNode"; + +describe("extractTextFromParts", () => { + it("returns empty string for empty array", () => { + expect(extractTextFromParts([])).toBe(""); + }); + + it("concatenates text parts in order", () => { + const parts = [ + { type: "text", text: "Hello, " }, + { type: "text", text: "world!" }, + ]; + expect(extractTextFromParts(parts)).toBe("Hello, world!"); + }); + + it("ignores non-text parts", () => { + const parts = [ + { type: "text", text: "visible" }, + { type: "tool-call", text: "ignored" }, + { type: "text", text: " text" }, + ]; + expect(extractTextFromParts(parts)).toBe("visible text"); + }); + + it("returns empty string when all parts are non-text", () => { + const parts = [{ type: "tool-result" }, { type: "image" }]; + expect(extractTextFromParts(parts)).toBe(""); + }); +}); + +describe("buildSeedPrompt", () => { + it("wraps the summary in tags", () => { + const result = buildSeedPrompt("some graph summary"); + expect(result).toContain( + "\nsome graph summary\n", + ); + }); + + it("includes instructions for update_node_input format", () => { + const result = buildSeedPrompt(""); + expect(result).toContain('"action": "update_node_input"'); + }); + + it("includes instructions for connect_nodes format", () => { + const result = buildSeedPrompt(""); + expect(result).toContain('"action": "connect_nodes"'); + }); + + it("ends with a question to prompt AI response", () => { + const result = buildSeedPrompt(""); + expect(result.trim().endsWith("What does this agent do?")).toBe(true); + }); +}); + +describe("serializeGraphForChat – XML injection prevention", () => { + it("escapes < and > in node names before embedding in prompt", () => { + const nodes = [ + { + id: "1", + data: { + title: "", + description: "", + hardcodedValues: {}, + inputSchema: {}, + outputSchema: {}, + uiType: 1, + block_id: "b1", + costs: [], + categories: [], + }, + type: "custom" as const, + position: { x: 0, y: 0 }, + }, + ] as unknown as CustomNode[]; + + const result = serializeGraphForChat(nodes, []); + expect(result).not.toContain("