From 527b6ca36b22308c4675a3da6062cffcb55125b2 Mon Sep 17 00:00:00 2001 From: core-fe Date: Mon, 18 May 2026 02:29:38 -0700 Subject: [PATCH] feat(canvas): always-visible Agent Abilities toggles in ConfigTab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The broadcast_enabled and talk_to_user_enabled workspace abilities have complete, wired backends (commit 29b4bffb: workspace_abilities.go, workspace_broadcast.go, agent_message_writer.go) but no usable canvas control — so the CTO cannot see or toggle them from the canvas. - broadcast_enabled (default FALSE): no canvas control existed at all. - talk_to_user_enabled (default TRUE): only surfaced as the ChatTab recovery banner, which renders solely when the flag is false and is therefore invisible under the TRUE default. Adds an always-visible "Agent Abilities" section to ConfigTab with two on/off toggles bound to the existing PATCH /workspaces/:id/abilities endpoint (same call the ChatTab recovery banner uses), optimistic store updates via updateNodeData with rollback on failure, and server-truth reconciliation through the existing canvas-topology hydration. The ChatTab recovery banner is left unchanged — the disabled-state recovery path is not regressed; the new toggles are the always-visible control. Refs internal#510, internal#511. Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/src/components/tabs/ConfigTab.tsx | 126 +++++++++++++ .../__tests__/ConfigTab.abilities.test.tsx | 165 ++++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 canvas/src/components/tabs/__tests__/ConfigTab.abilities.test.tsx diff --git a/canvas/src/components/tabs/ConfigTab.tsx b/canvas/src/components/tabs/ConfigTab.tsx index 645edc25e..ba79ce137 100644 --- a/canvas/src/components/tabs/ConfigTab.tsx +++ b/canvas/src/components/tabs/ConfigTab.tsx @@ -109,6 +109,130 @@ function AgentCardSection({ workspaceId }: { workspaceId: string }) { ); } +// --- Agent Abilities Section --- +// +// Always-visible on/off controls for the two workspace-level ability flags +// (broadcast_enabled, talk_to_user_enabled). Both are mutated through the +// same admin endpoint the ChatTab recovery banner already uses +// (PATCH /workspaces/:id/abilities) and reflected into the canvas store node +// data (broadcastEnabled / talkToUserEnabled) so every surface that reads +// useCanvasStore.nodes stays consistent without a full re-hydrate. +// +// Before this section there was NO canvas control for either flag: the +// backend was fully wired (workspace_abilities.go / workspace_broadcast.go / +// agent_message_writer.go, see commit 29b4bffb + internal#510/#511) but the +// only frontend affordance was the ChatTab recovery banner, which renders +// solely when talk_to_user_enabled===false and so is invisible under the +// TRUE default and never existed at all for broadcast. +function AgentAbilitiesSection({ workspaceId }: { workspaceId: string }) { + // Read the live ability flags off the canvas store node — the platform + // event stream hydrates these (canvas-topology.ts maps the workspace row's + // broadcast_enabled/talk_to_user_enabled onto node data), so this stays in + // sync with the recovery banner and avoids a duplicate GET. Mirrors the + // store-read pattern used by AgentCardSection above. + const node = useCanvasStore((s) => + s.nodes?.find?.((n) => n.id === workspaceId), + ); + // Defaults match the backend column defaults + canvas-topology mapping: + // broadcast_enabled defaults FALSE, talk_to_user_enabled defaults TRUE. + const broadcastEnabled = node?.data.broadcastEnabled ?? false; + const talkToUserEnabled = node?.data.talkToUserEnabled ?? true; + + // Track an in-flight PATCH per field so a double-click can't fire two + // racing writes, and surface a one-line error if the server rejects. + const [pending, setPending] = useState(null); + const [error, setError] = useState(null); + + const patchAbility = async ( + which: "broadcast" | "talk", + body: { broadcast_enabled: boolean } | { talk_to_user_enabled: boolean }, + optimistic: Partial<{ broadcastEnabled: boolean; talkToUserEnabled: boolean }>, + ) => { + setError(null); + setPending(which); + // Optimistic store update — the toggle flips immediately; on failure we + // roll back to the server-truth value the store last held. + const prev = { + broadcastEnabled, + talkToUserEnabled, + }; + useCanvasStore.getState().updateNodeData(workspaceId, optimistic); + try { + await api.patch(`/workspaces/${workspaceId}/abilities`, body); + } catch (e) { + // Roll back the optimistic change to last-known server truth. + useCanvasStore.getState().updateNodeData(workspaceId, { + broadcastEnabled: prev.broadcastEnabled, + talkToUserEnabled: prev.talkToUserEnabled, + }); + setError( + e instanceof Error ? e.message : "Failed to update ability — try again", + ); + } finally { + setPending(null); + } + }; + + return ( +
+

