diff --git a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/components/Chat/chatStreamGraphHighlight.test.ts b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/components/Chat/chatStreamGraphHighlight.test.ts index f67113fe..d6b14851 100644 --- a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/components/Chat/chatStreamGraphHighlight.test.ts +++ b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/components/Chat/chatStreamGraphHighlight.test.ts @@ -3,25 +3,45 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { Edge, Node } from "@xyflow/react" import { describe, expect, it } from "vitest" -import { GROUP_MESSAGING_CONFIG } from "@/utils/graphConfigsData" +import { + A2A_HTTP_CONFIG, + GROUP_MESSAGING_CONFIG, +} from "@/utils/graphConfigsData" import { NODE_IDS } from "@/utils/const" import { animationSequenceStepIds, + deriveAnimationSequenceFromGraph, resolveStreamAuthorToNodeId, } from "./chatStreamGraphHighlight" describe("resolveStreamAuthorToNodeId", () => { - it("maps recruiter service author to the recruiter node", () => { - expect(resolveStreamAuthorToNodeId("recruiter_service")).toBe( - NODE_IDS.RECRUITER, - ) + it.each([ + { + caseName: "recruiter author -> live recruiter node via slug", + author: "recruiter_service", + config: A2A_HTTP_CONFIG, + expected: NODE_IDS.RECRUITER, + }, + { + caseName: "directory author -> live directory node via slug", + author: "directory", + config: A2A_HTTP_CONFIG, + expected: NODE_IDS.DIRECTORY, + }, + { + caseName: "group supervisor -> buyer node via label fallback", + author: "Supervisor", + config: GROUP_MESSAGING_CONFIG, + expected: NODE_IDS.AUCTION_AGENT, + }, + ])("$caseName", ({ author, config, expected }) => { + expect(resolveStreamAuthorToNodeId(author, config)).toBe(expected) }) - it("maps group stream supervisor to the buyer node", () => { - expect( - resolveStreamAuthorToNodeId("Supervisor", GROUP_MESSAGING_CONFIG), - ).toBe(NODE_IDS.AUCTION_AGENT) + it("returns null without a live graph to resolve against", () => { + expect(resolveStreamAuthorToNodeId("recruiter_service")).toBeNull() }) }) @@ -36,3 +56,92 @@ describe("animationSequenceStepIds", () => { expect(animationSequenceStepIds(GROUP_MESSAGING_CONFIG, 99)).toEqual([]) }) }) + +function liveNode( + id: string, + type: string, + data: Record, +): Node { + return { id, type, position: { x: 0, y: 0 }, data } +} + +function liveEdge(id: string, source: string, target: string): Edge { + return { id, source, target } +} + +describe("deriveAnimationSequenceFromGraph", () => { + const nodes: Node[] = [ + liveNode("agent://auction", "customNode", {}), + liveNode("transport://slim", "transportNode", {}), + liveNode("agent://brazil", "customNode", {}), + liveNode("agent://colombia", "customNode", {}), + liveNode("agent://vietnam", "customNode", {}), + liveNode("agent://weather", "customNode", {}), + liveNode("agent://payment", "customNode", {}), + ] + const edges: Edge[] = [ + liveEdge("e-auction-transport", "agent://auction", "transport://slim"), + liveEdge("e-transport-brazil", "transport://slim", "agent://brazil"), + liveEdge("e-transport-colombia", "transport://slim", "agent://colombia"), + liveEdge("e-transport-vietnam", "transport://slim", "agent://vietnam"), + liveEdge("e-colombia-weather", "agent://colombia", "agent://weather"), + liveEdge("e-colombia-payment", "agent://colombia", "agent://payment"), + ] + + it("pulses root, fan-out edges, then each next layer", () => { + const seq = deriveAnimationSequenceFromGraph(nodes, edges) + expect(seq.map((step) => step.ids)).toEqual([ + ["agent://auction"], + ["e-auction-transport"], + ["transport://slim"], + ["e-transport-brazil", "e-transport-colombia", "e-transport-vietnam"], + ["agent://brazil", "agent://colombia", "agent://vietnam"], + ["e-colombia-weather", "e-colombia-payment"], + ["agent://weather", "agent://payment"], + ]) + }) + + it("returns empty without edges and skips isolated container nodes", () => { + expect(deriveAnimationSequenceFromGraph(nodes, [])).toEqual([]) + const withContainer = deriveAnimationSequenceFromGraph( + [liveNode("group://logistics", "group", {}), ...nodes], + edges, + ) + expect(withContainer[0].ids).toEqual(["agent://auction"]) + }) + + it("roots at a non-transport node (a2a recruiter -> directory chain)", () => { + const seq = deriveAnimationSequenceFromGraph( + [ + liveNode("agent://recruiter", "customNode", {}), + liveNode("agent://directory", "customNode", {}), + ], + [ + liveEdge( + "e-recruiter-directory", + "agent://recruiter", + "agent://directory", + ), + ], + ) + expect(seq.map((step) => step.ids)).toEqual([ + ["agent://recruiter"], + ["e-recruiter-directory"], + ["agent://directory"], + ]) + }) + + it("returns empty for a cyclic graph with no root", () => { + const seq = deriveAnimationSequenceFromGraph( + [ + liveNode("agent://a", "customNode", {}), + liveNode("agent://b", "customNode", {}), + ], + [ + liveEdge("e-a-b", "agent://a", "agent://b"), + liveEdge("e-b-a", "agent://b", "agent://a"), + ], + ) + expect(seq).toEqual([]) + }) +}) diff --git a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/components/Chat/chatStreamGraphHighlight.ts b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/components/Chat/chatStreamGraphHighlight.ts index f95c79c5..d5f54fc8 100644 --- a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/components/Chat/chatStreamGraphHighlight.ts +++ b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/components/Chat/chatStreamGraphHighlight.ts @@ -3,29 +3,75 @@ * SPDX-License-Identifier: Apache-2.0 * * Map chat stream authors / sequence steps to React Flow graph element ids. + * + * The backend owns the graph, so there are no authored NODE_IDS/EDGE_IDS to map + * from. Stream authors resolve through a slug bridge (recruiter/directory slug + * aliases, then live labels), and the streaming animation sequence is derived + * straight from the live graph topology rather than translated from a static one. */ +import type { Edge, Node } from "@xyflow/react" import type { GraphConfig } from "@/utils/graphConfigs" -import { NODE_IDS } from "@/utils/const" +import type { CustomNodeData } from "@/components/MainArea/Graph/Elements/types" +import { NODE_TYPES } from "@/utils/const" +import { + getOasfSlugFromNodeData, + isDirectoryLabel, +} from "@/utils/agenticTopologyIdentityUiMap" import { buildSenderToNodeMap } from "./groupCommunicationFeedMapping" function normalizeAuthor(value: string): string { return value.trim().toLowerCase() } -/** Known recruiter / directory author strings from the discovery stream. */ -const AUTHOR_ALIASES: Readonly> = { - recruiter_service: NODE_IDS.RECRUITER, - recruiter_supervisor: NODE_IDS.RECRUITER, - recruiteragent: NODE_IDS.RECRUITER, - "agentic recruiter": NODE_IDS.RECRUITER, - "agntcy agent directory": NODE_IDS.DIRECTORY, - directory: NODE_IDS.DIRECTORY, +/** Known recruiter / directory stream authors mapped to a canonical node slug. */ +const AUTHOR_SLUG_ALIASES: Readonly> = { + recruiter_service: "recruiter", + recruiter_supervisor: "recruiter", + recruiteragent: "recruiter", + "agentic recruiter": "recruiter", + "agntcy agent directory": "directory", + directory: "directory", +} + +/** + * Stable identity key shared by static-config and API-driven nodes. Transport and + * group nodes resolve by type; directory has no OASF slug, so it is detected from + * its labels; everything else defers to `getOasfSlugFromNodeData`. + */ +function nodeSlugKey(node: Node): string | null { + if (node.type === NODE_TYPES.TRANSPORT) return "transport" + if (node.type === NODE_TYPES.GROUP) return "logistics-group" + + const data = node.data as unknown as CustomNodeData | undefined + const label1 = data?.label1?.toLowerCase() ?? "" + const label2 = data?.label2?.toLowerCase() ?? "" + const combined = `${label1} ${label2}`.trim() + if (label1 === "directory" || isDirectoryLabel(combined)) { + return "directory" + } + + try { + return getOasfSlugFromNodeData(data) + } catch { + return null + } +} + +function slugToNodeIdMap( + graphConfig: GraphConfig | undefined, +): Map { + const map = new Map() + for (const node of graphConfig?.nodes ?? []) { + const slug = nodeSlugKey(node) + if (slug && !map.has(slug)) map.set(slug, node.id) + } + return map } /** - * Resolve a stream event author to a graph node id using live graph labels and - * recruiter-specific aliases. + * Resolve a stream event author to a graph node id using recruiter/directory + * slug aliases first, then live graph labels. */ export function resolveStreamAuthorToNodeId( author: string | undefined, @@ -34,8 +80,11 @@ export function resolveStreamAuthorToNodeId( if (!author?.trim()) return null const normalized = normalizeAuthor(author) - const alias = AUTHOR_ALIASES[normalized] - if (alias) return alias + const slug = AUTHOR_SLUG_ALIASES[normalized] + if (slug) { + const bySlug = slugToNodeIdMap(graphConfig).get(slug) + if (bySlug) return bySlug + } const labelMap = buildSenderToNodeMap(graphConfig) return labelMap[author] ?? labelMap[normalized] ?? null @@ -49,3 +98,54 @@ export function animationSequenceStepIds( const step = graphConfig?.animationSequence?.[stepIndex] return step?.ids ?? [] } + +/** + * Build a streaming animation sequence straight from the live graph: pulse the + * root node(s) (no inbound edges, at least one outbound), then the edges fanning + * out, then the next layer of nodes, and so on. Isolated nodes (e.g. the group + * container) are skipped. No authored sequence or static config required. + */ +export function deriveAnimationSequenceFromGraph( + nodes: Node[], + edges: Edge[], +): GraphConfig["animationSequence"] { + const outgoing = new Map() + const inDegree = new Map() + for (const node of nodes) inDegree.set(node.id, 0) + for (const edge of edges) { + const list = outgoing.get(edge.source) ?? [] + list.push(edge) + outgoing.set(edge.source, list) + inDegree.set(edge.target, (inDegree.get(edge.target) ?? 0) + 1) + } + + const roots = nodes + .filter( + (node) => (inDegree.get(node.id) ?? 0) === 0 && outgoing.has(node.id), + ) + .map((node) => node.id) + if (roots.length === 0) return [] + + const steps: { ids: string[] }[] = [{ ids: [...roots] }] + const visited = new Set(roots) + let frontier = roots + + while (frontier.length > 0) { + const edgeIds: string[] = [] + const nextNodes: string[] = [] + for (const nodeId of frontier) { + for (const edge of outgoing.get(nodeId) ?? []) { + if (visited.has(edge.target)) continue + edgeIds.push(edge.id) + if (!nextNodes.includes(edge.target)) nextNodes.push(edge.target) + } + } + for (const id of nextNodes) visited.add(id) + if (edgeIds.length > 0) steps.push({ ids: edgeIds }) + if (nextNodes.length === 0) break + steps.push({ ids: nextNodes }) + frontier = nextNodes + } + + return steps +} diff --git a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/components/MainArea/useMainArea.ts b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/components/MainArea/useMainArea.ts index f4ef8047..a441968c 100644 --- a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/components/MainArea/useMainArea.ts +++ b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/components/MainArea/useMainArea.ts @@ -5,11 +5,12 @@ import { useEffect, useRef, useCallback, useState, useMemo } from "react" import { useNodesState, useEdgesState } from "@xyflow/react" -import { PatternType } from "@/utils/patternUtils" +import { PatternType, isStreamingPattern } from "@/utils/patternUtils" import { getGraphConfig } from "@/utils/graphConfigs" import { useViewportAwareFitView } from "@/hooks/useViewportAwareFitView" import { useModalManager } from "@/hooks/useModalManager" import { NODE_IDS } from "@/utils/const.ts" +import { applyDynamicTransportLabels } from "@/utils/dynamicTransportLabels" import type { DiscoveryResponseEvent } from "@/types/agent" import type { CustomNodeData } from "./Graph/Elements/types" import { useMainAreaDiscoveryGraph } from "./useMainAreaDiscoveryGraph" @@ -18,6 +19,7 @@ import { useWorkflowGraphFromAgenticApi } from "@/hooks/useWorkflowGraphFromAgen import type { WorkflowSummary } from "@/utils/agenticWorkflowsApi" import type { GraphConfig } from "@/utils/graphConfigs" import { graphConfigFromNodes } from "@/utils/graphConfigFromNodes" +import { deriveAnimationSequenceFromGraph } from "@/components/Chat/chatStreamGraphHighlight" import { logger } from "@/utils/logger" export interface MainAreaProps { @@ -87,20 +89,25 @@ export function useMainArea({ setOasfModalOpen(true) }, []) - const { agenticMode, agenticError, staticIdMapActive, restoreEdgeAnimation } = - useWorkflowGraphFromAgenticApi({ - pattern, - selectedWorkflowSummary, - setNodes, - setEdges, - handleOpenIdentityModal, - handleOpenOasfModal, - onTopologyApplied: () => { - setTimeout(() => { - fitViewWithViewport() - }, 200) - }, - }) + const { agenticMode, agenticError } = useWorkflowGraphFromAgenticApi({ + pattern, + selectedWorkflowSummary, + setNodes, + setEdges, + handleOpenIdentityModal, + handleOpenOasfModal, + onTopologyApplied: () => { + setTimeout(() => { + fitViewWithViewport() + }, 200) + void applyDynamicTransportLabels( + setNodes, + setEdges, + pattern, + isStreamingPattern(pattern), + ) + }, + }) useEffect(() => { if (!agenticError) return @@ -152,8 +159,7 @@ export function useMainArea({ useMainAreaGraphEffects({ pattern, - skipStaticGraphSync: agenticMode && !staticIdMapActive, - restoreEdgeAnimation: staticIdMapActive ? restoreEdgeAnimation : undefined, + skipStaticGraphSync: agenticMode, isGroupCommConnected, setNodes, setEdges, @@ -234,16 +240,17 @@ export function useMainArea({ agenticMode && selectedWorkflowSummary ? `${selectedWorkflowSummary.name} — ${selectedWorkflowSummary.scenario}` : config.title + const animationSequence = agenticMode + ? deriveAnimationSequenceFromGraph(nodes, edges) + : config.animationSequence onLiveGraphConfig( - graphConfigFromNodes(title, nodes, edges, config.animationSequence), + graphConfigFromNodes(title, nodes, edges, animationSequence), ) }, [ onLiveGraphConfig, agenticMode, - pattern, selectedWorkflowSummary, - config.title, - config.animationSequence, + config, nodes, edges, ]) diff --git a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/hooks/useWorkflowGraphFromAgenticApi.ts b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/hooks/useWorkflowGraphFromAgenticApi.ts index 0a729544..e159705c 100644 --- a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/hooks/useWorkflowGraphFromAgenticApi.ts +++ b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/hooks/useWorkflowGraphFromAgenticApi.ts @@ -15,12 +15,10 @@ import { getAgenticWorkflowsApiUrl } from "@/urls" import { mapWorkflowNameToSlug } from "@/utils/agenticWorkflowsApi" import { useActiveWorkflowInstanceStore } from "@/stores/activeWorkflowInstanceStore" import { logger } from "@/utils/logger" -import { staticIdMapForPattern } from "@/utils/topologyStaticIdMap" import { extractInstanceTopologyFromEvent, messagingHighlightIdsFromTopology, patchGraphActiveHighlight, - staticGraphHighlightIdsFromTopology, } from "@/utils/workflowEventMessagingHighlight" import { useWorkflowGraphAgenticBootstrap } from "./useWorkflowGraphAgenticBootstrap" import { useWorkflowGraphMessagingHighlight } from "./useWorkflowGraphMessagingHighlight" @@ -56,10 +54,6 @@ export function useWorkflowGraphFromAgenticApi({ mapWorkflowNameToSlug(workflowName) === pattern, ) - const staticIdMap = useMemo(() => staticIdMapForPattern(pattern), [pattern]) - const staticIdMapRef = useRef(staticIdMap) - staticIdMapRef.current = staticIdMap - const [agenticError, setAgenticError] = useState(null) const workflowInstanceId = useActiveWorkflowInstanceStore( (s) => s.workflowInstanceId, @@ -101,7 +95,6 @@ export function useWorkflowGraphFromAgenticApi({ const { applyInstanceTopologyRef, scheduleTopologyRefetchRef } = useWorkflowGraphTopologySync({ patternRef, - staticIdMapRef, sessionRef, onAppliedRef, attachHandlers, @@ -145,10 +138,7 @@ export function useWorkflowGraphFromAgenticApi({ catalogWorkflowName, instanceId, ) - const idMap = staticIdMapRef.current - const ids = idMap - ? staticGraphHighlightIdsFromTopology(partial, idMap) - : messagingHighlightIdsFromTopology(partial) + const ids = messagingHighlightIdsFromTopology(partial) const hasAny = ids.nodeIds.size > 0 || ids.edgeIds.size > 0 if (hasAny) { lastMessagingHighlightRef.current = { @@ -175,9 +165,7 @@ export function useWorkflowGraphFromAgenticApi({ scheduleMessagingHighlightTtl() } - if (!staticIdMapRef.current) { - scheduleTopologyRefetchRef.current() - } + scheduleTopologyRefetchRef.current() }, [ lastMessagingHighlightRef, @@ -209,7 +197,5 @@ export function useWorkflowGraphFromAgenticApi({ agenticMode, agenticError, workflowInstanceId, - staticIdMapActive: staticIdMap !== null, - restoreEdgeAnimation, } } diff --git a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/hooks/useWorkflowGraphFromAgenticApi.types.ts b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/hooks/useWorkflowGraphFromAgenticApi.types.ts index 113b2875..be564437 100644 --- a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/hooks/useWorkflowGraphFromAgenticApi.types.ts +++ b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/hooks/useWorkflowGraphFromAgenticApi.types.ts @@ -47,15 +47,6 @@ export interface UseWorkflowGraphFromAgenticApiResult { agenticError: string | null /** Active workflow instance id (e.g. ``instance://``) once instantiated. */ workflowInstanceId: string | null - /** - * True when this pattern has a static graph in `graphConfigsData` and the - * hook is deferring all positioning to it. Callers should still run their - * normal static-graph reconciliation (config sync on pattern change) even - * though `agenticMode` is true. - */ - staticIdMapActive: boolean - /** Restore edge animation after a static-config rebuild wipes it. */ - restoreEdgeAnimation: () => void } export type WorkflowGraphAgenticSession = { diff --git a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/hooks/useWorkflowGraphTopologySync.ts b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/hooks/useWorkflowGraphTopologySync.ts index 518e3fbc..dfa52b77 100644 --- a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/hooks/useWorkflowGraphTopologySync.ts +++ b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/hooks/useWorkflowGraphTopologySync.ts @@ -13,7 +13,6 @@ import type { TopologyWire } from "@/api/agenticWorkflowsTypes" import { logger } from "@/utils/logger" import { identityUiVariantForPattern } from "@/utils/agenticTopologyIdentityUiMap" import { topologyWireToReactFlow } from "@/utils/topologyToReactFlow" -import type { StaticIdMap } from "@/utils/topologyStaticIdMap" import type { PatternType } from "@/utils/patternUtils" import { mergeDiscoveryEdges, @@ -24,7 +23,6 @@ import { interface UseWorkflowGraphTopologySyncParams { patternRef: React.RefObject - staticIdMapRef: React.RefObject sessionRef: React.RefObject onAppliedRef: React.RefObject<(() => void) | undefined> attachHandlers: (node: Node) => Node @@ -35,7 +33,6 @@ interface UseWorkflowGraphTopologySyncParams { export function useWorkflowGraphTopologySync({ patternRef, - staticIdMapRef, sessionRef, onAppliedRef, attachHandlers, @@ -47,13 +44,6 @@ export function useWorkflowGraphTopologySync({ const applyInstanceTopology = useCallback( (topology: TopologyWire | undefined) => { - if (staticIdMapRef.current) { - onAppliedRef.current?.() - queueMicrotask(() => { - restoreEdgeAnimation() - }) - return - } const { nodes: mappedNodes, edges: mappedEdges } = topologyWireToReactFlow(topology, { identityUiVariant: identityUiVariantForPattern(patternRef.current), @@ -77,7 +67,6 @@ export function useWorkflowGraphTopologySync({ restoreEdgeAnimation, setEdges, setNodes, - staticIdMapRef, ], ) diff --git a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/dynamicTransportLabels.test.ts b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/dynamicTransportLabels.test.ts new file mode 100644 index 00000000..ae4ebfbb --- /dev/null +++ b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/dynamicTransportLabels.test.ts @@ -0,0 +1,152 @@ +/** + * Copyright AGNTCY Contributors (https://github.com/agntcy) + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Edge, Node } from "@xyflow/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { LUNGO_FRONTEND_URLS } from "@/urls" +import { EDGE_LABELS } from "@/utils/const" +import { + applyDynamicTransportLabels, + transportGithubLink, +} from "@/utils/dynamicTransportLabels" + +const SDK = LUNGO_FRONTEND_URLS.github.appSdkBaseUrl +const REGULAR = LUNGO_FRONTEND_URLS.github.transports.regular + +describe("transportGithubLink", () => { + it.each([ + { + caseName: "SLIM regular -> slim link", + transport: "SLIM", + isStreaming: false, + expected: `${SDK}${REGULAR.slim}`, + }, + { + caseName: "NATS regular -> nats link", + transport: "NATS", + isStreaming: false, + expected: `${SDK}${REGULAR.nats}`, + }, + { + caseName: "SLIM streaming -> streaming slim link", + transport: "SLIM", + isStreaming: true, + expected: `${SDK}${LUNGO_FRONTEND_URLS.github.transports.streaming.slim}`, + }, + { + caseName: "unknown transport -> general link", + transport: "KAFKA", + isStreaming: false, + expected: `${SDK}${LUNGO_FRONTEND_URLS.github.transports.general}`, + }, + ])("$caseName", ({ transport, isStreaming, expected }) => { + expect(transportGithubLink(transport, isStreaming)).toBe(expected) + }) +}) + +function transportNode(id: string): Node { + return { + id, + type: "transportNode", + position: { x: 0, y: 0 }, + data: { label: "Transport" }, + } +} + +function mcpEdge(id: string): Edge { + return { id, source: "a", target: "b", data: { label: "MCP: " } } +} + +function stdioEdge(id: string): Edge { + return { + id, + source: "a", + target: "b", + data: { label: EDGE_LABELS.MCP_WITH_STDIO }, + } +} + +describe("applyDynamicTransportLabels", () => { + let fetchMock: ReturnType + + beforeEach(() => { + fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ transport: "SLIM" }), + })) + vi.stubGlobal("fetch", fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it("skips patterns without a transport config endpoint", async () => { + let nodes: Node[] = [transportNode("t")] + const setNodes = (updater: (n: Node[]) => Node[]) => { + nodes = updater(nodes) + } + const setEdges = (updater: (e: Edge[]) => Edge[]) => updater([]) + + await applyDynamicTransportLabels(setNodes, setEdges, "group_messaging") + + expect(fetchMock).not.toHaveBeenCalled() + expect(nodes[0].data.label).toBe("Transport") + }) + + it("patches transport nodes + MCP edges and caches the fetch", async () => { + let nodes: Node[] = [transportNode("transport://transport")] + let edges: Edge[] = [mcpEdge("e1")] + const setNodes = (updater: (n: Node[]) => Node[]) => { + nodes = updater(nodes) + } + const setEdges = (updater: (e: Edge[]) => Edge[]) => { + edges = updater(edges) + } + + await applyDynamicTransportLabels( + setNodes, + setEdges, + "publish_subscribe", + false, + ) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(nodes[0].data.label).toBe("Transport: SLIM") + expect(nodes[0].data.githubLink).toBe(`${SDK}${REGULAR.slim}`) + expect(edges[0].data?.label).toBe("MCP: SLIM") + + const nodesRef = nodes + const edgesRef = edges + await applyDynamicTransportLabels( + setNodes, + setEdges, + "publish_subscribe", + false, + ) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(nodes).toBe(nodesRef) + expect(edges).toBe(edgesRef) + }) + + it("patches plain MCP edges but leaves the MCP_WITH_STDIO label intact", async () => { + let edges: Edge[] = [mcpEdge("plain"), stdioEdge("stdio")] + const setNodes = (updater: (n: Node[]) => Node[]) => updater([]) + const setEdges = (updater: (e: Edge[]) => Edge[]) => { + edges = updater(edges) + } + + await applyDynamicTransportLabels( + setNodes, + setEdges, + "publish_subscribe", + false, + ) + + expect(edges[0].data?.label).toBe("MCP: SLIM") + expect(edges[1].data?.label).toBe(EDGE_LABELS.MCP_WITH_STDIO) + }) +}) diff --git a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/dynamicTransportLabels.ts b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/dynamicTransportLabels.ts new file mode 100644 index 00000000..394f0213 --- /dev/null +++ b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/dynamicTransportLabels.ts @@ -0,0 +1,144 @@ +/** + * Copyright AGNTCY Contributors (https://github.com/agntcy) + * SPDX-License-Identifier: Apache-2.0 + * + * ----------------------------------------------------------------------------- + * Transport-name patch for the API-driven graph. + * + * Mirrors `updateTransportLabels`, but targets dynamically rendered nodes/edges: + * transport nodes by React Flow type and MCP edges by their "MCP: " label prefix + * instead of the authored static ids that no longer exist once the backend owns + * the topology. The transport config is immutable per deployment, so the fetch + * result is cached and the state patch is idempotent (returns the same array when + * nothing changes) to stay cheap when called after every topology apply. + * ----------------------------------------------------------------------------- + */ + +import type { Edge, Node } from "@xyflow/react" +import { EDGE_LABELS, NODE_TYPES } from "./const" +import { logger } from "./logger" +import { joinBaseUrl, LUNGO_FRONTEND_URLS } from "@/urls" +import { getApiUrlForPattern, supportsTransportUpdates } from "./patternUtils" + +// Cache the in-flight (and resolved) fetch promise per url so overlapping +// topology applies share one request; evict on failure/empty so a later apply +// can retry. +const transportNameByUrl = new Map>() + +export function transportGithubLink( + transport: string, + isStreaming: boolean, +): string { + const transportUrls = isStreaming + ? LUNGO_FRONTEND_URLS.github.transports.streaming + : LUNGO_FRONTEND_URLS.github.transports.regular + if (transport === "SLIM") { + return `${LUNGO_FRONTEND_URLS.github.appSdkBaseUrl}${transportUrls.slim}` + } + if (transport === "NATS") { + return `${LUNGO_FRONTEND_URLS.github.appSdkBaseUrl}${transportUrls.nats}` + } + return `${LUNGO_FRONTEND_URLS.github.appSdkBaseUrl}${LUNGO_FRONTEND_URLS.github.transports.general}` +} + +async function fetchTransportName(url: string): Promise { + const response = await fetch(url) + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + const data = await response.json() + const transport = data.transport + if (typeof transport !== "string" || transport.length === 0) return null + return transport +} + +function evictOnEmpty(url: string, name: string | null): void { + if (!name) transportNameByUrl.delete(url) +} + +function evictOnError(url: string): void { + transportNameByUrl.delete(url) +} + +function resolveTransportName(url: string): Promise { + const inflight = transportNameByUrl.get(url) + if (inflight) return inflight + const pending = fetchTransportName(url) + transportNameByUrl.set(url, pending) + pending.then( + (name) => evictOnEmpty(url, name), + () => evictOnError(url), + ) + return pending +} + +function patchTransportNodes( + setNodes: (updater: (nodes: Node[]) => Node[]) => void, + label: string, + githubLink: string, +): void { + setNodes((nodes) => { + let changed = false + const next = nodes.map((node) => { + if (node.type !== NODE_TYPES.TRANSPORT) return node + const data = node.data as { label?: unknown; githubLink?: unknown } + if (data.label === label && data.githubLink === githubLink) return node + changed = true + return { ...node, data: { ...node.data, label, githubLink } } + }) + return changed ? next : nodes + }) +} + +function patchMcpEdges( + setEdges: (updater: (edges: Edge[]) => Edge[]) => void, + mcpLabel: string, +): void { + setEdges((edges) => { + let changed = false + const next = edges.map((edge) => { + const label = (edge.data as { label?: unknown } | undefined)?.label + if (typeof label !== "string" || !label.startsWith(EDGE_LABELS.MCP)) { + return edge + } + // MCP_WITH_STDIO also starts with the "MCP: " prefix but is a distinct + // A2A edge label, not a transport-named MCP edge; leave it untouched. + if (label === EDGE_LABELS.MCP_WITH_STDIO) return edge + if (label === mcpLabel) return edge + changed = true + return { ...edge, data: { ...edge.data, label: mcpLabel } } + }) + return changed ? next : edges + }) +} + +/** + * Fetch the live transport name and stamp it onto dynamic transport nodes and + * MCP edges. No-op for patterns without a transport config endpoint. + */ +export async function applyDynamicTransportLabels( + setNodes: (updater: (nodes: Node[]) => Node[]) => void, + setEdges: (updater: (edges: Edge[]) => Edge[]) => void, + pattern?: string, + isStreaming?: boolean, +): Promise { + if (!supportsTransportUpdates(pattern)) return + + const url = joinBaseUrl( + getApiUrlForPattern(pattern), + LUNGO_FRONTEND_URLS.apiPaths.transportConfig, + ) + + try { + const transport = await resolveTransportName(url) + if (!transport) return + patchTransportNodes( + setNodes, + `Transport: ${transport}`, + transportGithubLink(transport, Boolean(isStreaming)), + ) + patchMcpEdges(setEdges, `${EDGE_LABELS.MCP}${transport}`) + } catch (error) { + logger.apiError(LUNGO_FRONTEND_URLS.apiPaths.transportConfig, error) + } +} diff --git a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyStaticIdMap.ts b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyStaticIdMap.ts deleted file mode 100644 index 0056c1b5..00000000 --- a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/topologyStaticIdMap.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Copyright AGNTCY Contributors (https://github.com/agntcy) - * SPDX-License-Identifier: Apache-2.0 - * - * ----------------------------------------------------------------------------- - * TEMPORARY: Wire-id → static NODE_ID map for patterns with a hand-tuned graph. - * - * Bridges dynamic wire ids (stable_agent_id, transport canonical key, label) - * to the NODE_IDS authored in `graphConfigs.ts` / `graphConfigsData.tsx`, so - * SSE-driven highlights/animations land on the statically-rendered nodes and - * edges. - * - * Longer term, the four patterns shipped with static configs should render - * directly from the Agentic Workflows API topology (positions, labels, icons - * all wire-supplied). At that point the wire id == the rendered id, this - * module is no longer needed, and `graphConfigs.ts` / `graphConfigsData.tsx` - * can be removed or reduced to animation sequences only. - * ----------------------------------------------------------------------------- - */ - -import { stableAgentUuidForRecordName } from "@/utils/agenticTopologyIdentityUiMap" -import { PATTERNS, type PatternType } from "@/utils/patternUtils" - -export interface StaticIdMap { - /** UUID (no `agent://` prefix) -> static NODE_IDS value. */ - idByStableAgentUuid: ReadonlyMap - /** Lowercased wire `label` -> static NODE_IDS value (no stable_agent_id only). */ - idByLabel: ReadonlyMap - /** Transport canonical key (e.g. `"transport"`) -> static NODE_IDS value. */ - idByTransportKey: ReadonlyMap -} - -function lc(s: string): string { - return s.trim().toLowerCase() -} - -/** Build the P/S id map (also serves PUBLISH_SUBSCRIBE_STREAMING). */ -function buildPublishSubscribeIdMap(): StaticIdMap { - // OASF agent record top-level names; stable_agent_id derives from these via - // `stableAgentUuidForRecordName`. Must stay in sync with the OASF JSON files - // under `agents/supervisors/*/oasf/agents/*.json` and the backend in - // `api/agentic_workflows/workflows.py`. - const idByStableAgentUuid = new Map([ - [stableAgentUuidForRecordName("Auction Supervisor agent"), "1"], - [stableAgentUuidForRecordName("Brazil Coffee Farm"), "3"], - [stableAgentUuidForRecordName("Colombia Coffee Farm"), "4"], - [stableAgentUuidForRecordName("Vietnam Coffee Farm"), "5"], - [stableAgentUuidForRecordName("Weather MCP Server"), "6"], - [stableAgentUuidForRecordName("Payment MCP Server"), "7"], - ]) - const idByLabel = new Map([ - // Defensive label fallbacks if the wire lacks a stable_agent_id. - [lc("Auction Agent"), "1"], - [lc("Weather MCP Server"), "6"], - [lc("Payment MCP Server"), "7"], - ]) - const idByTransportKey = new Map([["transport", "2"]]) - return { - idByStableAgentUuid, - idByLabel, - idByTransportKey, - } -} - -function buildGroupCommunicationIdMap(): StaticIdMap { - const idByStableAgentUuid = new Map([ - [stableAgentUuidForRecordName("Logistics Supervisor agent"), "1"], - [stableAgentUuidForRecordName("Tatooine Farm agent"), "3"], - [stableAgentUuidForRecordName("Shipping agent"), "4"], - [stableAgentUuidForRecordName("Accountant agent"), "5"], - ]) - const idByLabel = new Map([ - [lc("Buyer Logistics Agent"), "1"], - [lc("Shipper Agent"), "4"], - [lc("Accountant Agent"), "5"], - // The group container has no stable_agent_id; match by label only. - [lc("Logistics Group"), "logistics-group"], - ]) - const idByTransportKey = new Map([["transport", "2"]]) - return { - idByStableAgentUuid, - idByLabel, - idByTransportKey, - } -} - -function buildDiscoveryIdMap(): StaticIdMap { - // Recruiter middleware may emit the OASF record name or the A2A service - // card name; both collapse to the static recruiter node. - const idByStableAgentUuid = new Map([ - [ - stableAgentUuidForRecordName("Agentic Recruiter agent"), - "recruiter-agent", - ], - [stableAgentUuidForRecordName("RecruiterAgent"), "recruiter-agent"], - ]) - const idByLabel = new Map([ - [lc("Agentic Recruiter"), "recruiter-agent"], - // No OASF record for the directory; resolved by label only. - [lc("AGNTCY Agent Directory"), "agntcy-directory"], - ]) - // Discovery pattern has no transport node in the static config. - const idByTransportKey = new Map() - return { - idByStableAgentUuid, - idByLabel, - idByTransportKey, - } -} - -/** - * Return an id map for the given pattern, or null when the dynamic - * grid-based layout should be used as-is (no static config to honour). - */ -export function staticIdMapForPattern( - pattern: PatternType, -): StaticIdMap | null { - switch (pattern) { - case PATTERNS.PUBLISH_SUBSCRIBE: - case PATTERNS.PUBLISH_SUBSCRIBE_STREAMING: - return buildPublishSubscribeIdMap() - case PATTERNS.GROUP_MESSAGING: - return buildGroupCommunicationIdMap() - case PATTERNS.A2A_HTTP: - return buildDiscoveryIdMap() - default: - return null - } -} diff --git a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/workflowEventMessagingHighlight.ts b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/workflowEventMessagingHighlight.ts index 1f88eb0a..bb5a709f 100644 --- a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/workflowEventMessagingHighlight.ts +++ b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/utils/workflowEventMessagingHighlight.ts @@ -13,8 +13,6 @@ import { extractStableAgentId, transportCanonicalRfId, } from "@/utils/topologyToReactFlow" -import type { StaticIdMap } from "@/utils/topologyStaticIdMap" -import { parseStableAgentUuid } from "@/utils/agenticTopologyIdentityUiMap" export interface MessagingHighlightIds { nodeIds: ReadonlySet @@ -36,11 +34,6 @@ function hasText(value: unknown): value is string { return typeof value === "string" && value.length > 0 } -/** Keep label fallback lookups consistent with the static id maps. */ -function normalizedLabel(value: unknown): string { - return typeof value === "string" ? value.trim().toLowerCase() : "" -} - /** * React Flow edge ids can drift, so source/target pairs provide a stable backup key. */ @@ -61,30 +54,6 @@ function resolveDynamicNodeId(node: TopologyNodeWire): string | null { return hasText(node.id) ? node.id : null } -/** Translate wire ids into authored static graph ids used by legacy patterns. */ -function resolveStaticGraphNodeId( - node: TopologyNodeWire, - idMap: StaticIdMap, -): string | null { - const stableAgentId = extractStableAgentId(node) - if (stableAgentId) { - const uuid = parseStableAgentUuid(stableAgentId) - const staticId = uuid ? idMap.idByStableAgentUuid.get(uuid) : undefined - return staticId ?? stableAgentId - } - - if (node.type === TRANSPORT_NODE_TYPE) { - const canonicalId = transportCanonicalRfId(node.label) - const transportKey = canonicalId.replace(/^transport:\/\//, "") - return idMap.idByTransportKey.get(transportKey) ?? canonicalId - } - - const labelId = idMap.idByLabel.get(normalizedLabel(node.label)) - if (labelId !== undefined) return labelId - - return hasText(node.id) ? node.id : null -} - /** Shared topology traversal; callers only choose how each node id is resolved. */ function collectMessagingHighlightIds( topology: TopologyWire | null | undefined, @@ -169,15 +138,3 @@ export function patchGraphActiveHighlight( }) return { nodes: nextNodes, edges: nextEdges } } - -/** - * Build highlight ids in the static graph id namespace when a pattern uses authored nodes. - */ -export function staticGraphHighlightIdsFromTopology( - topology: TopologyWire | null | undefined, - idMap: StaticIdMap, -): MessagingHighlightIds { - return collectMessagingHighlightIds(topology, (node) => - resolveStaticGraphNodeId(node, idMap), - ) -}