Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})

Expand All @@ -36,3 +56,92 @@ describe("animationSequenceStepIds", () => {
expect(animationSequenceStepIds(GROUP_MESSAGING_CONFIG, 99)).toEqual([])
})
})

function liveNode(
id: string,
type: string,
data: Record<string, unknown>,
): 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([])
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string>> = {
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<Record<string, string>> = {
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<string, string> {
const map = new Map<string, string>()
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,
Expand All @@ -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
Expand All @@ -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<string, Edge[]>()
const inDegree = new Map<string, number>()
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<string>(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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -152,8 +159,7 @@ export function useMainArea({

useMainAreaGraphEffects({
pattern,
skipStaticGraphSync: agenticMode && !staticIdMapActive,
restoreEdgeAnimation: staticIdMapActive ? restoreEdgeAnimation : undefined,
skipStaticGraphSync: agenticMode,
isGroupCommConnected,
setNodes,
setEdges,
Expand Down Expand Up @@ -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,
])
Expand Down
Loading