diff --git a/canvas/src/components/tabs/ConfigTab.tsx b/canvas/src/components/tabs/ConfigTab.tsx index 645edc25e..305871eb3 100644 --- a/canvas/src/components/tabs/ConfigTab.tsx +++ b/canvas/src/components/tabs/ConfigTab.tsx @@ -19,6 +19,70 @@ interface Props { workspaceId: string; } +// --- Agent Abilities Section --- + +function AgentAbilitiesSection({ workspaceId }: { workspaceId: string }) { + const node = useCanvasStore((s) => s.nodes?.find?.((n) => n.id === workspaceId)); + const broadcastEnabled = (node?.data as Record)?.broadcastEnabled as boolean | undefined; + const talkToUserEnabled = (node?.data as Record)?.talkToUserEnabled as boolean | undefined; + + const [saving, setSaving] = useState<"broadcast" | "talk" | null>(null); + const [error, setError] = useState<"broadcast" | "talk" | null>(null); + const [success, setSuccess] = useState<"broadcast" | "talk" | null>(null); + + const handleToggle = async (field: "broadcast" | "talk", newValue: boolean) => { + setError(null); + setSaving(field); + const bodyKey = field === "broadcast" ? "broadcast_enabled" : "talk_to_user_enabled"; + try { + await api.patch(`/workspaces/${workspaceId}/abilities`, { [bodyKey]: newValue }); + useCanvasStore.getState().updateNodeData(workspaceId, { + [field === "broadcast" ? "broadcastEnabled" : "talkToUserEnabled"]: newValue, + } as Record); + setSuccess(field); + setTimeout(() => setSuccess(null), 2000); + } catch { + setError(field); + setTimeout(() => setError(null), 3000); + } finally { + setSaving(null); + } + }; + + return ( +
+
+
+ handleToggle("broadcast", v)} + /> +

+ When enabled the workspace can broadcast to every other workspace in the org. +

+
+
+ handleToggle("talk", v)} + /> +

+ When disabled the agent cannot reach the user in Chat. Useful for headless / research-only workspaces. +

