From f84bfdc27c5df4887f648ea46639cc41db4111b3 Mon Sep 17 00:00:00 2001 From: pdobrei Date: Mon, 22 Jun 2026 11:46:48 +0200 Subject: [PATCH 1/4] feat(graph): topology node icon resolver Add a presentation-only icon resolver that derives a branded node icon from a node's directory slug (preferred) or split label, mirroring the label rules in getOasfSlugFromNodeData. Wire it into topologyWireToReactFlow in place of the generic SmartToy/LocalShipping fallback so the dynamic renderer can match the hand-authored graph icons. Inert until the dynamic path goes live. Co-authored-by: Cursor Signed-off-by: pdobrei Co-authored-by: Cursor --- .../src/utils/topologyNodeIcons.test.tsx | 150 ++++++++++++++++++ .../frontend/src/utils/topologyNodeIcons.tsx | 136 ++++++++++++++++ .../src/utils/topologyToReactFlow.tsx | 24 +-- 3 files changed, 298 insertions(+), 12 deletions(-) create mode 100644 coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyNodeIcons.test.tsx create mode 100644 coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyNodeIcons.tsx diff --git a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyNodeIcons.test.tsx b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyNodeIcons.test.tsx new file mode 100644 index 00000000..df2411dd --- /dev/null +++ b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyNodeIcons.test.tsx @@ -0,0 +1,150 @@ +/** + * Copyright AGNTCY Contributors (https://github.com/agntcy) + * SPDX-License-Identifier: Apache-2.0 + **/ + +import { isValidElement } from "react" +import { describe, expect, it } from "vitest" +import { + resolveTopologyNodeIcon, + topologyNodeIconKind, + type TopologyNodeIconInput, + type TopologyNodeIconKind, +} from "@/utils/topologyNodeIcons" + +describe("topologyNodeIconKind", () => { + it.each<{ + caseName: string + input: TopologyNodeIconInput + expected: TopologyNodeIconKind + }>([ + { + caseName: "slug wins over labels (auction supervisor)", + input: { + directoryAgentSlug: "auction-supervisor-agent", + label1: "Weather", + label2: "MCP Server", + }, + expected: "supervisor", + }, + { + caseName: "logistics supervisor slug -> supervisor", + input: { directoryAgentSlug: "logistics-supervisor-agent" }, + expected: "supervisor", + }, + { + caseName: "recruiter slug -> recruiter", + input: { directoryAgentSlug: "recruiter" }, + expected: "recruiter", + }, + { + caseName: "coffee farm slug -> farm", + input: { directoryAgentSlug: "colombia-coffee-farm" }, + expected: "farm", + }, + { + caseName: "tatooine farm slug -> farm", + input: { directoryAgentSlug: "tatooine-farm-agent" }, + expected: "farm", + }, + { + caseName: "weather mcp slug -> weatherMcp", + input: { directoryAgentSlug: "weather-mcp-server" }, + expected: "weatherMcp", + }, + { + caseName: "payment mcp slug -> paymentMcp", + input: { directoryAgentSlug: "payment-mcp-server" }, + expected: "paymentMcp", + }, + { + caseName: "shipping slug -> shipping", + input: { directoryAgentSlug: "shipping-agent" }, + expected: "shipping", + }, + { + caseName: "accountant slug -> accountant", + input: { directoryAgentSlug: "accountant-agent" }, + expected: "accountant", + }, + { + caseName: "unknown slug falls back to labels", + input: { directoryAgentSlug: "mystery", label1: "Shipper" }, + expected: "shipping", + }, + { + caseName: "weather mcp by labels", + input: { label1: "Weather", label2: "MCP Server" }, + expected: "weatherMcp", + }, + { + caseName: "payment mcp by labels", + input: { label1: "Payment", label2: "MCP Server" }, + expected: "paymentMcp", + }, + { + caseName: "generic mcp by labels -> default", + input: { label1: "Inventory", label2: "MCP Server" }, + expected: "default", + }, + { + caseName: "agentic recruiter by labels", + input: { + label1: "Agentic Recruiter", + label2: "Discovery and delegation", + }, + expected: "recruiter", + }, + { + caseName: "agntcy agent directory by labels", + input: { label1: "Directory", label2: "AGNTCY Agent Directory" }, + expected: "directory", + }, + { + caseName: "coffee farm by labels", + input: { label1: "Brazil", label2: "Coffee Farm Agent" }, + expected: "farm", + }, + { + caseName: "auction agent by labels", + input: { label1: "Auction Agent", label2: "Buyer" }, + expected: "supervisor", + }, + { + caseName: "logistics buyer by labels", + input: { label1: "Buyer", label2: "Logistics Agent" }, + expected: "supervisor", + }, + { + caseName: "shipper by labels", + input: { label1: "Shipper", label2: "Shipper Agent" }, + expected: "shipping", + }, + { + caseName: "accountant by labels", + input: { label1: "Accountant", label2: "Accountant Agent" }, + expected: "accountant", + }, + { + caseName: "unknown -> default", + input: { label1: "Mystery", label2: "Thing" }, + expected: "default", + }, + { + caseName: "empty input -> default", + input: {}, + expected: "default", + }, + ])("$caseName", ({ input, expected }) => { + expect(topologyNodeIconKind(input)).toBe(expected) + }) +}) + +describe("resolveTopologyNodeIcon", () => { + it("returns a renderable element for every input", () => { + expect(isValidElement(resolveTopologyNodeIcon({ label1: "Shipper" }))).toBe( + true, + ) + expect(isValidElement(resolveTopologyNodeIcon({}))).toBe(true) + }) +}) diff --git a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyNodeIcons.tsx b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyNodeIcons.tsx new file mode 100644 index 00000000..000d7aaa --- /dev/null +++ b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyNodeIcons.tsx @@ -0,0 +1,136 @@ +/** + * Copyright AGNTCY Contributors (https://github.com/agntcy) + * SPDX-License-Identifier: Apache-2.0 + * + * ----------------------------------------------------------------------------- + * Presentation-only icon resolver for dynamically rendered topology nodes. + * + * Backend topology carries no icon field, so the dynamic renderer derives a + * branded icon from a node's directory slug (preferred) or its split label, + * using loose substring matching rather than an exhaustive agent map. This is + * presentation, not data: if the backend later supplies an icon/type hint, key + * off that. + * ----------------------------------------------------------------------------- + */ + +import React from "react" +import Air from "@mui/icons-material/Air" +import Calculate from "@mui/icons-material/Calculate" +import LocalShipping from "@mui/icons-material/LocalShipping" +import SmartToy from "@mui/icons-material/SmartToy" +import { GraphDiscoveryAssetImg } from "@/utils/GraphDiscoveryAssetImg" +import supervisorIcon from "@/assets/supervisor.png" +import farmAgentIcon from "@/assets/Grader-Agent.png" + +export interface TopologyNodeIconInput { + label1?: string + label2?: string + directoryAgentSlug?: string +} + +export type TopologyNodeIconKind = + | "supervisor" + | "recruiter" + | "directory" + | "farm" + | "weatherMcp" + | "paymentMcp" + | "shipping" + | "accountant" + | "default" + +function normalize(value: string | undefined): string { + return typeof value === "string" ? value.trim().toLowerCase() : "" +} + +function iconKindFromSlug(slug: string): TopologyNodeIconKind | null { + if (slug.includes("supervisor")) return "supervisor" + if (slug.includes("recruiter")) return "recruiter" + if (slug.includes("farm")) return "farm" + if (slug.includes("weather")) return "weatherMcp" + if (slug.includes("payment")) return "paymentMcp" + if (slug.includes("shipping")) return "shipping" + if (slug.includes("accountant")) return "accountant" + return null +} + +function iconKindFromLabels( + label1: string, + label2: string, +): TopologyNodeIconKind { + const combined = `${label1} ${label2}`.trim() + + if (label2 === "mcp server" || label1.endsWith("mcp server")) { + if (label1.includes("weather")) return "weatherMcp" + if (label1.includes("payment")) return "paymentMcp" + return "default" + } + + if (/\bagentic\b/.test(combined) && /\brecruiter\b/.test(combined)) { + return "recruiter" + } + + if ( + label1 === "directory" || + (combined.includes("agntcy") && combined.includes("agent directory")) + ) { + return "directory" + } + + if (label2.includes("coffee farm")) return "farm" + if (label1 === "shipper") return "shipping" + if (label1 === "accountant") return "accountant" + + if ( + label1 === "auction agent" || + (label1 === "auction" && label2.includes("agent")) || + label1 === "buyer" || + label2.includes("buyer") || + label2.includes("logistics agent") || + combined === "logistics group" + ) { + return "supervisor" + } + + return "default" +} + +export function topologyNodeIconKind( + input: TopologyNodeIconInput, +): TopologyNodeIconKind { + const slug = normalize(input.directoryAgentSlug) + if (slug) { + const bySlug = iconKindFromSlug(slug) + if (bySlug) return bySlug + } + return iconKindFromLabels(normalize(input.label1), normalize(input.label2)) +} + +function brandedImg(src: string, alt: string): React.ReactNode { + return +} + +export function resolveTopologyNodeIcon( + input: TopologyNodeIconInput, +): React.ReactNode { + switch (topologyNodeIconKind(input)) { + case "supervisor": + return brandedImg(supervisorIcon, "Supervisor Icon") + case "recruiter": + return brandedImg(supervisorIcon, "Recruiter Icon") + case "directory": + return brandedImg(supervisorIcon, "Directory Icon") + case "farm": + return brandedImg(farmAgentIcon, "Farm Agent Icon") + case "weatherMcp": + return + case "paymentMcp": + return + case "shipping": + return + case "accountant": + return + default: + return + } +} diff --git a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyToReactFlow.tsx b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyToReactFlow.tsx index ed00d30b..837cd8b7 100644 --- a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyToReactFlow.tsx +++ b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyToReactFlow.tsx @@ -3,9 +3,6 @@ * SPDX-License-Identifier: Apache-2.0 **/ -import React from "react" -import LocalShipping from "@mui/icons-material/LocalShipping" -import SmartToy from "@mui/icons-material/SmartToy" import type { Edge, Node } from "@xyflow/react" import type { TopologyEdgeWire, @@ -34,6 +31,7 @@ import { splitTopologyNodeLabel, } from "@/utils/agenticTopologyIdentityUiMap" import type { IdentityUiGithubVariant } from "@/utils/agenticTopologyIdentityUiMap" +import { resolveTopologyNodeIcon } from "@/utils/topologyNodeIcons" // Transport label -> canonical synonym. Seed emits "transport"; runtime emits // "slim"/"nats"/"jsonrpc" for the same logical transport. Distinct logical @@ -68,14 +66,6 @@ export function extractStableAgentId(n: TopologyNodeWire): string { return "" } -function defaultCustomIcon(label: string): React.ReactNode { - const lower = label.toLowerCase() - if (lower.includes("transport")) { - return - } - return -} - export interface TopologyToFlowOptions { /** When false, skip SecurityClass check for tests. */ validateUrls?: boolean @@ -220,7 +210,7 @@ export function topologyWireToReactFlow( const labelStr = typeof n.label === "string" ? n.label : "" const { label1, label2 } = splitTopologyNodeLabel(labelStr) let data: CustomNodeData = { - icon: defaultCustomIcon(labelStr), + icon: resolveTopologyNodeIcon({ label1, label2 }), label1, label2, handles: HANDLE_TYPES.ALL, @@ -232,6 +222,16 @@ export function topologyWireToReactFlow( identityUiVariant: options.identityUiVariant, }) data = enrichAgenticTopologyWellKnownUi(data, n, { validateUrls }) + if (data.directoryAgentSlug) { + data = { + ...data, + icon: resolveTopologyNodeIcon({ + label1: data.label1, + label2: data.label2, + directoryAgentSlug: data.directoryAgentSlug, + }), + } + } return { id: rfId, From 782add91926da718fcca8d1bedc655746614f0d6 Mon Sep 17 00:00:00 2001 From: pdobrei Date: Mon, 22 Jun 2026 11:56:49 +0200 Subject: [PATCH 2/4] feat(graph): dynamic renderer parity for groups, MCP and A2A Bring the topology renderer up to visual parity with the hand-authored graph: - Render a single group node as a real container (type group + style) and parent its members (parentId/extent), marking the in-group transport compact so the existing compact-group layout applies. - Label edges into MCP servers as "MCP:" (detected by target label) while keeping them branching. - Attach the A2A directory/recruiter left/right extra handles and the "stdio -> grpc" edge label for the recruiter pattern. Inert until the dynamic path goes live. Co-authored-by: Cursor Signed-off-by: pdobrei --- .../src/utils/topologyToReactFlow.test.tsx | 104 +++++++++++++++--- .../src/utils/topologyToReactFlow.tsx | 101 +++++++++++++++-- 2 files changed, 177 insertions(+), 28 deletions(-) diff --git a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyToReactFlow.test.tsx b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyToReactFlow.test.tsx index dacac8e9..b29b19f0 100644 --- a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyToReactFlow.test.tsx +++ b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyToReactFlow.test.tsx @@ -4,10 +4,13 @@ **/ import { describe, expect, it } from "vitest" -import { NODE_TYPES, EDGE_TYPES } from "@/utils/const" +import { NODE_TYPES, EDGE_TYPES, EDGE_LABELS } from "@/utils/const" import { topologyWireToReactFlow } from "@/utils/topologyToReactFlow" import { stableAgentUuidForRecordName } from "@/utils/agenticTopologyIdentityUiMap" +function wireNode(id: string, type: string, label: string, layerIndex: number) { + return { id, type, label, layer_index: layerIndex } +} describe("topologyWireToReactFlow", () => { it.each([ { @@ -177,30 +180,99 @@ describe("topologyWireToReactFlow", () => { expect(data?.label2).toBe("Farm Agent") }) - it("enrich: logistics group node gets transport github link", () => { + it("renders a group container with children parented and compact transport", () => { + const groupId = "node://aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" const { nodes } = topologyWireToReactFlow( { nodes: [ + wireNode(groupId, "group", "Logistics Group", 0), + wireNode( + "node://bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "customNode", + "Buyer Logistics Agent", + 1, + ), + wireNode( + "node://cccccccc-cccc-4ccc-8ccc-cccccccccccc", + "transportNode", + "Transport", + 2, + ), + ], + edges: [], + }, + { validateUrls: false }, + ) + const group = nodes.find((node) => node.id === groupId) + const transport = nodes.find((node) => node.type === "transportNode") + const child = nodes.find( + (node) => node.type === "customNode" && node.parentId === groupId, + ) + expect(group?.type).toBe("group") + expect(group?.style).toBeDefined() + expect(transport?.parentId).toBe(groupId) + expect((transport?.data as { compact?: boolean })?.compact).toBe(true) + expect(child).toBeDefined() + expect(child?.extent).toBe("parent") + }) + + it("labels edges into MCP servers as MCP and keeps them branching", () => { + const colombiaId = "node://10101010-1010-4101-8101-101010101010" + const weatherId = "node://20202020-2020-4202-8202-202020202020" + const { edges } = topologyWireToReactFlow( + { + nodes: [ + wireNode(colombiaId, "customNode", "Colombia Coffee Farm Agent", 0), + wireNode(weatherId, "customNode", "Weather MCP Server", 1), + ], + edges: [ { - id: "node://aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - type: "group", - label: "Logistics Group", - layer_index: 0, + id: "edge://30303030-3030-4303-8303-303030303030", + type: "branching", + source: colombiaId, + target: weatherId, }, ], - edges: [], }, { validateUrls: false }, ) - const data = nodes[0]?.data as { - githubLink?: string - label1?: string - directoryAgentSlug?: string - } - expect(data?.label1).toBe("Logistics") - expect(data?.githubLink).toBeDefined() - expect(data?.githubLink).toContain("transport") - expect(data?.directoryAgentSlug).toBe("logistics-supervisor-agent") + expect(edges[0]?.type).toBe(EDGE_TYPES.BRANCHING) + expect((edges[0]?.data as { label?: string })?.label).toBe(EDGE_LABELS.MCP) + }) + + it("adds A2A directory/recruiter handles and stdio edge label", () => { + const recruiterId = "node://40404040-4040-4404-8404-404040404040" + const directoryId = "node://50505050-5050-4505-8505-505050505050" + const { nodes, edges } = topologyWireToReactFlow( + { + nodes: [ + wireNode(recruiterId, "customNode", "Agentic Recruiter", 0), + wireNode(directoryId, "customNode", "AGNTCY Agent Directory", 1), + ], + edges: [ + { + id: "edge://60606060-6060-4606-8606-606060606060", + type: "custom", + source: directoryId, + target: recruiterId, + }, + ], + }, + { validateUrls: false }, + ) + const recruiter = nodes.find((node) => node.id === recruiterId) + const directory = nodes.find((node) => node.id === directoryId) + const recruiterHandles = (recruiter?.data as { extraHandles?: unknown[] }) + ?.extraHandles + const directoryHandles = (directory?.data as { extraHandles?: unknown[] }) + ?.extraHandles + expect(recruiterHandles).toHaveLength(1) + expect(directoryHandles).toHaveLength(1) + expect(edges[0]?.sourceHandle).toBe("source-left") + expect(edges[0]?.targetHandle).toBe("target-right") + expect((edges[0]?.data as { label?: string })?.label).toBe( + EDGE_LABELS.MCP_WITH_STDIO, + ) }) it("enrich: AGNTCY Agent Directory gets directory github link", () => { diff --git a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyToReactFlow.tsx b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyToReactFlow.tsx index 837cd8b7..0dc86380 100644 --- a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyToReactFlow.tsx +++ b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyToReactFlow.tsx @@ -18,6 +18,7 @@ import { } from "@/utils/const" import type { CustomNodeData, + ExtraHandle, TransportNodeData, } from "@/components/MainArea/Graph/Elements/types" import { @@ -66,6 +67,35 @@ export function extractStableAgentId(n: TopologyNodeWire): string { return "" } +// Group container defaults; the compact-group layout recomputes width/height +// from content, these are only the pre-layout box. +const GROUP_DEFAULT_WIDTH = 900 +const GROUP_DEFAULT_HEIGHT = 650 + +function isMcpServerLabel(label: string): boolean { + return /mcp\s+server$/i.test(label.trim()) +} + +function isRecruiterLabel(label: string): boolean { + const lower = label.toLowerCase() + return /\bagentic\b/.test(lower) && /\brecruiter\b/.test(lower) +} + +function isDirectoryLabel(label: string): boolean { + const lower = label.toLowerCase() + return lower.includes("agntcy") && lower.includes("agent directory") +} + +function a2aExtraHandlesForLabel(label: string): ExtraHandle[] | undefined { + if (isRecruiterLabel(label)) { + return [{ id: "target-right", type: "target", position: "right" }] + } + if (isDirectoryLabel(label)) { + return [{ id: "source-left", type: "source", position: "left" }] + } + return undefined +} + export interface TopologyToFlowOptions { /** When false, skip SecurityClass check for tests. */ validateUrls?: boolean @@ -183,31 +213,62 @@ export function topologyWireToReactFlow( positions.set(rfId, position) } + // A single group node turns the workflow into a contained graph: members + // become children (parentId/extent) and the transport renders compact. + const groupRfIds = dedupedNodesIn + .filter((n) => n.type === NODE_TYPES.GROUP) + .map(rfIdOf) + const groupRfId = groupRfIds.length === 1 ? groupRfIds[0] : null + + const labelByRfId = new Map() + for (const n of dedupedNodesIn) { + labelByRfId.set(rfIdOf(n), typeof n.label === "string" ? n.label : "") + } + const nodes: Node[] = dedupedNodesIn.map((n): Node => { const rfId = rfIdOf(n) - const nodeType = - n.type === NODE_TYPES.TRANSPORT ? NODE_TYPES.TRANSPORT : NODE_TYPES.CUSTOM const position = positions.get(rfId) ?? { x: 0, y: 0 } + const labelStr = labelByRfId.get(rfId) ?? "" const gh = resolveGithubFromAgentRecordUri( n.agent_record_uri as string | undefined, { validateUrls }, ) - if (nodeType === NODE_TYPES.TRANSPORT) { + if (n.type === NODE_TYPES.GROUP) { + return { + id: rfId, + type: NODE_TYPES.GROUP, + position, + data: { label: labelStr }, + style: { + width: GROUP_DEFAULT_WIDTH, + height: GROUP_DEFAULT_HEIGHT, + backgroundColor: "var(--group-background)", + border: "none", + borderRadius: "8px", + }, + } + } + + const childProps = groupRfId + ? { parentId: groupRfId, extent: "parent" as const } + : {} + + if (n.type === NODE_TYPES.TRANSPORT) { const data: TransportNodeData = { - label: typeof n.label === "string" ? n.label : "Transport", + label: labelStr || "Transport", githubLink: gh, - compact: false, + compact: groupRfId != null, } return { id: rfId, type: NODE_TYPES.TRANSPORT, position, data: data as unknown as Record, + ...childProps, } } - const labelStr = typeof n.label === "string" ? n.label : "" const { label1, label2 } = splitTopologyNodeLabel(labelStr) let data: CustomNodeData = { icon: resolveTopologyNodeIcon({ label1, label2 }), @@ -232,12 +293,17 @@ export function topologyWireToReactFlow( }), } } + const extraHandles = a2aExtraHandlesForLabel(labelStr) + if (extraHandles) { + data = { ...data, extraHandles } + } return { id: rfId, type: NODE_TYPES.CUSTOM, position, data: data as unknown as Record, + ...childProps, } }) @@ -253,18 +319,29 @@ export function topologyWireToReactFlow( seenEdgePairs.add(pairKey) const edgeType = e.type === EDGE_TYPES.BRANCHING ? EDGE_TYPES.BRANCHING : EDGE_TYPES.CUSTOM + const sourceLabel = labelByRfId.get(source) ?? "" + const targetLabel = labelByRfId.get(target) ?? "" + + let label: string = EDGE_LABELS.A2A + let sourceHandle: string | undefined + let targetHandle: string | undefined + if (isMcpServerLabel(targetLabel)) { + label = EDGE_LABELS.MCP + } else if (isDirectoryLabel(sourceLabel) && isRecruiterLabel(targetLabel)) { + label = EDGE_LABELS.MCP_WITH_STDIO + sourceHandle = "source-left" + targetHandle = "target-right" + } + const base: Edge = { id: e.id, source, target, type: edgeType, - data: { - label: - typeof e.type === "string" && e.type.toLowerCase().includes("mcp") - ? EDGE_LABELS.MCP - : EDGE_LABELS.A2A, - }, + data: { label }, } + if (sourceHandle) base.sourceHandle = sourceHandle + if (targetHandle) base.targetHandle = targetHandle const branches = (e as { branches?: string[] }).branches if (edgeType === EDGE_TYPES.BRANCHING && Array.isArray(branches)) { base.data = { From fbd0bf997a2a141ffe8ada822eb897dc05bc3b7f Mon Sep 17 00:00:00 2001 From: pdobrei Date: Tue, 23 Jun 2026 09:47:36 +0200 Subject: [PATCH 3/4] refactor(graph): centralize topology node label predicates Extract isMcpServerLabel/isRecruiterLabel/isDirectoryLabel into agenticTopologyIdentityUiMap and reuse them from the dynamic renderer and icon resolver, removing the duplicated label heuristics. (The chat-stream highlight adoption lands later, alongside the live-topology switch.) Co-authored-by: Cursor Signed-off-by: pdobrei --- .../agenticTopologyIdentityUiMap.test.ts | 53 +++++++++++++++++++ .../src/utils/agenticTopologyIdentityUiMap.ts | 17 ++++++ .../frontend/src/utils/topologyNodeIcons.tsx | 11 ++-- .../src/utils/topologyToReactFlow.tsx | 17 ++---- 4 files changed, 79 insertions(+), 19 deletions(-) diff --git a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/agenticTopologyIdentityUiMap.test.ts b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/agenticTopologyIdentityUiMap.test.ts index a6508d28..407a4215 100644 --- a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/agenticTopologyIdentityUiMap.test.ts +++ b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/agenticTopologyIdentityUiMap.test.ts @@ -10,6 +10,9 @@ import type { CustomNodeData } from "@/components/MainArea/Graph/Elements/types" import { getOasfSlugFromNodeData, IDENTITY_UI_BY_STABLE_AGENT_UUID, + isDirectoryLabel, + isMcpServerLabel, + isRecruiterLabel, normalizeAgentRecordUriToRepoPath, parseStableAgentUuid, resolveGithubFromAgentRecordUri, @@ -162,4 +165,54 @@ describe("agenticTopologyIdentityUiMap", () => { ).toBe("recruiter") }) }) + + describe("label predicates", () => { + it.each([ + { caseName: "weather mcp server", label: "Weather MCP Server", v: true }, + { + caseName: "lowercased mcp server", + label: "payment mcp server", + v: true, + }, + { + caseName: "trailing whitespace", + label: "Weather MCP Server ", + v: true, + }, + { caseName: "not at end", label: "MCP Server Gateway", v: false }, + { caseName: "plain agent", label: "Brazil Coffee Farm Agent", v: false }, + ])("isMcpServerLabel: $caseName", ({ label, v }) => { + expect(isMcpServerLabel(label)).toBe(v) + }) + + it.each([ + { caseName: "agentic recruiter", label: "Agentic Recruiter", v: true }, + { + caseName: "split labels combined", + label: "agentic recruiter delegation", + v: true, + }, + { caseName: "recruiter alone", label: "Recruiter", v: false }, + { caseName: "directory", label: "AGNTCY Agent Directory", v: false }, + ])("isRecruiterLabel: $caseName", ({ label, v }) => { + expect(isRecruiterLabel(label)).toBe(v) + }) + + it.each([ + { + caseName: "agntcy agent directory", + label: "AGNTCY Agent Directory", + v: true, + }, + { + caseName: "combined labels", + label: "directory agntcy agent directory", + v: true, + }, + { caseName: "missing agntcy", label: "Agent Directory", v: false }, + { caseName: "recruiter", label: "Agentic Recruiter", v: false }, + ])("isDirectoryLabel: $caseName", ({ label, v }) => { + expect(isDirectoryLabel(label)).toBe(v) + }) + }) }) diff --git a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/agenticTopologyIdentityUiMap.ts b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/agenticTopologyIdentityUiMap.ts index 282655aa..f4890e79 100644 --- a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/agenticTopologyIdentityUiMap.ts +++ b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/agenticTopologyIdentityUiMap.ts @@ -212,6 +212,23 @@ export function splitTopologyNodeLabel(label: string): { return { label1: t.slice(0, sp), label2: t.slice(sp + 1).trim() } } +/** True when a node label denotes an MCP server (e.g. "Weather MCP Server"). */ +export function isMcpServerLabel(label: string): boolean { + return /mcp\s+server$/i.test(label.trim()) +} + +/** True when a node label denotes the agentic recruiter. */ +export function isRecruiterLabel(label: string): boolean { + const lower = label.toLowerCase() + return /\bagentic\b/.test(lower) && /\brecruiter\b/.test(lower) +} + +/** True when a node label denotes the AGNTCY agent directory. */ +export function isDirectoryLabel(label: string): boolean { + const lower = label.toLowerCase() + return lower.includes("agntcy") && lower.includes("agent directory") +} + /** * Normalize catalog `agent_record_uri` relative paths to a repo path segment * under `coffeeAGNTCY/coffee_agents` (for GitHub blob URLs). diff --git a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyNodeIcons.tsx b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyNodeIcons.tsx index 000d7aaa..53a59b32 100644 --- a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyNodeIcons.tsx +++ b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyNodeIcons.tsx @@ -19,6 +19,10 @@ import Calculate from "@mui/icons-material/Calculate" import LocalShipping from "@mui/icons-material/LocalShipping" import SmartToy from "@mui/icons-material/SmartToy" import { GraphDiscoveryAssetImg } from "@/utils/GraphDiscoveryAssetImg" +import { + isDirectoryLabel, + isRecruiterLabel, +} from "@/utils/agenticTopologyIdentityUiMap" import supervisorIcon from "@/assets/supervisor.png" import farmAgentIcon from "@/assets/Grader-Agent.png" @@ -66,14 +70,11 @@ function iconKindFromLabels( return "default" } - if (/\bagentic\b/.test(combined) && /\brecruiter\b/.test(combined)) { + if (isRecruiterLabel(combined)) { return "recruiter" } - if ( - label1 === "directory" || - (combined.includes("agntcy") && combined.includes("agent directory")) - ) { + if (label1 === "directory" || isDirectoryLabel(combined)) { return "directory" } diff --git a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyToReactFlow.tsx b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyToReactFlow.tsx index 0dc86380..fe4e7d2d 100644 --- a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyToReactFlow.tsx +++ b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyToReactFlow.tsx @@ -27,6 +27,9 @@ import { } from "@/utils/topologyLayout" import { enrichAgenticTopologyWellKnownUi, + isDirectoryLabel, + isMcpServerLabel, + isRecruiterLabel, mergeAgenticTopologyIdentityUi, resolveGithubFromAgentRecordUri, splitTopologyNodeLabel, @@ -72,20 +75,6 @@ export function extractStableAgentId(n: TopologyNodeWire): string { const GROUP_DEFAULT_WIDTH = 900 const GROUP_DEFAULT_HEIGHT = 650 -function isMcpServerLabel(label: string): boolean { - return /mcp\s+server$/i.test(label.trim()) -} - -function isRecruiterLabel(label: string): boolean { - const lower = label.toLowerCase() - return /\bagentic\b/.test(lower) && /\brecruiter\b/.test(lower) -} - -function isDirectoryLabel(label: string): boolean { - const lower = label.toLowerCase() - return lower.includes("agntcy") && lower.includes("agent directory") -} - function a2aExtraHandlesForLabel(label: string): ExtraHandle[] | undefined { if (isRecruiterLabel(label)) { return [{ id: "target-right", type: "target", position: "right" }] From 47fbfe651212ab8aa8e6bf261d326686d0c1b1cd Mon Sep 17 00:00:00 2001 From: pdobrei Date: Fri, 26 Jun 2026 13:50:45 +0200 Subject: [PATCH 4/4] Enums for IconKind Use enums instead of string literals for ikon kind Signed-off-by: pdobrei --- .../src/utils/topologyNodeIcons.test.tsx | 46 +++++----- .../frontend/src/utils/topologyNodeIcons.tsx | 85 +++++++++++-------- 2 files changed, 73 insertions(+), 58 deletions(-) diff --git a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyNodeIcons.test.tsx b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyNodeIcons.test.tsx index df2411dd..71a96e6b 100644 --- a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyNodeIcons.test.tsx +++ b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyNodeIcons.test.tsx @@ -8,8 +8,8 @@ import { describe, expect, it } from "vitest" import { resolveTopologyNodeIcon, topologyNodeIconKind, + TopologyNodeIconKind, type TopologyNodeIconInput, - type TopologyNodeIconKind, } from "@/utils/topologyNodeIcons" describe("topologyNodeIconKind", () => { @@ -25,67 +25,67 @@ describe("topologyNodeIconKind", () => { label1: "Weather", label2: "MCP Server", }, - expected: "supervisor", + expected: TopologyNodeIconKind.Supervisor, }, { caseName: "logistics supervisor slug -> supervisor", input: { directoryAgentSlug: "logistics-supervisor-agent" }, - expected: "supervisor", + expected: TopologyNodeIconKind.Supervisor, }, { caseName: "recruiter slug -> recruiter", input: { directoryAgentSlug: "recruiter" }, - expected: "recruiter", + expected: TopologyNodeIconKind.Recruiter, }, { caseName: "coffee farm slug -> farm", input: { directoryAgentSlug: "colombia-coffee-farm" }, - expected: "farm", + expected: TopologyNodeIconKind.Farm, }, { caseName: "tatooine farm slug -> farm", input: { directoryAgentSlug: "tatooine-farm-agent" }, - expected: "farm", + expected: TopologyNodeIconKind.Farm, }, { caseName: "weather mcp slug -> weatherMcp", input: { directoryAgentSlug: "weather-mcp-server" }, - expected: "weatherMcp", + expected: TopologyNodeIconKind.WeatherMcp, }, { caseName: "payment mcp slug -> paymentMcp", input: { directoryAgentSlug: "payment-mcp-server" }, - expected: "paymentMcp", + expected: TopologyNodeIconKind.PaymentMcp, }, { caseName: "shipping slug -> shipping", input: { directoryAgentSlug: "shipping-agent" }, - expected: "shipping", + expected: TopologyNodeIconKind.Shipping, }, { caseName: "accountant slug -> accountant", input: { directoryAgentSlug: "accountant-agent" }, - expected: "accountant", + expected: TopologyNodeIconKind.Accountant, }, { caseName: "unknown slug falls back to labels", input: { directoryAgentSlug: "mystery", label1: "Shipper" }, - expected: "shipping", + expected: TopologyNodeIconKind.Shipping, }, { caseName: "weather mcp by labels", input: { label1: "Weather", label2: "MCP Server" }, - expected: "weatherMcp", + expected: TopologyNodeIconKind.WeatherMcp, }, { caseName: "payment mcp by labels", input: { label1: "Payment", label2: "MCP Server" }, - expected: "paymentMcp", + expected: TopologyNodeIconKind.PaymentMcp, }, { caseName: "generic mcp by labels -> default", input: { label1: "Inventory", label2: "MCP Server" }, - expected: "default", + expected: TopologyNodeIconKind.Default, }, { caseName: "agentic recruiter by labels", @@ -93,47 +93,47 @@ describe("topologyNodeIconKind", () => { label1: "Agentic Recruiter", label2: "Discovery and delegation", }, - expected: "recruiter", + expected: TopologyNodeIconKind.Recruiter, }, { caseName: "agntcy agent directory by labels", input: { label1: "Directory", label2: "AGNTCY Agent Directory" }, - expected: "directory", + expected: TopologyNodeIconKind.Directory, }, { caseName: "coffee farm by labels", input: { label1: "Brazil", label2: "Coffee Farm Agent" }, - expected: "farm", + expected: TopologyNodeIconKind.Farm, }, { caseName: "auction agent by labels", input: { label1: "Auction Agent", label2: "Buyer" }, - expected: "supervisor", + expected: TopologyNodeIconKind.Supervisor, }, { caseName: "logistics buyer by labels", input: { label1: "Buyer", label2: "Logistics Agent" }, - expected: "supervisor", + expected: TopologyNodeIconKind.Supervisor, }, { caseName: "shipper by labels", input: { label1: "Shipper", label2: "Shipper Agent" }, - expected: "shipping", + expected: TopologyNodeIconKind.Shipping, }, { caseName: "accountant by labels", input: { label1: "Accountant", label2: "Accountant Agent" }, - expected: "accountant", + expected: TopologyNodeIconKind.Accountant, }, { caseName: "unknown -> default", input: { label1: "Mystery", label2: "Thing" }, - expected: "default", + expected: TopologyNodeIconKind.Default, }, { caseName: "empty input -> default", input: {}, - expected: "default", + expected: TopologyNodeIconKind.Default, }, ])("$caseName", ({ input, expected }) => { expect(topologyNodeIconKind(input)).toBe(expected) diff --git a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyNodeIcons.tsx b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyNodeIcons.tsx index 53a59b32..e2ab0eda 100644 --- a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyNodeIcons.tsx +++ b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyNodeIcons.tsx @@ -32,29 +32,44 @@ export interface TopologyNodeIconInput { directoryAgentSlug?: string } -export type TopologyNodeIconKind = - | "supervisor" - | "recruiter" - | "directory" - | "farm" - | "weatherMcp" - | "paymentMcp" - | "shipping" - | "accountant" - | "default" +export enum TopologyNodeIconKind { + Supervisor = "supervisor", + Recruiter = "recruiter", + Directory = "directory", + Farm = "farm", + WeatherMcp = "weatherMcp", + PaymentMcp = "paymentMcp", + Shipping = "shipping", + Accountant = "accountant", + Default = "default", +} function normalize(value: string | undefined): string { return typeof value === "string" ? value.trim().toLowerCase() : "" } function iconKindFromSlug(slug: string): TopologyNodeIconKind | null { - if (slug.includes("supervisor")) return "supervisor" - if (slug.includes("recruiter")) return "recruiter" - if (slug.includes("farm")) return "farm" - if (slug.includes("weather")) return "weatherMcp" - if (slug.includes("payment")) return "paymentMcp" - if (slug.includes("shipping")) return "shipping" - if (slug.includes("accountant")) return "accountant" + if (slug.includes(TopologyNodeIconKind.Supervisor)) { + return TopologyNodeIconKind.Supervisor + } + if (slug.includes(TopologyNodeIconKind.Recruiter)) { + return TopologyNodeIconKind.Recruiter + } + if (slug.includes(TopologyNodeIconKind.Farm)) { + return TopologyNodeIconKind.Farm + } + if (slug.includes("weather")) { + return TopologyNodeIconKind.WeatherMcp + } + if (slug.includes("payment")) { + return TopologyNodeIconKind.PaymentMcp + } + if (slug.includes(TopologyNodeIconKind.Shipping)) { + return TopologyNodeIconKind.Shipping + } + if (slug.includes(TopologyNodeIconKind.Accountant)) { + return TopologyNodeIconKind.Accountant + } return null } @@ -65,22 +80,22 @@ function iconKindFromLabels( const combined = `${label1} ${label2}`.trim() if (label2 === "mcp server" || label1.endsWith("mcp server")) { - if (label1.includes("weather")) return "weatherMcp" - if (label1.includes("payment")) return "paymentMcp" - return "default" + if (label1.includes("weather")) return TopologyNodeIconKind.WeatherMcp + if (label1.includes("payment")) return TopologyNodeIconKind.PaymentMcp + return TopologyNodeIconKind.Default } if (isRecruiterLabel(combined)) { - return "recruiter" + return TopologyNodeIconKind.Recruiter } if (label1 === "directory" || isDirectoryLabel(combined)) { - return "directory" + return TopologyNodeIconKind.Directory } - if (label2.includes("coffee farm")) return "farm" - if (label1 === "shipper") return "shipping" - if (label1 === "accountant") return "accountant" + if (label2.includes("coffee farm")) return TopologyNodeIconKind.Farm + if (label1 === "shipper") return TopologyNodeIconKind.Shipping + if (label1 === "accountant") return TopologyNodeIconKind.Accountant if ( label1 === "auction agent" || @@ -90,10 +105,10 @@ function iconKindFromLabels( label2.includes("logistics agent") || combined === "logistics group" ) { - return "supervisor" + return TopologyNodeIconKind.Supervisor } - return "default" + return TopologyNodeIconKind.Default } export function topologyNodeIconKind( @@ -115,21 +130,21 @@ export function resolveTopologyNodeIcon( input: TopologyNodeIconInput, ): React.ReactNode { switch (topologyNodeIconKind(input)) { - case "supervisor": + case TopologyNodeIconKind.Supervisor: return brandedImg(supervisorIcon, "Supervisor Icon") - case "recruiter": + case TopologyNodeIconKind.Recruiter: return brandedImg(supervisorIcon, "Recruiter Icon") - case "directory": + case TopologyNodeIconKind.Directory: return brandedImg(supervisorIcon, "Directory Icon") - case "farm": + case TopologyNodeIconKind.Farm: return brandedImg(farmAgentIcon, "Farm Agent Icon") - case "weatherMcp": + case TopologyNodeIconKind.WeatherMcp: return - case "paymentMcp": + case TopologyNodeIconKind.PaymentMcp: return - case "shipping": + case TopologyNodeIconKind.Shipping: return - case "accountant": + case TopologyNodeIconKind.Accountant: return default: return