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 e17f2c49ff..e6042e02bb 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 @@ -26,17 +26,11 @@ interface Props { isGraphLoaded?: boolean; } -/** - * BuilderChatPanel renders a collapsible AI chat panel for the flow builder. - * All business logic lives in `useBuilderChatPanel`. - * - * `isGraphLoaded` controls when the seed message is sent to the AI — pass - * `true` only once the graph has finished loading so the AI receives full context. - */ export function BuilderChatPanel({ className, isGraphLoaded }: Props) { const { isOpen, handleToggle, + retrySession, messages, stop, error, @@ -99,7 +93,7 @@ export function BuilderChatPanel({ className, isGraphLoaded }: Props) { parsedActions={parsedActions} appliedActionKeys={appliedActionKeys} onApplyAction={handleApplyAction} - onRetry={handleToggle} + onRetry={retrySession} seedMessageId={seedMessageId} messagesEndRef={messagesEndRef} /> @@ -220,11 +214,7 @@ function MessageList({

Failed to start chat session.

)} @@ -327,17 +320,15 @@ function MessageList({ function ActionItem({ action, - nodes, + nodeMap, isApplied, onApply, }: { action: GraphAction; - nodes: CustomNode[]; + nodeMap: Map; isApplied: boolean; onApply: (action: GraphAction) => void; }) { - const nodeMap = new Map(nodes.map((n) => [n.id, n])); - const label = action.type === "update_node_input" ? `Set "${getNodeDisplayName(nodeMap.get(action.nodeId), action.nodeId)}" "${action.key}" = ${JSON.stringify(action.value)}` 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 2005c4dde0..ca66bf827d 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 @@ -32,10 +32,9 @@ function makeMockHook( return { isOpen: false, handleToggle: vi.fn(), + retrySession: vi.fn(), messages: [], - sendMessage: vi.fn(), stop: vi.fn(), - status: "ready", error: undefined, isCreatingSession: false, sessionError: false, @@ -335,12 +334,15 @@ describe("BuilderChatPanel", () => { }); it("shows session error message with Retry when sessionError is true", () => { + const retrySession = vi.fn(); mockUseBuilderChatPanel.mockReturnValue( - makeMockHook({ isOpen: true, sessionError: true }), + makeMockHook({ isOpen: true, sessionError: true, retrySession }), ); render(); expect(screen.getByText(/Failed to start chat session/i)).toBeDefined(); expect(screen.getByText("Retry")).toBeDefined(); + fireEvent.click(screen.getByText("Retry")); + expect(retrySession).toHaveBeenCalledOnce(); }); it("renders the panel with role=dialog and message list with role=log", () => { @@ -751,9 +753,15 @@ describe("buildSeedPrompt", () => { expect(result).toContain('"action": "connect_nodes"'); }); - it("ends with a question to prompt an AI response", () => { + it("ends with a prompt inviting the user to interact", () => { const result = buildSeedPrompt(""); - expect(result.trim().endsWith("What does this agent do?")).toBe(true); + expect( + result + .trim() + .endsWith( + "Ask me what you'd like to know about or change in this agent.", + ), + ).toBe(true); }); }); diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/__tests__/useBuilderChatPanel.test.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/__tests__/useBuilderChatPanel.test.ts index 5ba08e617e..56a9833ce1 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/__tests__/useBuilderChatPanel.test.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/__tests__/useBuilderChatPanel.test.ts @@ -60,12 +60,14 @@ vi.mock("@/components/molecules/Toast/use-toast", () => ({ const mockSendMessage = vi.fn(); const mockStop = vi.fn(); +let mockChatMessages: unknown[] = []; +let mockChatStatus = "ready"; vi.mock("@ai-sdk/react", () => ({ useChat: () => ({ - messages: [], + messages: mockChatMessages, sendMessage: mockSendMessage, stop: mockStop, - status: "ready", + status: mockChatStatus, error: undefined, }), })); @@ -91,6 +93,8 @@ beforeEach(() => { mockFlowID = null; mockNodes.length = 0; mockEdges.length = 0; + mockChatMessages = []; + mockChatStatus = "ready"; mockUpdateNodeData.mockClear(); mockAddEdge.mockClear(); mockRemoveEdge.mockClear(); @@ -306,8 +310,8 @@ describe("useBuilderChatPanel – flowID reset", () => { }); }); -describe("useBuilderChatPanel – cache invalidation", () => { - it("invalidates graph query cache after applying an update_node_input action", () => { +describe("useBuilderChatPanel – apply does not trigger cache refetch", () => { + it("does NOT call invalidateQueries after applying an update_node_input action (prevents refetch overwriting local state)", () => { mockNodes.push({ id: "n1", data: { hardcodedValues: { existing: "val" } }, @@ -325,44 +329,6 @@ describe("useBuilderChatPanel – cache invalidation", () => { }); }); - expect(mockInvalidateQueries).toHaveBeenCalledWith({ - queryKey: ["graphs", "flow-cache"], - }); - }); - - it("invalidates graph query cache after applying a connect_nodes action", () => { - mockNodes.push({ id: "src", data: {} }, { id: "tgt", data: {} }); - mockFlowID = "flow-edges"; - - const { result } = renderHook(() => useBuilderChatPanel()); - - act(() => { - result.current.handleApplyAction({ - type: "connect_nodes", - source: "src", - target: "tgt", - sourceHandle: "out", - targetHandle: "in", - }); - }); - - expect(mockInvalidateQueries).toHaveBeenCalledWith({ - queryKey: ["graphs", "flow-edges"], - }); - }); - - it("does NOT invalidate cache when validation fails (node not found)", () => { - const { result } = renderHook(() => useBuilderChatPanel()); - - act(() => { - result.current.handleApplyAction({ - type: "update_node_input", - nodeId: "nonexistent", - key: "query", - value: "test", - }); - }); - expect(mockInvalidateQueries).not.toHaveBeenCalled(); }); }); @@ -693,3 +659,162 @@ describe("useBuilderChatPanel – undo", () => { expect(result.current.appliedActionKeys.size).toBe(0); }); }); + +describe("useBuilderChatPanel – parsedActions integration", () => { + it("returns parsed actions from assistant messages when status is ready", () => { + mockChatMessages = [ + { + id: "msg-1", + role: "assistant", + parts: [ + { + type: "text", + text: '```json\n{"action":"update_node_input","node_id":"n1","key":"query","value":"AI news"}\n```', + }, + ], + }, + ]; + mockChatStatus = "ready"; + + const { result } = renderHook(() => useBuilderChatPanel()); + + expect(result.current.parsedActions).toHaveLength(1); + expect(result.current.parsedActions[0]).toEqual({ + type: "update_node_input", + nodeId: "n1", + key: "query", + value: "AI news", + }); + }); + + it("returns empty parsedActions when status is streaming", () => { + mockChatMessages = [ + { + id: "msg-1", + role: "assistant", + parts: [ + { + type: "text", + text: '```json\n{"action":"update_node_input","node_id":"n1","key":"query","value":"AI news"}\n```', + }, + ], + }, + ]; + mockChatStatus = "streaming"; + + const { result } = renderHook(() => useBuilderChatPanel()); + + expect(result.current.parsedActions).toHaveLength(0); + }); + + it("deduplicates identical actions from multiple assistant messages", () => { + const actionBlock = + '```json\n{"action":"update_node_input","node_id":"n1","key":"query","value":"AI news"}\n```'; + mockChatMessages = [ + { + id: "msg-1", + role: "assistant", + parts: [{ type: "text", text: actionBlock }], + }, + { + id: "msg-2", + role: "assistant", + parts: [{ type: "text", text: actionBlock }], + }, + ]; + mockChatStatus = "ready"; + + const { result } = renderHook(() => useBuilderChatPanel()); + + expect(result.current.parsedActions).toHaveLength(1); + }); +}); + +describe("useBuilderChatPanel – Escape key handler", () => { + it("closes the panel when Escape is pressed while open", () => { + const { result } = renderHook(() => useBuilderChatPanel()); + + act(() => { + result.current.handleToggle(); + }); + expect(result.current.isOpen).toBe(true); + + act(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + }); + expect(result.current.isOpen).toBe(false); + }); + + it("does not error when Escape is pressed while panel is closed", () => { + const { result } = renderHook(() => useBuilderChatPanel()); + expect(result.current.isOpen).toBe(false); + + act(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + }); + + expect(result.current.isOpen).toBe(false); + }); +}); + +describe("useBuilderChatPanel – retrySession", () => { + it("clears sessionError so the session-creation effect can re-run", async () => { + mockPostV2CreateSession.mockRejectedValueOnce(new Error("network error")); + + const { result } = renderHook(() => useBuilderChatPanel()); + + await openAndFlush(() => result.current.handleToggle()); + expect(result.current.sessionError).toBe(true); + + mockPostV2CreateSession.mockResolvedValue({ + status: 200, + data: { id: "sess-retry" }, + }); + + await act(async () => { + result.current.retrySession(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(result.current.sessionError).toBe(false); + expect(result.current.sessionId).toBe("sess-retry"); + }); +}); + +describe("useBuilderChatPanel – handleSend", () => { + it("clears inputValue after sending when session is ready", async () => { + mockPostV2CreateSession.mockResolvedValue({ + status: 200, + data: { id: "sess-send" }, + }); + + const { result } = renderHook(() => useBuilderChatPanel()); + + await openAndFlush(() => result.current.handleToggle()); + + act(() => { + result.current.setInputValue("hello world"); + }); + + act(() => { + result.current.handleSend(); + }); + + expect(result.current.inputValue).toBe(""); + expect(mockSendMessage).toHaveBeenCalledWith({ text: "hello world" }); + }); + + it("does not send when inputValue is whitespace only", () => { + const { result } = renderHook(() => useBuilderChatPanel()); + + act(() => { + result.current.setInputValue(" "); + }); + + act(() => { + result.current.handleSend(); + }); + + expect(mockSendMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/helpers.ts index 1763b7c64d..fc1593028e 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/helpers.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/helpers.ts @@ -124,8 +124,8 @@ export function buildSeedPrompt(summary: string): string { `To add a connection between nodes:\n` + `\`\`\`json\n{"action": "connect_nodes", "source": "", "target": "", "source_handle": "", "target_handle": ""}\n\`\`\`\n\n` + `Rules: the "action" key is required and must be exactly "update_node_input" or "connect_nodes". ` + - `Do not use any other field names (e.g. "block", "change", "field", "from", "to" are NOT valid).\n\n` + - `What does this agent do?` + `Do not use any other field names (e.g. "block", "change", "field", "from", "to" are NOT valid). ` + + `Ask me what you'd like to know about or change in this agent.` ); } diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/useBuilderChatPanel.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/useBuilderChatPanel.ts index a7283a5b1d..86e28a5b5a 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/useBuilderChatPanel.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderChatPanel/useBuilderChatPanel.ts @@ -1,14 +1,11 @@ import { postV2CreateSession } from "@/app/api/__generated__/endpoints/chat/chat"; -import { getGetV1GetSpecificGraphQueryKey } from "@/app/api/__generated__/endpoints/graphs/graphs"; import { getWebSocketToken } from "@/lib/supabase/actions"; import { environment } from "@/services/environment"; import { useToast } from "@/components/molecules/Toast/use-toast"; -import { useQueryClient } from "@tanstack/react-query"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; import { type KeyboardEvent, - useCallback, useEffect, useMemo, useRef, @@ -41,15 +38,6 @@ interface UseBuilderChatPanelArgs { isGraphLoaded?: boolean; } -/** - * useBuilderChatPanel manages all business logic for the collapsible AI chat - * panel in the flow builder. It owns session lifecycle, streaming transport, - * graph context serialization, action parsing, apply/undo, and input handling. - * - * @param isGraphLoaded - When true the seed message is sent automatically. - * Pass false (the default) until the graph has finished loading to avoid - * sending an empty context to the AI. - */ export function useBuilderChatPanel({ isGraphLoaded = false, }: UseBuilderChatPanelArgs = {}) { @@ -72,7 +60,6 @@ export function useBuilderChatPanel({ const isCreatingSessionRef = useRef(false); const [{ flowID }] = useQueryStates({ flowID: parseAsString }); - const queryClient = useQueryClient(); const { toast } = useToast(); const nodes = useNodeStore(useShallow((s) => s.nodes)); @@ -233,13 +220,16 @@ export function useBuilderChatPanel({ Boolean(sessionId) && !isCreatingSession && !sessionError && !isStreaming; function handleToggle() { - // Reset session error when reopening so the panel can retry session creation - if (!isOpen && !sessionId) { - setSessionError(false); - } setIsOpen((o) => !o); } + // Resets session error state so the session-creation effect re-runs on + // the next render without toggling the panel closed and back open. + function retrySession() { + setSessionError(false); + isCreatingSessionRef.current = false; + } + function handleSend() { const text = inputValue.trim(); if (!text || !canSend) return; @@ -276,8 +266,10 @@ export function useBuilderChatPanel({ }); return; } - // Capture a snapshot before mutating so we can undo. - const prevHardcoded = node.data.hardcodedValues; + // Deep-clone before mutating so sequential applies to the same node + // each capture an independent snapshot — without this, the reference + // would point to the same object after mutation. + const prevHardcoded = structuredClone(node.data.hardcodedValues); const key = getActionKey(action); setUndoStack((prev) => [ ...prev, @@ -361,29 +353,23 @@ export function useBuilderChatPanel({ return _; } setAppliedActionKeys((prev) => new Set([...prev, getActionKey(action)])); - if (flowID) { - queryClient.invalidateQueries({ - queryKey: getGetV1GetSpecificGraphQueryKey(flowID), - }); - } } - const handleUndoLastAction = useCallback(() => { + function handleUndoLastAction() { setUndoStack((prev) => { if (prev.length === 0) return prev; const last = prev[prev.length - 1]; last.restore(); return prev.slice(0, -1); }); - }, []); + } return { isOpen, handleToggle, + retrySession, messages, - sendMessage, stop, - status, error, isCreatingSession, sessionError,