diff --git a/canvas/src/components/canvas/__tests__/A2AEdge.test.tsx b/canvas/src/components/canvas/__tests__/A2AEdge.test.tsx
new file mode 100644
index 00000000..51605793
--- /dev/null
+++ b/canvas/src/components/canvas/__tests__/A2AEdge.test.tsx
@@ -0,0 +1,264 @@
+// @vitest-environment jsdom
+/**
+ * Tests for A2AEdge — the custom React Flow edge that renders the
+ * delegation count between two workspaces and routes a click into the
+ * source workspace's Activity feed.
+ *
+ * Behavioural coverage for the four contracts the component owns:
+ * 1. No label → render only the BaseEdge SVG, NO portaled HTML pill
+ * (renders nothing visible at the label layer)
+ * 2. Click the pill → selectNode(source) AND setPanelTab("activity")
+ * when this is a *fresh* selection
+ * 3. Click the pill on an already-selected source → selectNode(source)
+ * runs but setPanelTab is NOT called (preserves the user's current
+ * tab so they don't get yanked off Chat / Memory)
+ * 4. isHot toggles the violet vs. blue accent classes — locks the
+ * buildA2AEdges output → A2AEdge styling contract
+ * 5. ARIA label pluralizes correctly (1 delegation vs N delegations)
+ *
+ * Issue: #2071 (Canvas test gaps follow-up).
+ */
+import {
+ describe,
+ it,
+ expect,
+ vi,
+ beforeEach,
+ afterEach,
+ type Mock,
+} from "vitest";
+import {
+ render,
+ screen,
+ cleanup,
+ fireEvent,
+} from "@testing-library/react";
+
+// ── Hoisted mocks ────────────────────────────────────────────────────────────
+
+// @xyflow/react is mocked end-to-end so the test doesn't need a
+// ReactFlow provider / Pane / canvas. EdgeLabelRenderer normally
+// portals into the canvas root; here it just renders children inline
+// so screen.queryByRole picks up the pill.
+vi.mock("@xyflow/react", () => ({
+ BaseEdge: ({ id }: { id: string }) => ,
+ EdgeLabelRenderer: ({ children }: { children: React.ReactNode }) => (
+ <>{children}>
+ ),
+ getBezierPath: () => ["M0,0 L1,1", 50, 50],
+}));
+
+// Canvas store mock — simulates selectNode + setPanelTab + the
+// selectedNodeId state the click handler reads via getState().
+const { mockSelectNode, mockSetPanelTab, mockState } = vi.hoisted(() => ({
+ mockSelectNode: vi.fn(),
+ mockSetPanelTab: vi.fn(),
+ mockState: {
+ selectedNodeId: null as string | null,
+ selectNode: vi.fn(),
+ setPanelTab: vi.fn(),
+ },
+}));
+
+// Wire the hoisted mock fns onto the store-state object (vi.hoisted
+// returns referentially-stable objects, so this assignment after
+// hoisting is observed by every consumer).
+mockState.selectNode = mockSelectNode;
+mockState.setPanelTab = mockSetPanelTab;
+
+vi.mock("@/store/canvas", () => ({
+ useCanvasStore: Object.assign(
+ (selector: (s: typeof mockState) => unknown) => selector(mockState),
+ { getState: () => mockState },
+ ),
+}));
+
+// Import the SUT after the mocks are declared.
+import { A2AEdge } from "../A2AEdge";
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+function defaultEdgeProps(over: Record = {}) {
+ return {
+ id: "edge-1",
+ source: "ws-source",
+ target: "ws-target",
+ sourceX: 0,
+ sourceY: 0,
+ targetX: 100,
+ targetY: 100,
+ sourcePosition: "right",
+ targetPosition: "left",
+ style: {},
+ ...over,
+ } as never; // EdgeProps is a discriminated union; cast simplifies the test fixture
+}
+
+beforeEach(() => {
+ mockSelectNode.mockReset();
+ mockSetPanelTab.mockReset();
+ mockState.selectedNodeId = null;
+});
+
+afterEach(() => {
+ cleanup();
+});
+
+// ── Tests ────────────────────────────────────────────────────────────────────
+
+describe("A2AEdge — render", () => {
+ it("renders the BaseEdge but no pill when data.label is empty", () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId("base-edge-edge-1")).toBeTruthy();
+ // No clickable pill rendered.
+ expect(screen.queryByRole("button")).toBeNull();
+ });
+
+ it("renders the pill with the label text when data.label is present", () => {
+ render(
+ ,
+ );
+ const btn = screen.getByRole("button");
+ expect(btn.textContent).toBe("5 calls · 2m ago");
+ });
+
+ it("applies the violet accent classes when isHot is true", () => {
+ render(
+ ,
+ );
+ const btn = screen.getByRole("button");
+ expect(btn.className).toContain("border-violet-500/60");
+ expect(btn.className).toContain("text-violet-200");
+ // Negative: blue accent must NOT appear when hot
+ expect(btn.className).not.toContain("border-blue-500/60");
+ });
+
+ it("applies the blue accent classes when isHot is false", () => {
+ render(
+ ,
+ );
+ const btn = screen.getByRole("button");
+ expect(btn.className).toContain("border-blue-500/60");
+ expect(btn.className).toContain("text-blue-200");
+ });
+
+ it("ARIA label pluralizes (singular)", () => {
+ render(
+ ,
+ );
+ const btn = screen.getByRole("button");
+ // count=1 → "1 delegation from ." (no trailing s)
+ expect(btn.getAttribute("aria-label")).toMatch(/^1 delegation from/);
+ expect(btn.getAttribute("aria-label")).not.toMatch(/^1 delegations/);
+ });
+
+ it("ARIA label pluralizes (plural)", () => {
+ render(
+ ,
+ );
+ const btn = screen.getByRole("button");
+ expect(btn.getAttribute("aria-label")).toMatch(/^7 delegations from/);
+ });
+});
+
+describe("A2AEdge — click behaviour", () => {
+ it("click on the pill selects the source workspace", () => {
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole("button"));
+ expect(mockSelectNode).toHaveBeenCalledWith("ws-alpha");
+ });
+
+ it("on FRESH selection, also switches the panel tab to Activity", () => {
+ // Pre-state: nothing selected. The click should yank the user
+ // into Activity to expose the discovery affordance.
+ mockState.selectedNodeId = null;
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole("button"));
+ expect(mockSetPanelTab).toHaveBeenCalledWith("activity");
+ });
+
+ it("on RE-CLICK of an already-selected source, does NOT switch the tab", () => {
+ // Pre-state: source is already selected; user may have intentionally
+ // switched to Chat / Memory. Re-clicking the edge must NOT yank them
+ // back to Activity. (Selector-store getState() returns mockState.)
+ mockState.selectedNodeId = "ws-alpha";
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole("button"));
+ // selectNode still fires (cheap, idempotent on the same id).
+ expect(mockSelectNode).toHaveBeenCalledWith("ws-alpha");
+ // setPanelTab MUST NOT — that's the regression-locked guarantee.
+ expect(mockSetPanelTab).not.toHaveBeenCalled();
+ });
+
+ it("click stops propagation so the canvas pane doesn't deselect", () => {
+ // Without stopPropagation, clicking the edge label would bubble
+ // to the canvas Pane and (per existing handlers) clear the
+ // selection — exactly the opposite of what the click is meant to do.
+ const paneClick = vi.fn();
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole("button"));
+ expect(paneClick).not.toHaveBeenCalled();
+ expect(mockSelectNode).toHaveBeenCalledWith("ws-alpha");
+ });
+});