+
+ {success && ( +
Updated
+ )} + {error && ( +
Failed to update
+ )} +
+
+ ); +} + // --- Agent Card Section --- function AgentCardSection({ workspaceId }: { workspaceId: string }) { @@ -885,6 +949,8 @@ export function ConfigTab({ workspaceId }: Props) { )} + + {/* Claude Settings — shown for claude-code runtime or claude/anthropic model names */} {(config.runtime === "claude-code" || (config.runtime_config?.model || config.model || "").toLowerCase().includes("claude") || diff --git a/canvas/src/components/tabs/__tests__/ConfigTab.abilities.test.tsx b/canvas/src/components/tabs/__tests__/ConfigTab.abilities.test.tsx new file mode 100644 index 000000000..d130485b0 --- /dev/null +++ b/canvas/src/components/tabs/__tests__/ConfigTab.abilities.test.tsx @@ -0,0 +1,218 @@ +// @vitest-environment jsdom +/** + * Tests for AgentAbilitiesSection — two toggles for broadcast_enabled and + * talk_to_user_enabled on PATCH /workspaces/:id/abilities. + * + * Covers: + * - Section is always visible (no runtime gate) + * - Both toggles reflect the store defaults (broadcast OFF / talk ON) + * - Each toggle fires the correct PATCH body and optimistically updates the store + * - Success and error banners + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; +import React from "react"; + +afterEach(cleanup); + +// ── @/lib/api mock ──────────────────────────────────────────────────────────── + +const apiGet = vi.fn(); +const apiPatch = vi.fn(); +vi.mock("@/lib/api", () => ({ + api: { + get: (path: string) => apiGet(path), + patch: (...args: unknown[]) => apiPatch(...args), + put: vi.fn(), + post: vi.fn(), + del: vi.fn(), + }, +})); + +// ── @/store/canvas mock ─────────────────────────────────────────────────────── + +const storeUpdateNodeData = vi.fn(); +const storeRestartWorkspace = vi.fn(); +vi.mock("@/store/canvas", () => ({ + useCanvasStore: Object.assign( + (selector: (s: unknown) => unknown) => + selector({ + nodes: [ + { + id: "ws-test", + data: { + broadcastEnabled: false, + talkToUserEnabled: true, + }, + }, + ], + restartWorkspace: storeRestartWorkspace, + updateNodeData: storeUpdateNodeData, + }), + { + getState: () => ({ + nodes: [ + { + id: "ws-test", + data: { + broadcastEnabled: false, + talkToUserEnabled: true, + }, + }, + ], + restartWorkspace: storeRestartWorkspace, + updateNodeData: storeUpdateNodeData, + }), + }, + ), +})); + +// ── Section / component stubs ────────────────────────────────────────────────── + +vi.mock("../ExternalConnectionSection", () => ({ + ExternalConnectionSection: () =>
, +})); + +vi.mock("./config/secrets-section", () => ({ + SecretsSection: () =>
, +})); + +// ── ConfigTab ───────────────────────────────────────────────────────────────── + +import { ConfigTab } from "../ConfigTab"; + +beforeEach(() => { + apiGet.mockReset(); + apiPatch.mockReset(); + storeUpdateNodeData.mockReset(); + + apiGet.mockImplementation((path: string) => { + if (path === "/workspaces/ws-test") return Promise.resolve({ runtime: "langgraph" }); + if (path === "/workspaces/ws-test/model") return Promise.resolve({}); + if (path === "/workspaces/ws-test/provider") return Promise.resolve({}); + if (path === "/workspaces/ws-test/files/config.yaml") + return Promise.resolve({ content: "name: test\nruntime: langgraph\n" }); + if (path === "/templates") + return Promise.resolve([{ id: "langgraph", name: "LangGraph", runtime: "langgraph", providers: [] }]); + return Promise.reject(new Error(`unmocked api.get: ${path}`)); + }); + + apiPatch.mockResolvedValue({ status: "updated" }); +}); + +describe("AgentAbilitiesSection", () => { + it("renders the section even when runtime is not claude-code", async () => { + render(); + await waitFor(() => { + expect(screen.queryByRole("button", { name: /Agent Abilities/i })).not.toBeNull(); + }); + }); + + it("renders both toggles with the correct initial checked states", async () => { + render(); + await waitFor(() => { + expect(screen.queryByRole("button", { name: /Agent Abilities/i })).not.toBeNull(); + }); + + // Confirm Agent Abilities section button is in the DOM + const sectionButton = screen.getByRole("button", { name: /Agent Abilities/i }); + expect(sectionButton).not.toBeNull(); + + // Section is defaultOpen=true; check aria-expanded to confirm open + expect(sectionButton.getAttribute("aria-expanded")).toBe("true"); + + // Find toggles by label text (no click needed — section is already open) + const broadcastLabel = screen.getByText(/Broadcast — agent may send org-wide messages/); + const talkLabel = screen.getByText(/Talk to User — agent may send chat messages to the canvas/); + + const broadcastInput = broadcastLabel.previousElementSibling as HTMLInputElement; + const talkInput = talkLabel.previousElementSibling as HTMLInputElement; + + expect(broadcastInput.type).toBe("checkbox"); + expect(broadcastInput.checked).toBe(false); // store default: broadcast OFF + expect(talkInput.checked).toBe(true); // store default: talk ON + }); + + it("PATCHes broadcast_enabled: true when the broadcast toggle is switched on", async () => { + render(); + await waitFor(() => { + expect(screen.queryByRole("button", { name: /Agent Abilities/i })).not.toBeNull(); + }); + + const broadcastLabel = screen.getByText(/Broadcast — agent may send org-wide messages/); + const broadcastInput = broadcastLabel.previousElementSibling as HTMLInputElement; + fireEvent.click(broadcastInput); + + await waitFor(() => { + expect(apiPatch).toHaveBeenCalledWith("/workspaces/ws-test/abilities", { + broadcast_enabled: true, + }); + }); + }); + + it("PATCHes talk_to_user_enabled: false when the talk toggle is switched off", async () => { + render(); + await waitFor(() => { + expect(screen.queryByRole("button", { name: /Agent Abilities/i })).not.toBeNull(); + }); + + const talkLabel = screen.getByText(/Talk to User — agent may send chat messages to the canvas/); + const talkInput = talkLabel.previousElementSibling as HTMLInputElement; + fireEvent.click(talkInput); + + await waitFor(() => { + expect(apiPatch).toHaveBeenCalledWith("/workspaces/ws-test/abilities", { + talk_to_user_enabled: false, + }); + }); + }); + + it("optimistically updates the store on a successful PATCH", async () => { + render(); + await waitFor(() => { + expect(screen.queryByRole("button", { name: /Agent Abilities/i })).not.toBeNull(); + }); + + const broadcastLabel = screen.getByText(/Broadcast — agent may send org-wide messages/); + const broadcastInput = broadcastLabel.previousElementSibling as HTMLInputElement; + fireEvent.click(broadcastInput); + + await waitFor(() => { + expect(storeUpdateNodeData).toHaveBeenCalledWith("ws-test", { + broadcastEnabled: true, + }); + }); + }); + + it("shows a success banner after a successful update", async () => { + render(); + await waitFor(() => { + expect(screen.queryByRole("button", { name: /Agent Abilities/i })).not.toBeNull(); + }); + + const broadcastLabel = screen.getByText(/Broadcast — agent may send org-wide messages/); + const broadcastInput = broadcastLabel.previousElementSibling as HTMLInputElement; + fireEvent.click(broadcastInput); + + await waitFor(() => { + expect(screen.getByText("Updated")).toBeTruthy(); + }); + }); + + it("shows an error banner when the PATCH fails", async () => { + apiPatch.mockRejectedValueOnce(new Error("server error")); + + render(); + await waitFor(() => { + expect(screen.queryByRole("button", { name: /Agent Abilities/i })).not.toBeNull(); + }); + + const broadcastLabel = screen.getByText(/Broadcast — agent may send org-wide messages/); + const broadcastInput = broadcastLabel.previousElementSibling as HTMLInputElement; + fireEvent.click(broadcastInput); + + await waitFor(() => { + expect(screen.getByText("Failed to update")).toBeTruthy(); + }); + }); +});