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 84da305308..9d24d1e6db 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 @@ -74,10 +74,9 @@ export function BuilderChatPanel({ className, isGraphLoaded }: Props) { > {isOpen && (
)} - {visibleMessages.length === 0 && !isCreatingSession && !sessionError && ( -
- -

Ask me to explain or modify your agent.

-

- You can say things like “What does this agent do?” or - “Add a step that formats the output.” -

-
- )} + {visibleMessages.length === 0 && + !isCreatingSession && + !sessionError && + !messages.some((m) => m.id === seedMessageId) && ( +
+ +

Ask me to explain or modify your agent.

+

+ You can say things like “What does this agent do?” or + “Add a step that formats the output.” +

+
+ )} {visibleMessages.length === 0 && messages.some((m) => m.id === seedMessageId) && ( @@ -281,6 +287,16 @@ function MessageList({ {children} ), + a: ({ href, children }) => ( + + {children} + + ), }} > {textParts} 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 24be65656b..b491d504cf 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 @@ -345,10 +345,10 @@ describe("BuilderChatPanel", () => { expect(retrySession).toHaveBeenCalledOnce(); }); - it("renders the panel with role=dialog and message list with role=log", () => { + it("renders the panel with role=complementary and message list with role=log", () => { mockUseBuilderChatPanel.mockReturnValue(makeMockHook({ isOpen: true })); render(); - expect(screen.getByRole("dialog")).toBeDefined(); + expect(screen.getByRole("complementary")).toBeDefined(); expect(screen.getByRole("log")).toBeDefined(); }); 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 f1e0f22cc1..53ffffdf6a 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 @@ -44,10 +44,6 @@ vi.mock("@/app/api/__generated__/endpoints/chat/chat", () => ({ postV2CreateSession: (...args: unknown[]) => mockPostV2CreateSession(...args), })); -vi.mock("@/app/api/__generated__/endpoints/graphs/graphs", () => ({ - getGetV1GetSpecificGraphQueryKey: (id: string) => ["graphs", id], -})); - vi.mock("@/lib/supabase/actions", () => ({ getWebSocketToken: vi.fn().mockResolvedValue({ token: "tok", error: null }), })); @@ -409,6 +405,7 @@ describe("useBuilderChatPanel – handleApplyAction", () => { sourceHandle: "output", targetHandle: "input", type: "custom", + markerEnd: expect.objectContaining({ type: "arrowclosed" }), }), ]), ); @@ -581,6 +578,7 @@ describe("useBuilderChatPanel – handleApplyAction", () => { sourceHandle: "result", targetHandle: "input", type: "custom", + markerEnd: expect.objectContaining({ type: "arrowclosed" }), }), ]), ); @@ -1148,3 +1146,113 @@ describe("useBuilderChatPanel – handleUndoLastAction on empty stack", () => { expect(result.current.undoStack).toHaveLength(0); }); }); + +describe("useBuilderChatPanel – transport prepareSendMessagesRequest", () => { + it("calls getWebSocketToken and returns correct request body", async () => { + const { getWebSocketToken } = await import("@/lib/supabase/actions"); + const { DefaultChatTransport } = await import("ai"); + const MockTransport = DefaultChatTransport as ReturnType; + + mockPostV2CreateSession.mockResolvedValue({ + status: 200, + data: { id: "sess-transport" }, + }); + + const { result } = renderHook(() => useBuilderChatPanel()); + + await openAndFlush(() => result.current.handleToggle()); + + expect(MockTransport).toHaveBeenCalled(); + const ctorArg = MockTransport.mock.calls[ + MockTransport.mock.calls.length - 1 + ][0] as { + prepareSendMessagesRequest: (args: { + messages: unknown[]; + }) => Promise; + }; + expect(typeof ctorArg.prepareSendMessagesRequest).toBe("function"); + + const messages = [ + { role: "user", parts: [{ type: "text", text: "hello" }] }, + ]; + const req = await ctorArg.prepareSendMessagesRequest({ messages }); + + expect(getWebSocketToken).toHaveBeenCalled(); + expect(req).toMatchObject({ + body: { message: "hello", is_user_message: true }, + headers: { Authorization: "Bearer tok" }, + }); + }); + + it("throws when getWebSocketToken returns null token", async () => { + const { getWebSocketToken } = await import("@/lib/supabase/actions"); + const { DefaultChatTransport } = await import("ai"); + const MockTransport = DefaultChatTransport as ReturnType; + + vi.mocked(getWebSocketToken).mockResolvedValueOnce({ + token: null, + error: "auth failed", + }); + + mockPostV2CreateSession.mockResolvedValue({ + status: 200, + data: { id: "sess-auth-fail" }, + }); + + const { result } = renderHook(() => useBuilderChatPanel()); + + await openAndFlush(() => result.current.handleToggle()); + + const ctorArg = MockTransport.mock.calls[ + MockTransport.mock.calls.length - 1 + ][0] as { + prepareSendMessagesRequest: (args: { + messages: unknown[]; + }) => Promise; + }; + const messages = [{ role: "user", parts: [{ type: "text", text: "hi" }] }]; + await expect( + ctorArg.prepareSendMessagesRequest({ messages }), + ).rejects.toThrow("Authentication failed"); + }); +}); + +describe("useBuilderChatPanel – handleKeyDown empty input guard", () => { + it("does NOT call sendMessage on Enter when inputValue is empty", async () => { + mockPostV2CreateSession.mockResolvedValue({ + status: 200, + data: { id: "sess-empty" }, + }); + const { result } = renderHook(() => useBuilderChatPanel()); + + await openAndFlush(() => result.current.handleToggle()); + + const mockPreventDefault = vi.fn(); + act(() => { + result.current.handleKeyDown({ + key: "Enter", + shiftKey: false, + preventDefault: mockPreventDefault, + } as unknown as import("react").KeyboardEvent); + }); + + expect(mockSendMessage).not.toHaveBeenCalled(); + }); +}); + +describe("useBuilderChatPanel – inputValue resets on flowID change", () => { + it("clears inputValue when flowID changes", () => { + mockFlowID = "flow-a"; + const { result, rerender } = renderHook(() => useBuilderChatPanel()); + + act(() => { + result.current.setInputValue("typed text"); + }); + expect(result.current.inputValue).toBe("typed text"); + + mockFlowID = "flow-b"; + rerender(); + + expect(result.current.inputValue).toBe(""); + }); +}); 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 526fc6b52b..983a8df32d 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 @@ -7,8 +7,6 @@ const MAX_NODES = 100; const MAX_EDGES = 200; /** Maximum characters of a node description included in the seed prompt. */ const MAX_DESC_CHARS = 500; -/** Matches fenced JSON code blocks in AI responses. Module-scoped to avoid recompilation. */ -const JSON_BLOCK_REGEX = /```(?:json)?\s*\n?([\s\S]*?)\n?```/g; /** Escapes XML special characters in user-controlled strings before embedding in prompts. */ function sanitizeForXml(s: string): string { @@ -64,7 +62,7 @@ export function serializeGraphForChat( const name = sanitizeForXml(getNodeDisplayName(n, "")); const rawDesc = n.data.description?.slice(0, MAX_DESC_CHARS) ?? ""; const desc = rawDesc ? ` — ${sanitizeForXml(rawDesc)}` : ""; - return `- Node ${n.id}: "${name}"${desc}`; + return `- Node ${sanitizeForXml(n.id)}: "${name}"${desc}`; }); const truncationNote = @@ -82,7 +80,7 @@ export function serializeGraphForChat( const tgtName = sanitizeForXml( getNodeDisplayName(nodeMap.get(e.target), e.target), ); - return `- "${srcName}" (${e.sourceHandle}) → "${tgtName}" (${e.targetHandle})`; + return `- "${srcName}" (${sanitizeForXml(e.sourceHandle ?? "")}) → "${tgtName}" (${sanitizeForXml(e.targetHandle ?? "")})`; }); const edgeTruncationNote = @@ -168,7 +166,10 @@ export function extractTextFromParts( parts: ReadonlyArray<{ type: string; text?: string }> | null | undefined, ): string { return (parts ?? []) - .filter((p): p is { type: "text"; text: string } => p.type === "text") + .filter( + (p): p is { type: "text"; text: string } => + p.type === "text" && typeof p.text === "string", + ) .map((p) => p.text) .join(""); } @@ -186,10 +187,10 @@ export function extractTextFromParts( */ export function parseGraphActions(text: string): GraphAction[] { const actions: GraphAction[] = []; - JSON_BLOCK_REGEX.lastIndex = 0; + const jsonBlockRegex = /```(?:json)?\s*\n?([\s\S]*?)\n?```/g; let match: RegExpExecArray | null; - while ((match = JSON_BLOCK_REGEX.exec(text)) !== null) { + while ((match = jsonBlockRegex.exec(text)) !== null) { try { const parsed = JSON.parse(match[1]) as unknown; if ( 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 f1385340aa..fccd30e26c 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 @@ -66,8 +66,8 @@ export function useBuilderChatPanel({ const [{ flowID }] = useQueryStates({ flowID: parseAsString }); const { toast } = useToast(); - const nodes = useNodeStore(useShallow((s) => s.nodes)); - const edges = useEdgeStore(useShallow((s) => s.edges)); + const nodes = useNodeStore(useShallow((s) => (isOpen ? s.nodes : []))); + const edges = useEdgeStore(useShallow((s) => (isOpen ? s.edges : []))); const setNodes = useNodeStore((s) => s.setNodes); const setEdges = useEdgeStore((s) => s.setEdges); @@ -78,6 +78,7 @@ export function useBuilderChatPanel({ setSessionError(false); setAppliedActionKeys(new Set()); setUndoStack([]); + setInputValue(""); hasSentSeedMessageRef.current = false; // Also reset the creation ref so a new session can be started after // navigation, even if one was in-flight when flowID changed. @@ -101,7 +102,15 @@ export function useBuilderChatPanel({ const res = await postV2CreateSession(null); if (cancelled) return; if (res.status === 200) { - setSessionId(res.data.id); + const id = res.data.id; + // Validate the session ID is a safe non-empty identifier before + // interpolating it into the streaming URL — rejects values that + // contain path-traversal characters or whitespace. + if (typeof id !== "string" || !id || !/^[\w-]+$/i.test(id)) { + setSessionError(true); + return; + } + setSessionId(id); } else { setSessionError(true); } @@ -256,7 +265,10 @@ export function useBuilderChatPanel({ function handleApplyAction(action: GraphAction) { if (action.type === "update_node_input") { - const node = nodes.find((n) => n.id === action.nodeId); + // Read live state for both validation and mutation so rapid successive + // applies see the latest nodes rather than a stale render-cycle snapshot. + const liveNodes = useNodeStore.getState().nodes; + const node = liveNodes.find((n) => n.id === action.nodeId); if (!node) { toast({ title: "Cannot apply change", @@ -276,12 +288,15 @@ export function useBuilderChatPanel({ }); return; } - // Capture a full nodes snapshot before mutating. Both the apply and the - // restore use setNodes (not updateNodeData) to bypass the global history - // store — this keeps chat-panel changes completely separate from Ctrl+Z, - // preventing the "Applied" badge from going stale after a global undo. - const prevNodes = useNodeStore.getState().nodes; - const nextNodes = prevNodes.map((n) => + // Capture a shallow-copied nodes snapshot before mutating. Spreading + // ensures the undo restore references an independent array rather than + // the same reference that the store may update in-place. + // Both the apply and the restore use setNodes (not updateNodeData) to + // bypass the global history store — this keeps chat-panel changes + // completely separate from Ctrl+Z, preventing the "Applied" badge from + // going stale after a global undo. + const prevNodes = [...liveNodes]; + const nextNodes = liveNodes.map((n) => n.id === action.nodeId ? { ...n, @@ -313,8 +328,11 @@ export function useBuilderChatPanel({ }); setNodes(nextNodes); } else if (action.type === "connect_nodes") { - const sourceNode = nodes.find((n) => n.id === action.source); - const targetNode = nodes.find((n) => n.id === action.target); + // Read live state so validation reflects the current graph even when + // multiple actions are applied within the same render cycle. + const liveNodes = useNodeStore.getState().nodes; + const sourceNode = liveNodes.find((n) => n.id === action.source); + const targetNode = liveNodes.find((n) => n.id === action.target); if (!sourceNode || !targetNode) { toast({ title: "Cannot apply connection", @@ -343,10 +361,11 @@ export function useBuilderChatPanel({ return; } const edgeId = `${action.source}:${action.sourceHandle}->${action.target}:${action.targetHandle}`; - // Capture a full edges snapshot before mutating. Both the apply and the - // restore use setEdges (not addEdge/removeEdge) to bypass the global - // history store — keeps chat-panel changes separate from Ctrl+Z. - const prevEdges = useEdgeStore.getState().edges; + // Shallow-copy the edges snapshot so the undo restore references an + // independent array rather than the same reference the store may update. + // Both the apply and the restore use setEdges (not addEdge/removeEdge) + // to bypass the global history store — keeps chat-panel changes separate. + const prevEdges = [...useEdgeStore.getState().edges]; // Guard against duplicate edges — the same connection may appear after an // undo-then-reapply or from identical suggestions across AI messages. const alreadyExists = prevEdges.some( @@ -358,9 +377,11 @@ export function useBuilderChatPanel({ ); if (alreadyExists) { // Edge already present — mark as applied without duplicating it. - setAppliedActionKeys( - (prev) => new Set([...prev, getActionKey(action)]), - ); + setAppliedActionKeys((prev) => { + const next = new Set(prev); + next.add(getActionKey(action)); + return next; + }); return; } const key = getActionKey(action); @@ -402,7 +423,11 @@ export function useBuilderChatPanel({ const _: never = action; return _; } - setAppliedActionKeys((prev) => new Set([...prev, getActionKey(action)])); + setAppliedActionKeys((prev) => { + const next = new Set(prev); + next.add(getActionKey(action)); + return next; + }); } function handleUndoLastAction() {