Compare commits

...

1 Commits

Author SHA1 Message Date
fullstack-engineer 2ab2992333 feat(canvas): add Agent Abilities toggles to ConfigTab
E2E API Smoke Test / E2E API Smoke Test (pull_request) Blocked by required conditions
E2E Chat / E2E Chat (pull_request) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Blocked by required conditions
Harness Replays / Harness Replays (pull_request) Blocked by required conditions
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Blocked by required conditions
qa-review / approved (pull_request) Waiting to run
security-review / approved (pull_request) Waiting to run
sop-checklist / all-items-acked (pull_request) Waiting to run
sop-tier-check / tier-check (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 14s
CI / Detect changes (pull_request) Successful in 9s
audit-force-merge / audit (pull_request) Waiting to run
CI / Shellcheck (E2E scripts) (pull_request) Successful in 11s
E2E API Smoke Test / detect-changes (pull_request) Successful in 6s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 5s
E2E Chat / detect-changes (pull_request) Successful in 8s
Harness Replays / detect-changes (pull_request) Successful in 8s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 15s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
gate-check-v3 / gate-check (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m1s
CI / Platform (Go) (pull_request) Successful in 3m23s
CI / Canvas (Next.js) (pull_request) Successful in 4m48s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Python Lint & Test (pull_request) Successful in 6m35s
CI / all-required (pull_request) Successful in 6m32s
Adds a new AgentAbilitiesSection to ConfigTab with two toggles:

- Broadcast — agent may send org-wide messages (PATCH broadcast_enabled)
- Talk to User — agent may send chat messages to canvas (PATCH talk_to_user_enabled)

Both toggles reflect the workspace node's broadcastEnabled/talkToUserEnabled
store values and optimistically update the store on success.  PATCH calls
/proxy/workspaces/:id/abilities on the platform API.

Adds 7 vitest tests covering: section visibility, initial toggle states,
PATCH payload shapes, optimistic store update, success/error banners.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 12:22:43 +00:00
2 changed files with 284 additions and 0 deletions
+66
View File
@@ -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<string, unknown>)?.broadcastEnabled as boolean | undefined;
const talkToUserEnabled = (node?.data as Record<string, unknown>)?.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<string, unknown>);
setSuccess(field);
setTimeout(() => setSuccess(null), 2000);
} catch {
setError(field);
setTimeout(() => setError(null), 3000);
} finally {
setSaving(null);
}
};
return (
<Section title="Agent Abilities">
<div className="space-y-3">
<div>
<Toggle
label="Broadcast — agent may send org-wide messages"
checked={broadcastEnabled ?? false}
onChange={(v) => handleToggle("broadcast", v)}
/>
<p className="text-[10px] text-ink-soft mt-1 pl-5">
When enabled the workspace can broadcast to every other workspace in the org.
</p>
</div>
<div>
<Toggle
label="Talk to User — agent may send chat messages to the canvas"
checked={talkToUserEnabled ?? true}
onChange={(v) => handleToggle("talk", v)}
/>
<p className="text-[10px] text-ink-soft mt-1 pl-5">
When disabled the agent cannot reach the user in Chat. Useful for headless / research-only workspaces.
</p>
</div>
{success && (
<div className="px-2 py-1 bg-green-900/30 border border-green-800 rounded text-[10px] text-good">Updated</div>
)}
{error && (
<div className="px-2 py-1 bg-red-900/30 border border-red-800 rounded text-[10px] text-bad">Failed to update</div>
)}
</div>
</Section>
);
}
// --- Agent Card Section ---
function AgentCardSection({ workspaceId }: { workspaceId: string }) {
@@ -885,6 +949,8 @@ export function ConfigTab({ workspaceId }: Props) {
)}
</Section>
<AgentAbilitiesSection workspaceId={workspaceId} />
{/* 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") ||
@@ -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: () => <div data-testid="external-connection-stub" />,
}));
vi.mock("./config/secrets-section", () => ({
SecretsSection: () => <div data-testid="secrets-section-stub" />,
}));
// ── 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(<ConfigTab workspaceId="ws-test" />);
await waitFor(() => {
expect(screen.queryByRole("button", { name: /Agent Abilities/i })).not.toBeNull();
});
});
it("renders both toggles with the correct initial checked states", async () => {
render(<ConfigTab workspaceId="ws-test" />);
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(<ConfigTab workspaceId="ws-test" />);
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(<ConfigTab workspaceId="ws-test" />);
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(<ConfigTab workspaceId="ws-test" />);
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(<ConfigTab workspaceId="ws-test" />);
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(<ConfigTab workspaceId="ws-test" />);
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();
});
});
});