+ Workspace-level permissions for this agent. Changes apply immediately + (no restart required). +

+
+
+ + pending + ? undefined + : patchAbility( + "talk", + { talk_to_user_enabled: v }, + { talkToUserEnabled: v }, + ) + } + /> +

+ When off, the agent's send_message_to_user{" "} + and POST /notify calls are + rejected (403) — it must route updates through a parent workspace. +

+
+
+ + pending + ? undefined + : patchAbility( + "broadcast", + { broadcast_enabled: v }, + { broadcastEnabled: v }, + ) + } + /> +

+ When on, the agent may POST /broadcast{" "} + to message all non-removed agent workspaces in the org. Off by + default — only privileged orchestrators should hold this. +

+
+
+ {pending && ( +
Saving…
+ )} + {error && ( +
+ {error} +
+ )} +
+ ); +} + // --- Main ConfigTab --- interface ModelSpec { @@ -885,6 +1009,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..c01575981 --- /dev/null +++ b/canvas/src/components/tabs/__tests__/ConfigTab.abilities.test.tsx @@ -0,0 +1,165 @@ +// @vitest-environment jsdom +// +// Tests for the always-visible "Agent Abilities" section added to ConfigTab +// (internal#510 broadcast_enabled, internal#511 talk_to_user_enabled; backend +// wired in commit 29b4bffb). +// +// Problem this pins: the two workspace ability flags had complete wired +// backends but NO canvas control — broadcast had none at all, talk-to-user +// only surfaced as a ChatTab recovery banner that is invisible under its +// TRUE default. The CTO could not see or toggle either from canvas. +// +// What this suite pins: +// 1. An "Agent Abilities" section renders (always visible, not gated). +// 2. Both toggles render and reflect the store node's ability fields, +// including the asymmetric defaults (broadcast FALSE, talk TRUE). +// 3. Toggling a switch calls PATCH /workspaces/:id/abilities with the +// correct snake_case body and optimistically updates the store. + +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react"; +import React from "react"; + +afterEach(cleanup); + +const apiGet = vi.fn(); +const apiPatch = vi.fn(); +vi.mock("@/lib/api", () => ({ + api: { + get: (path: string) => apiGet(path), + patch: (path: string, body?: unknown) => apiPatch(path, body), + put: vi.fn(), + post: vi.fn(), + del: vi.fn(), + }, +})); + +// Store node carries the ability flags hydrated by the platform stream +// (canvas-topology.ts maps broadcast_enabled/talk_to_user_enabled onto +// node.data). Mirror that shape so the section reads real values. +const storeUpdateNodeData = vi.fn(); +const storeRestartWorkspace = vi.fn(); +let nodeData: { broadcastEnabled?: boolean; talkToUserEnabled?: boolean } = {}; +const makeState = () => ({ + nodes: [{ id: "ws-test", data: nodeData }], + restartWorkspace: storeRestartWorkspace, + updateNodeData: storeUpdateNodeData, +}); +vi.mock("@/store/canvas", () => ({ + useCanvasStore: Object.assign( + (selector: (s: unknown) => unknown) => selector(makeState()), + { getState: () => makeState() }, + ), +})); + +vi.mock("../AgentCardSection", () => ({ + AgentCardSection: () =>
, +})); + +import { ConfigTab } from "../ConfigTab"; + +beforeEach(() => { + apiGet.mockReset(); + apiPatch.mockReset(); + apiPatch.mockResolvedValue({ status: "updated" }); + storeUpdateNodeData.mockReset(); + apiGet.mockImplementation((path: string) => { + if (path === `/workspaces/ws-test`) { + return Promise.resolve({ runtime: "claude-code" }); + } + if (path === `/workspaces/ws-test/model`) { + return Promise.resolve({ model: "claude-opus-4-7" }); + } + if (path === `/workspaces/ws-test/provider`) { + return Promise.resolve({ provider: "anthropic-oauth", source: "default" }); + } + if (path === `/workspaces/ws-test/files/config.yaml`) { + return Promise.resolve({ content: "name: test\nruntime: claude-code\n" }); + } + if (path === "/templates") { + return Promise.resolve([ + { id: "claude-code", name: "Claude Code", runtime: "claude-code", providers: [] }, + ]); + } + return Promise.reject(new Error(`unmocked api.get: ${path}`)); + }); +}); + +describe("ConfigTab Agent Abilities section", () => { + it("renders an always-visible 'Agent Abilities' section with both toggles", async () => { + nodeData = {}; // unset → defaults + render(); + await waitFor(() => expect(apiGet).toHaveBeenCalled()); + expect( + await screen.findByRole("button", { name: /Agent Abilities/i }), + ).toBeTruthy(); + expect(screen.getByText("Talk to user")).toBeTruthy(); + expect(screen.getByText("Broadcast to peers")).toBeTruthy(); + }); + + it("reflects the asymmetric defaults: talk-to-user ON, broadcast OFF", async () => { + nodeData = {}; // unset → backend defaults + render(); + await waitFor(() => expect(apiGet).toHaveBeenCalled()); + const talk = (await screen.findByText("Talk to user")) + .closest("label")! + .querySelector("input") as HTMLInputElement; + const broadcast = screen + .getByText("Broadcast to peers") + .closest("label")! + .querySelector("input") as HTMLInputElement; + expect(talk.checked).toBe(true); + expect(broadcast.checked).toBe(false); + }); + + it("reflects explicit store values", async () => { + nodeData = { broadcastEnabled: true, talkToUserEnabled: false }; + render(); + await waitFor(() => expect(apiGet).toHaveBeenCalled()); + const talk = (await screen.findByText("Talk to user")) + .closest("label")! + .querySelector("input") as HTMLInputElement; + const broadcast = screen + .getByText("Broadcast to peers") + .closest("label")! + .querySelector("input") as HTMLInputElement; + expect(talk.checked).toBe(false); + expect(broadcast.checked).toBe(true); + }); + + it("PATCHes /abilities with talk_to_user_enabled and optimistically updates the store", async () => { + nodeData = {}; // talk defaults true + render(); + await waitFor(() => expect(apiGet).toHaveBeenCalled()); + const talk = (await screen.findByText("Talk to user")) + .closest("label")! + .querySelector("input") as HTMLInputElement; + fireEvent.click(talk); // true → false + await waitFor(() => + expect(apiPatch).toHaveBeenCalledWith("/workspaces/ws-test/abilities", { + talk_to_user_enabled: false, + }), + ); + expect(storeUpdateNodeData).toHaveBeenCalledWith("ws-test", { + talkToUserEnabled: false, + }); + }); + + it("PATCHes /abilities with broadcast_enabled when the broadcast toggle is flipped", async () => { + nodeData = {}; // broadcast defaults false + render(); + await waitFor(() => expect(apiGet).toHaveBeenCalled()); + const broadcast = (await screen.findByText("Broadcast to peers")) + .closest("label")! + .querySelector("input") as HTMLInputElement; + fireEvent.click(broadcast); // false → true + await waitFor(() => + expect(apiPatch).toHaveBeenCalledWith("/workspaces/ws-test/abilities", { + broadcast_enabled: true, + }), + ); + expect(storeUpdateNodeData).toHaveBeenCalledWith("ws-test", { + broadcastEnabled: true, + }); + }); +}); -- 2.52.0