diff --git a/packages/apollo-react/package.json b/packages/apollo-react/package.json index 7f6df523ea..d3de446ebe 100644 --- a/packages/apollo-react/package.json +++ b/packages/apollo-react/package.json @@ -123,6 +123,11 @@ "import": "./dist/canvas/constants.js", "require": "./dist/canvas/constants.cjs" }, + "./canvas/guardrails": { + "types": "./dist/canvas/components/Guardrails/index.d.ts", + "import": "./dist/canvas/components/Guardrails/index.js", + "require": "./dist/canvas/components/Guardrails/index.cjs" + }, "./canvas/xyflow/*.css": "./dist/canvas/xyflow/*.css", "./canvas/styles/*.css": "./dist/canvas/styles/*.css" }, @@ -171,6 +176,7 @@ }, "dependencies": { "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@emotion/cache": "^11.14.0", @@ -211,6 +217,7 @@ "@uipath/apollo-wind": "workspace:*", "@xyflow/react": "12.8.2", "@xyflow/system": "0.0.66", + "class-variance-authority": "^0.7.1", "d3-hierarchy": "^3.1.2", "d3-sankey": "^0.12.3", "d3-scale": "^4.0.2", @@ -267,6 +274,7 @@ "@types/d3-scale-chromatic": "^3.0.3", "@types/d3-selection": "^3.0.11", "@types/d3-zoom": "^3.0.8", + "@types/jest-axe": "^3.5.9", "@types/lodash": "^4.17.21", "@types/luxon": "^3.7.1", "@types/mdast": "^4.0.4", @@ -284,6 +292,7 @@ "esbuild": "^0.28.1", "glob": "^13.0.0", "happy-dom": "^20.0.0", + "jest-axe": "^10.0.0", "react": "19.2.3", "react-dom": "19.2.3", "typescript": "^5.9.3", diff --git a/packages/apollo-react/rslib.config.ts b/packages/apollo-react/rslib.config.ts index d1999eae4c..cfd4500446 100644 --- a/packages/apollo-react/rslib.config.ts +++ b/packages/apollo-react/rslib.config.ts @@ -55,6 +55,10 @@ export default defineConfig({ '!./src/**/*.test.{ts,tsx}', '!./src/**/*.stories.{ts,tsx}', '!./src/**/storybook-utils/**', + // Test-only, like `src/test/**`: fixtures are data for the suites, not API, and they + // reach for devDependencies (the dnd geometry helper needs `@testing-library/react`), + // so there is nothing to gain from publishing them. + '!./src/**/__fixtures__/**', '!./src/test/**', '!./src/icons/.cache', '!./src/**/*.md', diff --git a/packages/apollo-react/src/canvas/components/Guardrails/README.md b/packages/apollo-react/src/canvas/components/Guardrails/README.md new file mode 100644 index 0000000000..b599e8ef29 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/README.md @@ -0,0 +1,514 @@ +# Guardrails components + +Shared UI for the UiPath Guardrails experience, consumed by Flow (flow-workbench) and, in a +later stage, Agents (`frontend-sw`). Lives in apollo-react next to canvas — MUI-free, built +entirely on `@uipath/apollo-wind` primitives and its `forms/` engine, strings on lingui — +and is exported through the narrow `@uipath/apollo-react/canvas/guardrails` subpath (also +re-exported from `./canvas`). Members: the definitions layer (wire types, parser, canonical +copy and `useGuardrailDefinitions`), `GuardrailList` (the applied-guardrails section), +`GuardrailPalette` (the add-guardrail picker), `GuardrailBuilder` (the whole Add/Edit +screen), `GuardrailFormLayout` (the screen shell), and `GuardrailValidatorForm` (the +validator parameter section, also rendered inside the builder), plus the leaves the +sections compose: `GuardrailStatusChip`, `GuardrailStatusBanner` and `MixedScopesBanner`. + +## Definitions layer + +Turns the `GET /api/execution/guardrails/definitions` payload into the +`GuardrailDefinition`s the builder renders. Three pure steps and one hook over them: + +``` +unknown payload → parseGuardrailDefinitions → enrichGuardrailDefinitions → GuardrailBuilder + (zod, private) (canonical copy on lingui) + useGuardrailDefinitions composes all three +``` + +```tsx +import { useGuardrailDefinitions } from '@uipath/apollo-react/canvas/guardrails'; + +const { definitions, invalid, loading, error, refetch } = useGuardrailDefinitions({ + baseUrl: `/${orgName}/${tenantName}/agents_`, // omit for same-origin + tenantId, +}); +``` + +Three host shapes, all supported: + +| Host | Call | +| --- | --- | +| Owns no transport | `useGuardrailDefinitions({ baseUrl, tenantId })` | +| Already has SWR or React Query | `useGuardrailDefinitions(null, { definitions: data })` | +| Never fetches (Flow's vsix, over postMessage) | `useGuardrailDefinitions(null, { definitions: fromMessage })` | + +`options.definitions` wins over the context: when it is present no request is made at all, and +the value is parsed and enriched instead. That is the seam that lets a product keep its own +cache rather than adopting a second one, and it is why the hook stays a `useState` plus +`fetch` plus `AbortController` (the `useDiscoveryModels` idiom) instead of a query library. + +### Contract + +- **The parser never throws.** `parseGuardrailDefinitions(unknown)` returns + `{ definitions, invalid, inputError? }`. A payload that is not an array sets `inputError`; + an individual definition that fails validation is dropped whole and listed in `invalid`, + which is what both products already do entry by entry. Unknown keys are stripped. Surface + `invalid` as a status banner, never as an error page: the other definitions are fine. +- **Transport errors and data errors are different channels.** `error` is a failed request. + A malformed payload arrives through `invalid` / `inputError` with `error` still `null`. +- **zod does not cross the boundary.** The schema is private to `definitions-parse.ts`; + `GuardrailDefinitionWire` is hand-written, and the two are pinned to each other by a + compile-time assignability check, a runtime key-set assertion and a source-level guard, so + the emitted `.d.ts` for this folder carries no schema types and consumers take no zod + dependency. +- **Enrichment is pure and exported.** `enrichGuardrailDefinitions(wire, { copy, hiddenValidators })` + is React-free, so non-React and bridge callers use it directly. + `EnrichedGuardrailDefinition extends GuardrailDefinition`, so its output feeds + `GuardrailBuilder` with no mapping. +- **Context and `hiddenValidators` are compared by content, not identity**, so a host can + build them inline. (`useDiscoveryModels` compares the context by identity; an inline object + there refetches on every render and never settles.) +- **`hiddenValidators` hides nothing by default** and never hides a BYO definition. Which + validators a product exposes is an entitlement decision, so it stays with the caller: Flow + passes `['prompt_injection']`, Agents passes nothing. +- **BYO folder placement stays host-side.** Resolving it needs each product's connections API + (Agents pages `fetchResources`, Flow calls `getConnectionById`), so the hook does not reach + for it. Stamp the result on afterwards: + + ```ts + const withFolders = withGuardrailFolderMetadata(definitions, (id) => connections.get(id)); + ``` + +### Canonical copy + +The display copy for the six built-in validators lives here, as lingui messages in the shared +canvas catalog, rather than in each product's own table. Both products get the same wording +and the same translations, and the strings sit in the real localization pipeline. + +Message ids use the raw wire values, never a transcribed slug: + +``` +guardrails.definitions..display-name | .description | .usage-note +guardrails.definitions..param..label | .tooltip +guardrails.definitions..option.. +``` + +Transcribing is exactly how the two products ended up keying the same Finland entity as +`finNationalId` and `fiNationalId`; `USSocialSecurityNumber` is the value we persist, so it is +also the id. + +Copy precedence, unchanged from what both products already do: + +| level | non-BYO | BYO | +| --- | --- | --- | +| display name | curated, wire, `validator` | wire, `validator` | +| description | curated, wire, `''` | wire, `''` | +| usage note | curated only | none | +| parameter label | **wire**, curated, humanized id | wire, humanized id | +| parameter tooltip | **wire**, curated | wire | +| option labels | curated merged under wire | wire only | + +Curated wins at definition level because that table is what product and localization review; +wire wins at parameter level because a BYO manifest and a newly shipped backend parameter +describe themselves. A BYO definition takes no curated copy at any level, even when its +validator id collides with a UiPath one. + +Where the two products' English differed, the choice is declared with a reason in +`definitions-parity.test.ts` (17 entries) and asserted against both products' transcribed +copy in `__fixtures__/host-copy-baselines.ts`. That suite fails on an undeclared difference, +a stale declaration, or a third wording we invented, so the table cannot quietly drift from +the products it is meant to replace. + +`GUARDRAIL_COPY_EN` is the English table the pure layer defaults to; +`GUARDRAIL_COPY_EN_MESSAGES` is the same copy flattened to id-to-English, exported so hosts +can diff their remaining local tables against it in CI while they migrate off them. + +> `src/canvas` uses no lingui macros, so `lingui extract` does not feed this catalog: its +> entries are hand-authored. A test asserts every message reaches `src/canvas/locales/en.json` +> with the same English, and that the catalog carries no `guardrails.definitions.*` id the +> source no longer declares. That test is what extraction would otherwise be doing for you. + +## GuardrailList + +The guardrails applied to an agent or a tool: an ordered list of rows with add, edit, remove +and reorder affordances, the two bring-your-own configuration notices, and optional status +chips. + +```tsx +import { GuardrailList } from '@uipath/apollo-react/canvas/guardrails'; + + persist(spliceBack(reordered, move))} +/>; +``` + +### Contract + +- **The host filters, the list renders.** Feature flags, entitlements and scope filtering + never cross this boundary: both products decide what a user may see, then pass the + survivors as `guardrails` and `definitions`. The list renders every row it is given, even + one whose definition never arrived. +- **Callbacks are intents.** `onRemove` reports the request; the confirmation dialog, the + scoped-removal unwind and the write stay host-side, because the two products confirm with + different copy and unwind tool-scoped guardrails differently. Same for `onAdd` and + `onEdit`. No telemetry ships in the package: hosts wrap their own callbacks. +- **`onReorder` reports the visible array plus the move** (`{ from, to, id }`), so a host + rendering a filtered view (Flow's per-tool view over an agent-level list) can splice the + result back without this component knowing a fuller list exists. +- **`definitions` is read for three things only**: the provider line, the two BYO notices and + the status chip. Rows resolve by validator id, and a BYO row by its validator name alone, + which is the rule both products already ship (the name is unique per tenant, so an admin + rebinding a configuration to another connection still resolves). + `resolveGuardrailListItemState` is exported for hosts needing the same answer outside a row. + The type is the minimum the list reads, so `useGuardrailDefinitions`' output feeds it + unchanged, and so does a product's own definition type. +- **The BYO notices keep the "definitions have loaded" guard.** While the array is empty no + row claims its configuration is gone, which is what both products already do; without it + every BYO row flashes the notice for as long as the catalog takes to load. +- **Every addition is opt-in**, so adopting the list behind a flag renders what the host + renders today and the new UI arrives deliberately: + + | prop | default | why | + | --- | --- | --- | + | `statusChips` | `false` | Neither product chips definition status or administration today. The two BYO notices are *not* gated: both products already show those, and a guardrail that cannot run is not an opt-in detail. | + | `previewChip` | `false` | Product lifecycle, not a package concern. Both hosts pass it today and drop it at GA without a release here. | + | `reorderDisabled` | `disabled` | Agents stops reordering in a read-only list and Flow does not, so one flag could not express both. Flow passes `false`. | + | `unstyled` | `false` | Agents renders inside its own section accordion, where the card border and padding are extra chrome. | + | `hideHeader` | `false` | Agents owns the section title and its add affordance. | + | `footer` | none | Agents' add affordance sits *below* the rows and swaps itself for an entitlement line, which the header-only `addSlot` cannot express. | + | `emptyState` | the default line | Agents renders nothing when empty: pass `null`. An explicit `null` is honoured, so the check is for an absent prop, not a falsy value. | + | `rowActivatesEdit` | `false` | Agents opens the editor by clicking the row body. | + | `renderItemActions` | inline buttons | Agents' actions are an overflow menu. The slot receives `defaultActions`, so it can add to them instead of replacing them. | + | `renderRowTooltip` | none | Agents hovers a combined description, provider and scopes tooltip over the row body. | + | `formatScopes` / `formatAction` | raw values | Scope and action wording is product copy; return `null` to hide either line. | + | `getItemId` | `id ?? name` | Flow keys rows by `id`, Agents by `name`. | + | `getItemAdministration` | `'local'` | Governance-managed guardrails come from a different endpoint, so nothing on the record identifies them. | + + So Flow passes `previewChip` and `reorderDisabled={false}`; Agents passes `previewChip`, + `unstyled`, `hideHeader`, `emptyState={null}`, `footer`, `rowActivatesEdit`, + `formatAction={() => null}`, `formatScopes`, `renderItemActions` and `renderRowTooltip`. + +- **Reorder is real dnd-kit, and keyboard operable.** Pointer and keyboard sensors, vertical + and parent-bound modifiers, and a drag handle that is a real button: Agents' handle today is + an `aria-hidden` icon carrying the listeners, so it cannot be reached from the keyboard. + That is a fix, not a regression. With reorder off, or with a single row, no drag machinery + is mounted at all. +- `renderRowTooltip` brings its own `TooltipProvider`, because a row renders where none is + guaranteed. A tooltip anchored to a row body that is not activatable is pointer-only, so + anything a keyboard user needs belongs in the row itself. + +### Chips and notices + +`GuardrailStatusChip` is `Badge`-based and reuses the chip family's pill geometry. It is +deliberately **not** `GuardrailChip`, which wraps a Radix `Toggle`: these are read-only +labels, and rendering them as toggles would put fake buttons in the tab order and misreport +them to screen readers. + +`getGuardrailListChips` is the mapping, exported and pure. An `Available`, locally +administered row chips nothing, which is the common case and keeps the list quiet. +`FeatureDisabled` and `Unauthorised` chip as warnings; `Disabled` and the synthetic +`Unavailable` (a BYO row whose definition stopped resolving) chip as errors. + +The administration axis is deliberately **not** called "origin": both products already use +that word for BYO versus UiPath-managed validators (`GuardrailOriginChip`, +`CentralizedGuardrailOriginBadge`), which is a different axis and travels on +`byoValidatorName`. Governance-managed guardrails still render in each product's separate +centralized section; this ships the chip and the seam, so either layout stays possible. + +`GuardrailStatusBanner` and `MixedScopesBanner` are exported for hosts composing their own +chrome. The list itself takes one `statusBanner` slot above the rows (a definitions load +failure, say) rather than a typed banner prop: the mixed-scopes banner ends in a "Save as +new" hint, which is builder copy, and both products render it inside a builder rather than +over a list. + +### Localization + +Chrome strings resolve through `useGuardrailListLabels` (lingui, `guardrails.list.*` ids in +the shared canvas catalog); `labels` overrides any of them and wins over the catalog. Twelve +of the seventeen are harvested from Flow's canvas catalog into all 12 translated locales, so +the list adds no translation work. The five with no host equivalent are English-only for now +and fall back per key: `edit-row`, `status-feature-disabled`, `status-disabled`, +`status-unavailable`, `administration-governance`. + +## GuardrailPalette + +The add-guardrail picker: the definitions a user may add, grouped, with an optional +create-custom entry. + +```tsx +import { GuardrailPalette } from '@uipath/apollo-react/canvas/guardrails'; + +const { definitions, loading, error } = useGuardrailDefinitions({ baseUrl, tenantId }); + +; +``` + +### Contract + +- **The host filters, the palette offers.** Feature flags, entitlements, `FeatureDisabled` / + `Disabled` removal and Tool-scope filtering never cross this boundary: both products + already filter before rendering, and both do it differently (Agents gates five per-validator + flags, Flow one `canvas.guardrails` flag plus a hidden-validator list). The palette offers + every definition it is given. +- **Both callbacks are intents.** `onSelectOotb` reports the choice; the builder that opens + next, the unique default name it starts with and the telemetry stay host-side. No telemetry + ships in the package: the two products' event taxonomies do not overlap + (`guardrails.create_ootb_clicked` versus `GUARDRAILS_CREATE_OUT_OF_THE_BOX_CLICKED`) and + wrapping the callbacks is what both adapters do anyway. +- **`onCreateCustom` is opt-in by presence, not by a boolean.** Flow offers custom guardrails + only for `scope === 'Tool'`, so it passes the prop conditionally. Agents keeps the affordance + in its own palette header, so it omits the prop and the picker renders no second one. +- **Grouping is the rule both products already ship** (`groupGuardrailsForPalette`, exported + and pure): with no bring-your-own definitions, one unheaded group in payload order; + otherwise one group per BYO `folderPath ?? byoConnectorName`, sorted by that key, then a + trailing UiPath group, with display-name sorting inside every group. One deliberate + difference: an empty catalog produces **no** groups rather than one empty group, which is + what makes the empty line reachable. Flow's own empty state is guarded on + `groups.length === 0` and its implementation can never return that. +- **Entry identity is `validator`, or `byoValidatorName:byoGuardrailConnectionId`** + (`getGuardrailPaletteItemId`). A BYO validator name is unique per *connection*, so two + connections can expose the same name; Flow keys BYO entries by name alone today and collides + in exactly that case. This is not the same question as `matchesGuardrailListDefinition`, + which resolves a *saved* guardrail and matches BYO on the name alone on purpose. +- **`Unauthorised` is offered, chipped and not choosable.** It is the only non-`Available` + status that reaches a correctly filtered palette, and it is how a tenant discovers a + validator it is not entitled to. The entry is `aria-disabled`, not `disabled`, so it keeps + its place in the tab order and a keyboard user reaches the chip that says why. Agents today + lets that entry through to the builder, which then refuses to save; Flow disables it in the + select, and that is the behaviour this ships. +- **`previewChip` defaults to `false`**, the same call as the list: product lifecycle is not a + package concern. Both hosts hardcode the chip today and both pass the prop, then drop it at + GA without a release here. +- **`isLoading` renders a polite loading line and `error` a `GuardrailStatusBanner`.** Any + definitions that did arrive stay pickable under the banner, which is what a host with a + stale cache and a failed revalidation wants. `error` is shaped like + `useGuardrailDefinitions`' own `error`, so the hook's result destructures straight in. +- **Definitions are generic.** `GuardrailPaletteDefinition` is the eight fields the palette + reads; `EnrichedGuardrailDefinition` satisfies it, and so does a product's own definition + type. The component is generic over it, so `onSelectOotb` hands back the object the host + passed in, `parameters` and all, and there is nothing to look up again. + +### What the palette is not + +Only the picker ships. The shell is host orchestration, because the two products disagree and +both are right for their surface: Flow opens a 500px dialog, or an inline properties-panel +overlay that renders the chosen builder underneath the picker; Agents takes over the whole +sidebar with a back button and its own create affordance. Both are a handful of lines around +this component (see the `InADialog` and `InAHostSidebar` stories), and a wrapper modelling +both would be a worse contract than no wrapper. `existingGuardrails` is likewise not a palette +concern: it exists so the *builder* can propose a unique default name. + +### Localization + +Chrome strings resolve through `useGuardrailPaletteLabels` (lingui, `guardrails.palette.*` ids +in the shared canvas catalog); `labels` overrides any of them and wins over the catalog. +Definition copy is not localized here: display names, descriptions and connector or folder +names arrive resolved on the definitions, from the canonical copy table or the wire. + +All nine strings are harvested from the two products' own catalogs (eight from Flow's +`addGuardrailPalette_*` i18next keys, `list-aria-label` from Agents' `guardrails.palette.*` +lingui id), so the palette adds no translation work: 9 of 9 in each of the 12 translated +locales, asserted by a test. `ru` is deliberately absent, as everywhere else in this catalog. +One loc review item comes with the harvest: German takes "Leitplanke" from Flow for eight ids +and "Leitlinien" from Agents for the ninth, because the two products picked different words. + +## GuardrailBuilder + +The complete Add/Edit screen for an OOTB guardrail validator: status banners, usage note, +type display (edit mode), name, description, validator parameters, scope selector, action +(log / block / escalate; filter reserved for the custom-guardrail phase), evaluations +toggle, mixed-scopes banner, and the Save / Cancel / Save-as-new footer. + +```tsx +import { GuardrailBuilder } from '@uipath/apollo-react/canvas/guardrails'; + +; +``` + +Contract highlights: + +- **Owns form state.** Initialized from `definition`/`guardrail`/`defaultName` at mount — + remount with a new `key` to reset (all known hosts already remount per session). +- **Owns validation and gates its own Save.** Messages localize through lingui + (overridable per string via `labels`); a host that validates externally (e.g. zod) passes + `errors` — host messages display immediately, win per field, and gate Save. The pure + predicates (`getGuardrailActionErrorFields`, `getGuardrailSelectorErrorFields`, + `getRequiredEmptyParameterIds`) are exported. +- **Escalation is slot-driven.** `renderRecipientSearch` (user/group directory autosuggest) + and `renderAppPicker` (escalation app) are host capabilities; `escalateHelp` renders under + the escalation grid (e.g. a marketplace link — product URLs never ship in this package). + Without slots the form falls back to a plain input / an "unavailable" note. +- **Layout knobs for both hosts**: `inline`/`hideHeader`/`dialogMaxWidth`, `title` accepts a + ReactNode (chips, links), `evalsTogglePlacement: 'form' | 'footer'`. +- Requires an ancestor `TooltipProvider`. + +`GuardrailFormLayout` is exported standalone for hosts composing their own screen: three +modes (inline+hideHeader / inline with back-button header / modal Dialog), `secondaryAction`, +`saveDisabled`, and a `footerStart` region. + +## GuardrailValidatorForm + +Renders one editor per `GuardrailParameterDefinition`, covering the seven wire parameter +types: + +| type | editor | +| --- | --- | +| `number` | numeric input with `min`/`max`/`step` | +| `text` | multiline textarea (`maxLength`) | +| `boolean` | switch | +| `enum` | single select (a stored value missing from `options` is kept as a synthetic option) | +| `enum-list` | toggleable chips inline for ≤8 options, otherwise the wind `MultiSelect` | +| `text-list` | repeated textarea rows with Add/Remove (`maxItems`, `maxLength`) | +| `map-enum` | one numeric input per key selected in the sibling `keySource` enum-list | + +```tsx +import { + GuardrailValidatorForm, + getRequiredEmptyParameterIds, + seedGuardrailParameters, +} from '@uipath/apollo-react/canvas/guardrails'; + +const [parameters, setParameters] = useState(() => + seedGuardrailParameters(definition.parameters, existingGuardrail?.validatorParameters) +); + + — host-owned validation + onClearError={clearParamError} +/>; +``` + +Requires an ancestor `TooltipProvider` (for the per-parameter info tooltips). + +### Contract + +- **Fully controlled, validation-free.** The host owns values and validation. Compute + required-field errors with `getRequiredEmptyParameterIds(definitions, parameters)` and map + the returned ids to your own (localized) messages; the component renders `errors[id]` under + the matching editor and calls `onClearError(id)` before `onChange` when that parameter is + edited. +- **Definitions arrive pre-resolved.** `label`, `tooltip` and `optionLabels` are display + strings the host already localized; domain copy (PII entity names, validator descriptions) + never ships in this package. +- **No product types cross the boundary.** `GuardrailValidatorParameter` structurally mirrors + the wire shape both products persist, so host unions assign cleanly in both directions. +- **Per-parameter override.** `renderParameter(ctx)` replaces the editor for any parameter + (return `undefined` to fall through). `ctx.onValueChange` upserts the parameter; + `ctx.onParametersChange` replaces the whole array for overrides that persist sidecar + parameters (e.g. a model picker storing connection metadata). + +### Save-time companions + +The editors never prune or drop values while typing; reconcile at save time: + +```ts +import { dropEmptyOptionalParameters, syncMapEnumParameters } from '@uipath/apollo-react/canvas/guardrails'; + +const cleaned = dropEmptyOptionalParameters( + syncMapEnumParameters(parameters, definition.parameters), + definition.parameters +); +``` + +- `syncMapEnumParameters` rebuilds every `map-enum` value so its keys exactly match the + current `keySource` selection (preserving user edits, then per-key defaults, then `min`). + It mirrors the map-enum editor's key resolution — keeping the two in one package is the + point: they must never drift. +- `dropEmptyOptionalParameters` removes optional parameters left `''`/`[]`, which runtimes + reject at publish time. +- `seedGuardrailParameters` builds the initial value array from definitions (editing passes + the stored values through verbatim), coercing `null` defaults to the union's value types. + +### Localization + +The component's own chrome strings (placeholders, Add, aria labels) localize through the +package's standard lingui setup: `useSafeLingui` with explicit `guardrails.*` ids and English +defaults, translations in the shared canvas catalog (`src/canvas/locales/*.json`, 13 locales +translated; `ru` falls back to English per key). Without a lingui provider the components +render the English defaults — mount `ApI18nProvider component="canvas"` (from +`@uipath/apollo-react/i18n`) for translations. `labels` overrides individual strings and wins +over the catalog. The required-error message is deliberately not included: it arrives through +`errors`, because validation messages belong to hosts. + +Localized template strings that cross into plain-string APIs (dialog titles, the text-list +remove label consumed by wind's `formatTemplate`) are ICU messages formatted with sentinel +values, so they come back carrying the `{{token}}` convention — see `TEMPLATE_TOKENS` in +`i18n.ts`. + +### Consuming from a shadow-DOM host (Agents stage 2) + +Radix overlays (the enum select, the enum-list popover, tooltips) portal to `document.body` +by default and escape shadow roots; wrap the form's subtree with `PortalContainerProvider` +and inject the compiled canvas stylesheet +(`@uipath/apollo-react/canvas/styles/tailwind.canvas.css?inline` — its Tailwind build scans +this directory) into the shadow root (see `AgentCanvasEditor` in `frontend-sw` for the +`?inline` injection precedent). `@uipath/apollo-wind` must resolve to a single copy alongside +apollo-react's own pin, or Radix contexts and CSS duplicate. + +## Built on the forms/ MetadataForm stack + +`GuardrailValidatorForm` is not a form renderer of its own: internally it is +`buildGuardrailFormSchema(definitions, labels)` + the package's `MetadataForm` +(`components/forms/`: `FormSchema` → `MetadataForm` → `field-renderer`), mounted through the +controlled-host seam (`values` / `onValuesChange` / `errors` / `disableValidation` / +`container="div"` / synchronous `components`). The public contract above is the adapter +boundary — hosts never see the schema. + +How each parameter type maps: + +| parameter type | rendering | +| --- | --- | +| `number` | field type `number` | +| `text` | field type `textarea` (`minRows`, `maxLength`) | +| `boolean` | field type `switch` | +| `enum` | field type `select` (synthetic option appended for a stale stored value) | +| `enum-list` > 8 options | field type `multiselect` | +| `enum-list` ≤ 8 options | custom component `guardrail-enum-list-chips` (`GuardrailChip` toggles in a `FieldShell`) | +| `text-list` | field type `string-list` (added to forms/ for this convergence — generic) | +| `map-enum` | custom component `guardrail-map-enum` (reads the `keySource` sibling via the form context) | +| any id claimed by `renderParameter` | custom component `guardrail-render-parameter` (the bridge that mounts the host's node and exposes `onValueChange`/`onParametersChange`) | + +Why the three custom components stay guardrail-owned: the chip-toggle UX is a product +decision (small option sets read better as chips than a dropdown), `map-enum` derives its +rows from a sibling field's live selection, and `renderParameter` is a host seam — all three +are exactly what `type: 'custom'` + component registration exists for. + +Adapter invariants (guarded by the `controlled contract` tests in +`guardrail-validator-form.test.tsx`): + +- Emissions upsert only the edited parameter into the host's current array — untouched + defaults never leak in, and parameters without a matching definition (sidecars written via + `onParametersChange`, e.g. `byomConnectionId`) never enter the form and round-trip + untouched. +- A synchronous host echo of the emitted array is a no-op (per-field deep-equal guard): no + re-emission, focus and cursor survive. Hosts must echo synchronously from `onChange`. +- Values are coerced to the wire shape on emit (`coerceGuardrailParameterValue`): a cleared + number input persists `0`, never `NaN`; text/enum never persist `null`. + +**Rule for new work**: a new parameter editor extends `field-renderer` with a first-class +field type (when it's generic) or registers a custom component here (when it's +guardrail-shaped) — never a parallel renderer next to `MetadataForm`. diff --git a/packages/apollo-react/src/canvas/components/Guardrails/__fixtures__/definitions-wire.fixtures.ts b/packages/apollo-react/src/canvas/components/Guardrails/__fixtures__/definitions-wire.fixtures.ts new file mode 100644 index 0000000000..534257682b --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/__fixtures__/definitions-wire.fixtures.ts @@ -0,0 +1,245 @@ +import type { GuardrailDefinitionWire } from '../definitions-wire'; + +/** + * Wire payloads shaped like what `GET /api/execution/guardrails/definitions` actually + * returns, trimmed to the fields the definitions layer reads. Kept close to the real + * payload (option lists, threshold bounds, BYO fields) so the enrichment tests exercise the + * same shapes the products see. + */ + +/** The 25 PII entities the backend offers today. */ +export const PII_ENTITY_OPTIONS: string[] = [ + 'Person', + 'Address', + 'Date', + 'PhoneNumber', + 'EugpsCoordinates', + 'Email', + 'CreditCardNumber', + 'InternationalBankingAccountNumber', + 'SwiftCode', + 'ABARoutingNumber', + 'USDriversLicenseNumber', + 'UKDriversLicenseNumber', + 'USIndividualTaxpayerIdentification', + 'UKUniqueTaxpayerNumber', + 'USBankAccountNumber', + 'USSocialSecurityNumber', + 'UsukPassportNumber', + 'NOIdentityNumber', + 'FINationalID', + 'FIPassportNumber', + 'SENationalID', + 'DKPersonalIdentificationNumber', + 'NLCitizensServiceNumber', + 'URL', + 'IPAddress', +]; + +export const PII_DETECTION_WIRE: GuardrailDefinitionWire = { + validator: 'pii_detection', + allowedScopes: ['Agent', 'Llm', 'Tool'], + status: 'Available', + parameters: [ + { + id: 'entities', + type: 'enum-list', + required: true, + defaultValue: ['Email', 'CreditCardNumber'], + options: PII_ENTITY_OPTIONS, + }, + { + // The backend omits the bounds here; enrichment supplies 0..1 step 0.1. + id: 'entityThresholds', + type: 'map-enum', + required: false, + defaultValue: { Email: 0.8, CreditCardNumber: 0.8 }, + keySource: 'entities', + }, + ], +}; + +export const HARMFUL_CONTENT_WIRE: GuardrailDefinitionWire = { + validator: 'harmful_content', + allowedScopes: ['Llm', 'Tool'], + status: 'Available', + parameters: [ + { + id: 'harmfulContentEntities', + type: 'enum-list', + required: true, + defaultValue: ['Hate', 'Violence'], + options: ['Hate', 'SelfHarm', 'Sexual', 'Violence'], + }, + { + id: 'harmfulContentEntityThresholds', + type: 'map-enum', + required: false, + defaultValue: { Hate: 2, Violence: 2 }, + keySource: 'harmfulContentEntities', + min: 0, + max: 6, + step: 2, + }, + ], +}; + +export const PROMPT_INJECTION_WIRE: GuardrailDefinitionWire = { + validator: 'prompt_injection', + allowedScopes: ['Llm'], + status: 'Available', + parameters: [ + { + id: 'threshold', + type: 'number', + required: true, + defaultValue: 0.7, + min: 0, + max: 1, + step: 0.1, + }, + ], +}; + +export const USER_PROMPT_ATTACKS_WIRE: GuardrailDefinitionWire = { + validator: 'user_prompt_attacks', + allowedScopes: ['Llm'], + status: 'Available', + parameters: [], +}; + +export const INTELLECTUAL_PROPERTY_WIRE: GuardrailDefinitionWire = { + validator: 'intellectual_property', + allowedScopes: ['Llm', 'Tool'], + status: 'FeatureDisabled', + parameters: [ + { + id: 'ipEntities', + type: 'enum-list', + required: true, + defaultValue: ['Text'], + options: ['Text', 'Code'], + }, + ], +}; + +export const LLM_AS_JUDGE_WIRE: GuardrailDefinitionWire = { + validator: 'llm_as_judge', + allowedScopes: ['Agent', 'Llm', 'Tool'], + status: 'Available', + parameters: [ + { + id: 'guardrailText', + type: 'text', + required: true, + defaultValue: null, + maxLength: 4000, + }, + { + id: 'model', + type: 'enum', + required: true, + defaultValue: null, + options: ['gpt-4o-mini-2024-07-18', 'gpt-4o-2024-11-20'], + }, + { + // Agents' schema has no `defaultValue` for text-list at all; this mirrors that. + id: 'positiveExamples', + type: 'text-list', + required: false, + maxItems: 5, + maxLength: 1000, + }, + { + id: 'negativeExamples', + type: 'text-list', + required: false, + defaultValue: null, + maxItems: 5, + maxLength: 1000, + }, + { + id: 'threshold', + type: 'number', + required: true, + defaultValue: 4, + min: 0, + max: 6, + step: 2, + }, + ], +}; + +/** A bring-your-own guardrail: manifest copy only, no curated table entry applies. */ +export const BYO_WIRE: GuardrailDefinitionWire = { + validator: 'pii_detection', + allowedScopes: ['Llm'], + status: 'Available', + displayName: 'Acme PII scan', + description: 'Runs the Acme detector over prompts and completions.', + byoValidatorName: 'acme-pii', + byoConnectorName: 'Acme AI Guardrails', + byoConnectorKey: 'acme', + byoGuardrailConnectionId: 'conn-1', + byoConfigurationId: 'cfg-1', + parameters: [ + { + id: 'sensitivity', + type: 'enum', + required: true, + defaultValue: 'medium', + displayName: 'Sensitivity', + description: 'How aggressively to flag.', + options: ['low', 'medium', 'high'], + optionLabels: { low: 'Low', high: 'High' }, + }, + ], +}; + +/** A UiPath validator the curated table has never heard of. */ +export const UNCURATED_WIRE: GuardrailDefinitionWire = { + validator: 'topic_drift', + allowedScopes: ['Llm'], + status: 'Available', + parameters: [ + { id: 'maxDriftScore', type: 'number', required: true, defaultValue: 0.5 }, + { + id: 'allowedTopics', + type: 'enum-list', + required: false, + defaultValue: [], + options: ['finance', 'legal'], + }, + ], +}; + +export const ALL_BUILT_IN_WIRE: GuardrailDefinitionWire[] = [ + PII_DETECTION_WIRE, + PROMPT_INJECTION_WIRE, + HARMFUL_CONTENT_WIRE, + USER_PROMPT_ATTACKS_WIRE, + INTELLECTUAL_PROPERTY_WIRE, + LLM_AS_JUDGE_WIRE, +]; + +/** + * A raw payload as it arrives over the wire: unknown keys the backend added, one entry that + * fails validation, and the empty display strings Flow's schema normalizes away. + */ +export const RAW_PAYLOAD_WITH_NOISE: unknown = [ + { + ...PROMPT_INJECTION_WIRE, + // Fields a newer backend added that this package does not model. + executionStage: 'PreLlm', + internalRanking: 3, + displayName: '', + description: '', + }, + { + // Missing `status`, so the whole definition is dropped. + validator: 'broken_validator', + allowedScopes: ['Llm'], + parameters: [], + }, + USER_PROMPT_ATTACKS_WIRE, +]; diff --git a/packages/apollo-react/src/canvas/components/Guardrails/__fixtures__/dnd-geometry.ts b/packages/apollo-react/src/canvas/components/Guardrails/__fixtures__/dnd-geometry.ts new file mode 100644 index 0000000000..9b35a719b0 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/__fixtures__/dnd-geometry.ts @@ -0,0 +1,69 @@ +/** + * Synthetic layout for the drag-and-drop suites. + * + * happy-dom runs no layout, so every element reports a zero rect. dnd-kit's collision + * detection then cannot tell two rows apart, and the keyboard sensor finds no candidate in the + * arrow direction, so a reorder silently does nothing. Laying the rendered rows out on a + * vertical grid is the minimum geometry a reorder needs; it is also all of it, which is why + * this is a fixture and not a mock of dnd-kit. + */ + +import { act } from '@testing-library/react'; + +const ROW_HEIGHT = 48; +const ROW_WIDTH = 320; + +function rectAt(top: number, height: number): DOMRect { + const rect = { + x: 0, + y: top, + top, + left: 0, + right: ROW_WIDTH, + bottom: top + height, + width: ROW_WIDTH, + height, + }; + return { ...rect, toJSON: () => rect } as DOMRect; +} + +function stubRect(element: Element, rect: DOMRect): void { + Object.defineProperty(element, 'getBoundingClientRect', { + configurable: true, + value: () => rect, + }); +} + +/** + * Stack every rendered row into a column, and give their container a rect that spans it (the + * `restrictToParentElement` modifier clamps the drag translation to that rect, and a zero-sized + * parent clamps it to nothing). + */ +export function layoutRowsVertically( + container: HTMLElement, + selector = '[data-slot="guardrail-list-row"]' +): void { + const rows = Array.from(container.querySelectorAll(selector)); + rows.forEach((row, index) => stubRect(row, rectAt(index * ROW_HEIGHT, ROW_HEIGHT))); + + const parents = new Set( + rows.map((row) => row.parentElement).filter((el): el is HTMLElement => el !== null) + ); + for (const parent of parents) { + stubRect(parent, rectAt(0, rows.length * ROW_HEIGHT)); + } +} + +/** + * Let dnd-kit finish a step of a drag. + * + * It measures droppables and applies coordinate changes inside `requestAnimationFrame`, which + * the canvas test setup implements as `setTimeout(cb, 0)`: a macrotask, so it does not flush + * with the `act()` around a `fireEvent`. Await this between the keystrokes of a drag, or the + * next one is computed against the geometry of the step before. + */ +export async function flushDndFrame(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/__fixtures__/guardrail-list.fixtures.ts b/packages/apollo-react/src/canvas/components/Guardrails/__fixtures__/guardrail-list.fixtures.ts new file mode 100644 index 0000000000..2abe04209c --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/__fixtures__/guardrail-list.fixtures.ts @@ -0,0 +1,51 @@ +import type { GuardrailListDefinition, GuardrailListItem } from '../list-types'; + +/** + * Rows and definitions shaped like what the two products pass: one UiPath validator, one + * bring-your-own validator resolved through a connector, and one custom guardrail with no + * definition at all. Between them they cover every branch a row can take. + */ + +export const PII_GUARDRAIL: GuardrailListItem = { + id: 'g1', + name: 'PII detection 1', + description: 'Scans agent output for personal data.', + $guardrailType: 'builtInValidator', + selector: { scopes: ['Agent', 'Tool'], matchNames: ['Send email'] }, + action: { $actionType: 'log' }, + validatorType: 'pii_detection', +}; + +export const BYO_GUARDRAIL: GuardrailListItem = { + id: 'g2', + name: 'Noma prompt shield', + $guardrailType: 'builtInValidator', + selector: { scopes: ['Llm'] }, + action: { $actionType: 'block' }, + validatorType: 'byo', + byoValidatorName: 'noma_prompt_injection', +}; + +export const CUSTOM_GUARDRAIL: GuardrailListItem = { + id: 'g3', + name: 'Blocked words', + $guardrailType: 'custom', + selector: { scopes: ['Tool'], matchNames: ['Send email'] }, + action: { $actionType: 'filter' }, +}; + +export const GUARDRAILS: GuardrailListItem[] = [PII_GUARDRAIL, BYO_GUARDRAIL, CUSTOM_GUARDRAIL]; + +export const PII_DEFINITION: GuardrailListDefinition = { + validator: 'pii_detection', + status: 'Available', +}; + +export const BYO_DEFINITION: GuardrailListDefinition = { + validator: 'byo', + status: 'Available', + byoValidatorName: 'noma_prompt_injection', + byoConnectorName: 'Noma Security', +}; + +export const DEFINITIONS: GuardrailListDefinition[] = [PII_DEFINITION, BYO_DEFINITION]; diff --git a/packages/apollo-react/src/canvas/components/Guardrails/__fixtures__/guardrail-palette.fixtures.ts b/packages/apollo-react/src/canvas/components/Guardrails/__fixtures__/guardrail-palette.fixtures.ts new file mode 100644 index 0000000000..32f1cd5854 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/__fixtures__/guardrail-palette.fixtures.ts @@ -0,0 +1,69 @@ +import type { GuardrailPaletteDefinition } from '../palette-types'; + +/** + * Definitions shaped like what the two products pass a palette: UiPath validators (one of + * them unauthorized), and bring-your-own validators across two connections, one of which has + * no folder. Between them they cover every grouping and every chip branch. + */ + +export const PII_DEFINITION: GuardrailPaletteDefinition = { + validator: 'pii_detection', + displayName: 'PII detection', + description: 'Detects personally identifiable information in agent traffic.', + status: 'Available', +}; + +export const PROMPT_ATTACKS_DEFINITION: GuardrailPaletteDefinition = { + validator: 'user_prompt_attacks', + displayName: 'Prompt attacks', + description: 'Detects attempts to override the agent instructions.', + status: 'Available', +}; + +export const UNAUTHORIZED_DEFINITION: GuardrailPaletteDefinition = { + validator: 'harmful_content', + displayName: 'Harmful content', + description: 'Detects harmful content categories in agent traffic.', + status: 'Unauthorised', +}; + +export const BYO_FOLDER_DEFINITION: GuardrailPaletteDefinition = { + validator: 'byo', + displayName: 'Noma prompt shield', + description: 'Vendor-managed prompt injection detection.', + status: 'Available', + byoValidatorName: 'noma_prompt_injection', + byoConnectorName: 'Noma Security', + byoGuardrailConnectionId: 'connection-1', + folderPath: 'Shared/Security', +}; + +/** The same validator name on a second connection: only the connection id keeps them apart. */ +export const BYO_SECOND_CONNECTION_DEFINITION: GuardrailPaletteDefinition = { + ...BYO_FOLDER_DEFINITION, + displayName: 'Noma prompt shield (EU)', + byoGuardrailConnectionId: 'connection-2', + folderPath: 'Shared/Security EU', +}; + +/** No folder resolved, so the group falls back to the connector name. */ +export const BYO_CONNECTOR_ONLY_DEFINITION: GuardrailPaletteDefinition = { + validator: 'byo', + displayName: 'Acme policy check', + status: 'Available', + byoValidatorName: 'acme_policy', + byoConnectorName: 'Acme Guard', + byoGuardrailConnectionId: 'connection-3', +}; + +export const UIPATH_DEFINITIONS: GuardrailPaletteDefinition[] = [ + PII_DEFINITION, + PROMPT_ATTACKS_DEFINITION, +]; + +export const MIXED_DEFINITIONS: GuardrailPaletteDefinition[] = [ + PII_DEFINITION, + BYO_FOLDER_DEFINITION, + PROMPT_ATTACKS_DEFINITION, + BYO_CONNECTOR_ONLY_DEFINITION, +]; diff --git a/packages/apollo-react/src/canvas/components/Guardrails/__fixtures__/host-copy-baselines.ts b/packages/apollo-react/src/canvas/components/Guardrails/__fixtures__/host-copy-baselines.ts new file mode 100644 index 0000000000..4c50c3fa00 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/__fixtures__/host-copy-baselines.ts @@ -0,0 +1,208 @@ +import type { GuardrailCopyTable } from '../definitions-copy'; + +/** + * The English validator copy each product ships today, transcribed into this package's copy + * shape so `definitions-parity.test.ts` can diff all three tables mechanically. + * + * Sources, both read at the time this layer was written: + * + * - Agents `origin/main`: + * `frontend-sw/src/components/definition/AddGuardrailPalette/AddGuardrailPalette.utils.tsx` + * (`OOB_GUARDRAILS_I8N`; its `name` / `params[].infoTooltip` / `params[].options` map onto + * `displayName` / `paramTooltips` / `optionLabels` here) + * - Flow `origin/develop`: + * `packages/canvas/src/components/properties-panel/guardrails/ootb-guardrail-definitions.ts` + * (`buildValidatorDisplayInfo`) + * + * Only the English defaults are transcribed, never the products' message keys: the whole + * point of the shared table is that the keys stop mattering. When a product changes its copy, + * update the baseline here and the parity test will say whether our choice still holds. + */ + +/** + * The 24 PII entity labels the two products spell identically. Shared rather than duplicated + * because they genuinely are the same strings; the one entity they disagree about + * (`FIPassportNumber`, Agents only) is added below. + */ +const SHARED_PII_ENTITY_LABELS: Record = { + Person: 'Person', + Address: 'Address', + Date: 'Date', + PhoneNumber: 'Phone Number', + EugpsCoordinates: 'EU GPS Coordinates', + Email: 'Email', + CreditCardNumber: 'Credit Card Number', + InternationalBankingAccountNumber: 'International Banking Account Number (IBAN)', + SwiftCode: 'SWIFT Code', + ABARoutingNumber: 'ABA Routing Number', + USDriversLicenseNumber: "US Driver's License Number", + UKDriversLicenseNumber: "UK Driver's License Number", + USIndividualTaxpayerIdentification: 'US Individual Taxpayer Identification Number (ITIN)', + UKUniqueTaxpayerNumber: 'UK Unique Taxpayer Number (UTR)', + USBankAccountNumber: 'US Bank Account Number', + USSocialSecurityNumber: 'US Social Security Number (SSN)', + UsukPassportNumber: 'US/UK Passport Number', + NOIdentityNumber: 'Norway Identity Number', + FINationalID: 'Finland National ID', + SENationalID: 'Sweden National ID', + DKPersonalIdentificationNumber: 'Danish Personal Identification Number', + NLCitizensServiceNumber: 'Netherlands Citizens Service Number', + URL: 'URL', + IPAddress: 'IP Address', +}; + +const SHARED_LLM_AS_JUDGE_TOOLTIPS: Record = { + guardrailText: + 'Describe the rule the judge will enforce. Be specific about what should pass and what should fail.', + model: 'The model used to evaluate the policy against each payload.', + positiveExamples: + 'Optional payloads that should pass the policy. Used by the judge as calibration anchors.', + negativeExamples: + 'Optional payloads that should fail the policy. Used by the judge as calibration anchors.', +}; + +/** Agents `frontend-sw`, `OOB_GUARDRAILS_I8N`. */ +export const AGENTS_COPY_EN: GuardrailCopyTable = { + pii_detection: { + displayName: 'PII detection', + description: + 'This validator is designed to detect personally identifiable information using Azure Cognitive Services', + paramLabels: { + entities: 'Entities to detect', + entityThresholds: 'Detection threshold', + }, + paramTooltips: { + entityThresholds: + 'Value between 0 and 1. The sensitivity level for PII detection. Higher thresholds detect more potential PII but may result in more false positives.', + }, + optionLabels: { + entities: { ...SHARED_PII_ENTITY_LABELS, FIPassportNumber: 'Finland Passport Number' }, + }, + }, + prompt_injection: { + displayName: 'Prompt injection', + description: + 'This validator is provided by Noma Security and is built to detect malicious attack attempts (e.g. prompt injection, jailbreak) in LLM calls.', + paramLabels: { threshold: 'Detection threshold' }, + paramTooltips: { + threshold: + 'Value between 0 and 1. The sensitivity level for Prompt Injection detection. Higher thresholds detect more potential Prompt Injection but may result in more false positives.', + }, + }, + harmful_content: { + displayName: 'Harmful content', + description: + 'This validator is provided by Microsoft Azure AI Content Safety and is built to detect harmful content (e.g. hate, violence, etc.) in LLM calls.', + paramLabels: { + harmfulContentEntities: 'Entities to detect', + harmfulContentEntityThresholds: 'Detection threshold', + }, + paramTooltips: { + harmfulContentEntityThresholds: + 'Integer value between 0 and 6 (step 2). The severity threshold for harmful content detection. Higher values require more severe content before triggering.', + }, + optionLabels: { + harmfulContentEntities: { + Hate: 'Hate', + SelfHarm: 'SelfHarm', + Sexual: 'Sexual', + Violence: 'Violence', + }, + }, + }, + user_prompt_attacks: { + displayName: 'User prompt attacks', + description: + 'This validator is provided by Microsoft Azure AI Content Safety and is built to detect user prompt attacks (e.g. jailbreak, prompt injection) that attempt to bypass system instructions.', + paramLabels: {}, + }, + intellectual_property: { + displayName: 'Intellectual property', + description: + 'This validator is provided by Microsoft Azure AI Content Safety and is built to detect potential intellectual property violations in text and code.', + paramLabels: { ipEntities: 'Entities to detect' }, + optionLabels: { ipEntities: { Text: 'Text', Code: 'Code' } }, + }, + llm_as_judge: { + displayName: 'LLM as Judge', + description: 'Detect violations of a rule you define, using an LLM as the judge.', + paramLabels: { + guardrailText: 'Rule prompt', + model: 'Judge model', + positiveExamples: 'Positive examples', + negativeExamples: 'Negative examples', + threshold: 'Threshold', + }, + paramTooltips: { + ...SHARED_LLM_AS_JUDGE_TOOLTIPS, + threshold: + 'Integer value between 0 and 6 (step 2). Lower values are stricter — borderline payloads will fail. Higher values are more lenient — only clear violations are flagged.', + }, + }, +}; + +/** Flow `packages/canvas`, `buildValidatorDisplayInfo()`. */ +export const FLOW_COPY_EN: GuardrailCopyTable = { + pii_detection: { + displayName: 'PII detection', + description: 'Detect personally identifiable information using Azure Cognitive Services.', + paramLabels: { + entities: 'Entities to detect', + entityThresholds: 'Detection thresholds', + }, + optionLabels: { entities: SHARED_PII_ENTITY_LABELS }, + }, + prompt_injection: { + displayName: 'Prompt injection', + description: + 'Detect malicious attack attempts (e.g. prompt injection, jailbreak) in LLM calls.', + paramLabels: { threshold: 'Detection threshold' }, + }, + harmful_content: { + displayName: 'Harmful content', + description: 'Detect harmful content (e.g. hate, violence) using Azure AI Content Safety.', + paramLabels: { + harmfulContentEntities: 'Content categories', + harmfulContentEntityThresholds: 'Severity thresholds', + }, + optionLabels: { + harmfulContentEntities: { + Hate: 'Hate', + SelfHarm: 'Self-harm', + Sexual: 'Sexual', + Violence: 'Violence', + }, + }, + }, + user_prompt_attacks: { + displayName: 'User prompt attacks', + description: + 'Detect user prompt attacks that attempt to bypass system instructions using Azure AI Content Safety.', + paramLabels: {}, + }, + intellectual_property: { + displayName: 'Intellectual property', + description: + 'Detect potential intellectual property violations in text and code using Azure AI Content Safety.', + paramLabels: { ipEntities: 'Content types' }, + optionLabels: { ipEntities: { Text: 'Text', Code: 'Code' } }, + }, + llm_as_judge: { + displayName: 'LLM as Judge', + description: 'Detect violations of a rule you define, using an LLM as the judge.', + usageNote: + "Judge model calls consume Agent units the same way the agent's own LLM calls do. Apply selectively to keep cost predictable, and choose the judge model with cost in mind — rates vary by model.", + paramLabels: { + guardrailText: 'Rule prompt', + model: 'Judge model', + positiveExamples: 'Positive examples', + negativeExamples: 'Negative examples', + threshold: 'Strictness', + }, + paramTooltips: { + ...SHARED_LLM_AS_JUDGE_TOOLTIPS, + threshold: + 'Strictness on a 0–6 scale. Lower values are stricter — the judge flags anything that hints at a violation. Higher values are more lenient — only clear, unambiguous violations are flagged.', + }, + }, +}; diff --git a/packages/apollo-react/src/canvas/components/Guardrails/builder-types.ts b/packages/apollo-react/src/canvas/components/Guardrails/builder-types.ts new file mode 100644 index 0000000000..8a9aa89413 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/builder-types.ts @@ -0,0 +1,177 @@ +import type * as React from 'react'; +import type { GuardrailParameterDefinition, GuardrailValidatorParameter } from './types'; + +/** + * Structural mirrors of the guardrail wire shapes both consuming products persist. Hosts + * pass their own equivalent types without mapping; mutual assignability is asserted on the + * host side (see the Flow adapter's type-assertion file). + */ + +export type GuardrailScope = 'Agent' | 'Llm' | 'Tool'; + +export interface GuardrailSelector { + scopes: GuardrailScope[]; + matchNames?: string[]; +} + +export type GuardrailSeverityLevel = 'Info' | 'Warning' | 'Error'; + +/** Recipient type discriminators (numeric on the wire). */ +export const GuardrailRecipientType = { + User: 1, + Group: 2, + StaticEmail: 3, + AssetEmail: 4, + StaticGroupName: 5, + AssetGroupName: 6, +} as const; +export type GuardrailRecipientTypeValue = + (typeof GuardrailRecipientType)[keyof typeof GuardrailRecipientType]; + +/** + * Escalation recipient union. The editor offers User/Group/StaticEmail/StaticGroupName; + * the asset variants (4/6) exist so values using them round-trip through the form unedited. + */ +export type GuardrailEscalateRecipient = + | { type: 1; value: string; displayName: string } + | { type: 2; value: string; displayName: string } + | { type: 3; value: string } + | { type: 4; folderPath?: string; assetName: string } + | { type: 5; value: string } + | { type: 6; folderPath?: string; assetName: string }; + +export interface GuardrailEscalateApp { + id: string; + version: string; + name: string; + folderId?: string; + folderName?: string; + appProcessKey?: string; + runtime?: string; +} + +export type GuardrailAction = + | { $actionType: 'log'; severityLevel: GuardrailSeverityLevel } + | { $actionType: 'block'; reason: string } + // Field references are product-shaped; the OOTB builder never edits them (pass-through + // only, the filter option is custom-guardrail territory), so they stay opaque here. + | { $actionType: 'filter'; fields: unknown[] } + | { $actionType: 'escalate'; app: GuardrailEscalateApp; recipient: GuardrailEscalateRecipient }; + +export type GuardrailDefinitionStatus = + | 'Available' + | 'FeatureDisabled' + | 'Unauthorised' + | 'Disabled'; + +/** + * The display-ready definition of an OOTB guardrail validator. `displayName` and `usageNote` + * arrive pre-resolved (host-localized); `parameters` reuses the validator-form definition + * type. + */ +export interface GuardrailDefinition { + validator: string; + displayName: string; + allowedScopes: GuardrailScope[]; + parameters: GuardrailParameterDefinition[]; + status: GuardrailDefinitionStatus; + /** Pre-localized informational note rendered above the form. */ + usageNote?: React.ReactNode; + /** Present for bring-your-own guardrail definitions; stamped onto saved values. */ + byoValidatorName?: string; +} + +/** `validatorType` persisted for bring-your-own guardrail definitions. */ +export const GUARDRAIL_BYO_VALIDATOR_TYPE = 'byo'; + +/** The builder's in/out value — mirrors the persisted built-in-validator guardrail shape. */ +export interface GuardrailBuilderValue { + id: string; + $guardrailType: 'builtInValidator'; + name: string; + description?: string; + selector: GuardrailSelector; + action: GuardrailAction; + enabledForEvals: boolean; + validatorType: string; + validatorParameters: GuardrailValidatorParameter[]; + byoValidatorName?: string; +} + +/** + * Host-supplied validation errors, merged over the builder's internal validation (the host + * message wins per field). Any present error gates Save. + */ +export interface GuardrailBuilderErrors { + name?: string; + blockReason?: string; + filterFields?: string; + recipient?: string; + actionApp?: string; + scopes?: string; + toolNames?: string; + /** Per-parameter messages keyed by parameter id. */ + parameters?: Record; +} + +/** Context handed to the `renderRecipientSearch` slot (user/group directory autosuggest). */ +export interface GuardrailRecipientSearchContext { + kind: 'user' | 'group'; + /** Current display value (displayName, falling back to the raw value). */ + displayValue: string; + /** Localized placeholder for the current kind. */ + placeholder: string; + /** Whether the recipient currently fails validation (style the input accordingly). */ + invalid: boolean; + onSelect: (selection: { value: string; displayName: string }) => void; + onClear: () => void; +} + +/** Context handed to the `renderAppPicker` slot (escalation action app). */ +export interface GuardrailAppPickerContext { + /** The selected app, or null when unset. */ + app: GuardrailEscalateApp | null; + onChange: (app: GuardrailEscalateApp | null) => void; + /** Localized field label. */ + label: string; + /** Validation message to surface, if any. */ + error?: string; +} + +/** + * Context handed to the `renderStaticRecipient` slot (StaticEmail/AssetEmail/StaticGroupName/ + * AssetGroupName recipients). + */ +export interface GuardrailStaticRecipientContext { + /** Which recipient family the type select currently shows. */ + kind: 'email' | 'groupName'; + /** The current recipient — the static or asset variant of the kind. */ + recipient: GuardrailEscalateRecipient; + /** Localized field label for the kind. */ + label: string; + /** Whether the recipient currently fails validation (style the control accordingly). */ + invalid: boolean; + /** Validation message to surface, if any. */ + error?: string; + /** + * Replace the recipient wholesale — lets hosts toggle between the static and asset + * variants of the same kind (StaticEmail 3 ↔ AssetEmail 4, StaticGroupName 5 ↔ + * AssetGroupName 6). + */ + onChange: (recipient: GuardrailEscalateRecipient) => void; +} + +export interface GuardrailBuilderSlots { + /** Replace the recipient autosuggest for User/Group recipients. Fallback: a plain input. */ + renderRecipientSearch?: (ctx: GuardrailRecipientSearchContext) => React.ReactNode; + /** + * Replace the editor for static/asset recipients (types 3/4/5/6). Return `undefined` to + * fall through to the built-in plain input (which edits `value` for static recipients and + * `assetName` for asset ones). + */ + renderStaticRecipient?: (ctx: GuardrailStaticRecipientContext) => React.ReactNode | undefined; + /** Render the escalation app picker. Fallback: a localized "picker unavailable" note. */ + renderAppPicker?: (ctx: GuardrailAppPickerContext) => React.ReactNode; + /** Rendered under the escalation grid (e.g. a marketplace help line). */ + escalateHelp?: React.ReactNode; +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/builder-utils.test.ts b/packages/apollo-react/src/canvas/components/Guardrails/builder-utils.test.ts new file mode 100644 index 0000000000..d6bf971a08 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/builder-utils.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from 'vitest'; +import { + type GuardrailAction, + type GuardrailDefinition, + GuardrailRecipientType, +} from './builder-types'; +import { + createDefaultGuardrailAction, + generateGuardrailId, + getGuardrailActionErrorFields, + getGuardrailSelectorErrorFields, + initGuardrailBuilderFormData, +} from './builder-utils'; + +function makeDef(overrides?: Partial): GuardrailDefinition { + return { + validator: 'pii_detection', + displayName: 'PII Detection', + allowedScopes: ['Agent', 'Llm', 'Tool'], + parameters: [], + status: 'Available', + ...overrides, + }; +} + +describe('generateGuardrailId', () => { + it('produces the guardrail-- format', () => { + expect(generateGuardrailId()).toMatch(/^guardrail-[a-z0-9]+-[a-z0-9]{5}$/); + }); +}); + +describe('createDefaultGuardrailAction', () => { + it('creates the per-type default payloads', () => { + expect(createDefaultGuardrailAction('log')).toEqual({ + $actionType: 'log', + severityLevel: 'Info', + }); + expect(createDefaultGuardrailAction('block')).toEqual({ $actionType: 'block', reason: '' }); + expect(createDefaultGuardrailAction('filter')).toEqual({ $actionType: 'filter', fields: [] }); + expect(createDefaultGuardrailAction('escalate')).toEqual({ + $actionType: 'escalate', + app: { id: '', version: '', name: '' }, + recipient: { type: GuardrailRecipientType.User, value: '', displayName: '' }, + }); + }); +}); + +describe('getGuardrailActionErrorFields', () => { + it('flags an empty block reason', () => { + expect(getGuardrailActionErrorFields({ $actionType: 'block', reason: ' ' })).toEqual([ + 'blockReason', + ]); + expect(getGuardrailActionErrorFields({ $actionType: 'block', reason: 'why' })).toEqual([]); + }); + + it('flags an empty filter field selection', () => { + expect(getGuardrailActionErrorFields({ $actionType: 'filter', fields: [] })).toEqual([ + 'filterFields', + ]); + expect(getGuardrailActionErrorFields({ $actionType: 'filter', fields: [{}] })).toEqual([]); + }); + + it('flags missing escalate recipient and app', () => { + const empty: GuardrailAction = { + $actionType: 'escalate', + app: { id: '', version: '', name: '' }, + recipient: { type: GuardrailRecipientType.User, value: '', displayName: '' }, + }; + expect(getGuardrailActionErrorFields(empty)).toEqual(['recipient', 'actionApp']); + + const filled: GuardrailAction = { + $actionType: 'escalate', + app: { id: 'app-1', version: '1', name: 'Escalation app' }, + recipient: { type: GuardrailRecipientType.StaticEmail, value: 'a@b.c' }, + }; + expect(getGuardrailActionErrorFields(filled)).toEqual([]); + }); + + it('treats a filled asset recipient as valid (validated on assetName)', () => { + const assetRecipient: GuardrailAction = { + $actionType: 'escalate', + app: { id: 'app-1', version: '1', name: 'App' }, + recipient: { type: GuardrailRecipientType.AssetEmail, assetName: 'asset' }, + }; + expect(getGuardrailActionErrorFields(assetRecipient)).toEqual([]); + }); + + it('never flags a log action', () => { + expect(getGuardrailActionErrorFields({ $actionType: 'log', severityLevel: 'Info' })).toEqual( + [] + ); + }); +}); + +describe('getGuardrailSelectorErrorFields', () => { + it('requires at least one scope', () => { + expect(getGuardrailSelectorErrorFields({ scopes: [] })).toEqual(['scopes']); + }); + + it('requires tools when the Tool scope is selected', () => { + expect(getGuardrailSelectorErrorFields({ scopes: ['Tool'] })).toEqual(['toolNames']); + expect(getGuardrailSelectorErrorFields({ scopes: ['Tool'], matchNames: [] })).toEqual([ + 'toolNames', + ]); + expect(getGuardrailSelectorErrorFields({ scopes: ['Tool'], matchNames: ['ToolA'] })).toEqual( + [] + ); + }); + + it('accepts non-Tool scopes without matchNames', () => { + expect(getGuardrailSelectorErrorFields({ scopes: ['Agent', 'Llm'] })).toEqual([]); + }); +}); + +describe('getGuardrailActionErrorFields — asset recipients', () => { + it('treats a filled assetName as a valid recipient and an empty one as invalid', () => { + const base = { + $actionType: 'escalate' as const, + app: { id: 'app1', version: '1', name: 'App' }, + }; + expect( + getGuardrailActionErrorFields({ + ...base, + recipient: { type: GuardrailRecipientType.AssetEmail, assetName: 'EmailAsset' }, + }) + ).toEqual([]); + expect( + getGuardrailActionErrorFields({ + ...base, + recipient: { type: GuardrailRecipientType.AssetGroupName, assetName: ' ' }, + }) + ).toEqual(['recipient']); + }); +}); + +describe('initGuardrailBuilderFormData', () => { + it('copies an existing guardrail verbatim', () => { + const existing = { + id: 'g1', + $guardrailType: 'builtInValidator' as const, + name: 'Mine', + description: 'desc', + selector: { scopes: ['Tool' as const], matchNames: ['OtherTool'] }, + action: { $actionType: 'log' as const, severityLevel: 'Info' as const }, + enabledForEvals: false, + validatorType: 'pii_detection', + validatorParameters: [], + }; + + const form = initGuardrailBuilderFormData(makeDef(), 'Tool', existing, 'MyTool'); + expect(form.id).toBe('g1'); + expect(form.selector.matchNames).toEqual(['OtherTool']); + expect(form.enabledForEvals).toBe(false); + }); + + it('sets matchNames when scope is Tool and toolName is provided', () => { + const form = initGuardrailBuilderFormData(makeDef(), 'Tool', undefined, 'MyTool'); + expect(form.selector.scopes).toEqual(['Tool']); + expect(form.selector.matchNames).toEqual(['MyTool']); + }); + + it('does not set matchNames when scope is Tool but no toolName', () => { + const form = initGuardrailBuilderFormData(makeDef(), 'Tool'); + expect(form.selector.scopes).toEqual(['Tool']); + expect(form.selector.matchNames).toBeUndefined(); + }); + + it('falls back to the first allowedScope when the opening scope is not allowed (non-Agent too)', () => { + // The palette filters by allowedScopes before opening, but a disallowed opening scope + // must never seed a selector the user can't fix (the scope selector renders only for Agent). + const form = initGuardrailBuilderFormData( + makeDef({ allowedScopes: ['Agent', 'Llm'] }), + 'Tool', + undefined, + 'MyTool' + ); + expect(form.selector.scopes).toEqual(['Agent']); + expect(form.selector.matchNames).toBeUndefined(); + }); + + it('falls back to first allowedScope when Agent is not allowed', () => { + const form = initGuardrailBuilderFormData( + makeDef({ allowedScopes: ['Llm', 'Tool'] }), + 'Agent', + undefined, + 'MyTool' + ); + expect(form.selector.scopes).toEqual(['Llm']); + expect(form.selector.matchNames).toBeUndefined(); + }); + + it('sets matchNames when Agent scope falls back to Tool', () => { + const form = initGuardrailBuilderFormData( + makeDef({ allowedScopes: ['Tool'] }), + 'Agent', + undefined, + 'MyTool' + ); + expect(form.selector.scopes).toEqual(['Tool']); + expect(form.selector.matchNames).toEqual(['MyTool']); + }); + + it('seeds defaults with null coercion for create mode', () => { + const definition = makeDef({ + parameters: [ + { id: 'prompt', type: 'text', label: 'Prompt', required: true, defaultValue: null }, + { + id: 'examples', + type: 'text-list', + label: 'Examples', + required: false, + defaultValue: null, + }, + ], + }); + const form = initGuardrailBuilderFormData(definition, 'Agent'); + + expect(form.validatorParameters).toEqual([ + { $parameterType: 'text', id: 'prompt', value: '' }, + { $parameterType: 'text-list', id: 'examples', value: [] }, + ]); + expect(form.action).toEqual({ $actionType: 'log', severityLevel: 'Info' }); + expect(form.enabledForEvals).toBe(true); + expect(form.name).toBe('PII Detection'); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/builder-utils.ts b/packages/apollo-react/src/canvas/components/Guardrails/builder-utils.ts new file mode 100644 index 0000000000..682aa1e011 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/builder-utils.ts @@ -0,0 +1,138 @@ +import { + type GuardrailAction, + type GuardrailBuilderValue, + type GuardrailDefinition, + GuardrailRecipientType, + type GuardrailScope, + type GuardrailSelector, +} from './builder-types'; +import { seedGuardrailParameters } from './utils'; + +export function generateGuardrailId(): string { + return `guardrail-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; +} + +/** Default action payload for a freshly selected action type. */ +export function createDefaultGuardrailAction( + type: GuardrailAction['$actionType'] +): GuardrailAction { + switch (type) { + case 'block': + return { $actionType: 'block', reason: '' }; + case 'filter': + return { $actionType: 'filter', fields: [] }; + case 'escalate': + return { + $actionType: 'escalate', + app: { id: '', version: '', name: '' }, + recipient: { type: GuardrailRecipientType.User, value: '', displayName: '' }, + }; + default: + return { $actionType: 'log', severityLevel: 'Info' }; + } +} + +export type GuardrailActionErrorField = 'blockReason' | 'filterFields' | 'recipient' | 'actionApp'; + +/** Action fields whose current value fails required validation. Message text is caller-owned. */ +export function getGuardrailActionErrorFields( + action: GuardrailAction +): GuardrailActionErrorField[] { + const fields: GuardrailActionErrorField[] = []; + switch (action.$actionType) { + case 'block': + if (!action.reason.trim()) fields.push('blockReason'); + break; + case 'filter': + if (action.fields.length === 0) fields.push('filterFields'); + break; + case 'escalate': { + const recipient = action.recipient; + const filled = + 'value' in recipient + ? recipient.value.trim().length > 0 + : recipient.assetName.trim().length > 0; + if (!filled) fields.push('recipient'); + if (!action.app.id) fields.push('actionApp'); + break; + } + } + return fields; +} + +export type GuardrailSelectorErrorField = 'scopes' | 'toolNames'; + +/** + * Selector fields whose current value fails required validation. Unconditional — callers + * gate on whether the scope selector is shown at all. + */ +export function getGuardrailSelectorErrorFields( + selector: GuardrailSelector +): GuardrailSelectorErrorField[] { + const fields: GuardrailSelectorErrorField[] = []; + if (selector.scopes.length === 0) fields.push('scopes'); + if ( + selector.scopes.includes('Tool') && + (!selector.matchNames || selector.matchNames.length === 0) + ) { + fields.push('toolNames'); + } + return fields; +} + +export interface GuardrailBuilderFormData { + id: string; + name: string; + description: string; + selector: GuardrailSelector; + action: GuardrailAction; + enabledForEvals: boolean; + validatorParameters: GuardrailBuilderValue['validatorParameters']; +} + +/** + * Initial form state: an existing guardrail is copied verbatim (edit); otherwise values are + * seeded from the definition — parameters via `seedGuardrailParameters`, the scope coerced to + * the definition's first allowed scope when `Agent` is not allowed, a log/Info default + * action, and evaluations enabled. + * + * @internal Exported for testing only + */ +export function initGuardrailBuilderFormData( + definition: GuardrailDefinition, + scope: GuardrailScope, + existing?: GuardrailBuilderValue, + toolName?: string +): GuardrailBuilderFormData { + if (existing) { + return { + id: existing.id, + name: existing.name, + description: existing.description ?? '', + selector: existing.selector, + action: existing.action, + enabledForEvals: existing.enabledForEvals, + validatorParameters: existing.validatorParameters, + }; + } + + // Coerce for every opening scope, not just Agent: hosts filter definitions by allowedScopes + // before opening the builder, but a definition that doesn't allow the opening scope must not + // seed an invalid selector the user can't fix (the scope selector only renders for Agent). + const initialScope = definition.allowedScopes.includes(scope) + ? scope + : (definition.allowedScopes[0] ?? scope); + + return { + id: generateGuardrailId(), + name: definition.displayName, + description: '', + selector: { + scopes: [initialScope], + ...(initialScope === 'Tool' && toolName ? { matchNames: [toolName] } : {}), + }, + action: { $actionType: 'log', severityLevel: 'Info' }, + enabledForEvals: true, + validatorParameters: seedGuardrailParameters(definition.parameters), + }; +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/enum-list-chips-field.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/enum-list-chips-field.tsx new file mode 100644 index 0000000000..feb53fcbda --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/enum-list-chips-field.tsx @@ -0,0 +1,49 @@ +import type { CustomFieldComponentProps } from '@uipath/apollo-wind'; +import { FormField, FormFieldError } from '@uipath/apollo-wind'; +import type { GuardrailValidatorFormLabels } from '../i18n'; +import type { GuardrailParameterDefinition } from '../types'; +import { FieldShell } from './field-shell'; +import { GuardrailChip } from './guardrail-chip'; +import { ParameterLabel } from './parameter-label'; + +/** + * Inline chip-toggle editor for enum-list parameters with small option sets (the ≤8 case), + * registered as the `guardrail-enum-list-chips` custom component; larger sets map to the + * first-class `multiselect` field type instead. `paramDef` and `labels` arrive via + * `componentProps` from `buildGuardrailFormSchema`. + */ +export function EnumListChipsField(props: CustomFieldComponentProps) { + const { value, onChange, error, disabled } = props; + const paramDef = props.paramDef as GuardrailParameterDefinition; + const labels = props.labels as GuardrailValidatorFormLabels; + + const selected = Array.isArray(value) ? (value as string[]) : []; + + /** Friendly label for an option value, falling back to the raw value when unmapped. */ + const labelFor = (option: string) => paramDef.optionLabels?.[option] ?? option; + + const handleToggle = (option: string, pressed: boolean) => { + onChange(pressed ? [...selected, option] : selected.filter((s) => s !== option)); + }; + + return ( + + + +
+ {(paramDef.options ?? []).map((option) => ( + handleToggle(option, pressed)} + disabled={disabled} + > + {labelFor(option)} + + ))} +
+
+ {error} +
+ ); +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/escalate-action-fields.test.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/escalate-action-fields.test.tsx new file mode 100644 index 0000000000..caab56fb7f --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/escalate-action-fields.test.tsx @@ -0,0 +1,309 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { axe } from 'jest-axe'; +import { describe, expect, it, vi } from 'vitest'; +import { type GuardrailAction, GuardrailRecipientType } from '../builder-types'; +import { GUARDRAIL_BUILDER_EN_LABELS } from '../i18n'; +import { EscalateActionFields } from './escalate-action-fields'; + +const labels = GUARDRAIL_BUILDER_EN_LABELS; +type EscalateAction = Extract; + +function makeAction(overrides?: Partial): EscalateAction { + return { + $actionType: 'escalate', + app: { id: '', version: '', name: '' }, + recipient: { type: GuardrailRecipientType.User, value: '', displayName: '' }, + ...overrides, + }; +} + +const baseProps = { + actionTypeSelect:
, + labels, +}; + +describe('EscalateActionFields', () => { + it('renders the injected action type cell inside its grid', () => { + render(); + expect(screen.getByTestId('action-type-cell')).toBeInTheDocument(); + }); + + describe('recipient type switching', () => { + it.each([ + ['Group', { type: GuardrailRecipientType.Group, value: '', displayName: '' }], + ['Email address', { type: GuardrailRecipientType.StaticEmail, value: '' }], + ['Group name', { type: GuardrailRecipientType.StaticGroupName, value: '' }], + ] as const)('switching to %s resets the recipient payload', async (optionLabel, expectedRecipient) => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('combobox')); + fireEvent.click(await screen.findByRole('option', { name: optionLabel })); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ recipient: expectedRecipient }) + ); + }); + }); + + it('uses the renderRecipientSearch slot for user recipients', () => { + const onChange = vi.fn(); + render( + ( + + )} + /> + ); + + const slot = screen.getByTestId('directory-search'); + expect(slot).toHaveAttribute('data-kind', 'user'); + expect(slot).toHaveAttribute('data-display', 'User One'); + expect(slot).toHaveAttribute('data-invalid', 'true'); + + fireEvent.click(slot); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + recipient: { type: GuardrailRecipientType.User, value: 'u2', displayName: 'User Two' }, + }) + ); + }); + + it('falls back to a plain input for user recipients without the slot', () => { + const onChange = vi.fn(); + render(); + + const input = screen.getByPlaceholderText('Search for a user...'); + fireEvent.change(input, { target: { value: 'jane@acme.com' } }); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + recipient: { + type: GuardrailRecipientType.User, + value: 'jane@acme.com', + displayName: 'jane@acme.com', + }, + }) + ); + }); + + it('renders a plain input for static email recipients', () => { + const onChange = vi.fn(); + render( + + ); + + fireEvent.change(screen.getByPlaceholderText('Enter email address'), { + target: { value: 'a@b.c' }, + }); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + recipient: { type: GuardrailRecipientType.StaticEmail, value: 'a@b.c' }, + }) + ); + }); + + it('renders the recipient error once, inside the recipient field', () => { + render( + + ); + + expect(screen.getAllByText('Recipient is required')).toHaveLength(1); + }); + + it('uses the renderAppPicker slot with app context', () => { + const onChange = vi.fn(); + render( + ( + + )} + /> + ); + + const slot = screen.getByTestId('app-picker'); + expect(slot).toHaveAttribute('data-label', 'Action App'); + expect(slot).toHaveAttribute('data-error', 'Action app is required'); + expect(slot).toHaveAttribute('data-app', 'My app'); + + fireEvent.click(slot); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ app: { id: '', version: '', name: '' } }) + ); + }); + + it('falls back to the unavailable note without an app picker slot', () => { + render(); + expect(screen.getByText(/app picker unavailable/i)).toBeInTheDocument(); + }); + + it('renders escalateHelp below the grid', () => { + render( + See the marketplace.

} + /> + ); + + expect(screen.getByTestId('help-line')).toBeInTheDocument(); + }); + + it('has no accessibility violations', async () => { + const { container } = render( + + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); + +describe('renderStaticRecipient slot', () => { + it('mounts the slot for static recipients and lets it replace the recipient wholesale', () => { + const onChange = vi.fn(); + const action = makeAction({ + recipient: { type: GuardrailRecipientType.StaticEmail, value: 'a@b.c' }, + }); + + render( + ( + + )} + /> + ); + + const slot = screen.getByTestId('static-slot'); + expect(slot).toHaveAttribute('data-kind', 'email'); + fireEvent.click(slot); + expect(onChange).toHaveBeenCalledWith({ + ...action, + recipient: { type: GuardrailRecipientType.AssetEmail, assetName: 'EmailAsset' }, + }); + }); + + it('falls through to the built-in input when the slot returns undefined', () => { + const onChange = vi.fn(); + const action = makeAction({ + recipient: { type: GuardrailRecipientType.StaticGroupName, value: '' }, + }); + + render( + undefined} + /> + ); + + const input = screen.getByPlaceholderText(labels.groupNamePlaceholder); + fireEvent.change(input, { target: { value: 'Ops' } }); + expect(onChange).toHaveBeenCalledWith({ + ...action, + recipient: { type: GuardrailRecipientType.StaticGroupName, value: 'Ops' }, + }); + }); + + it('is not invoked for searchable (user/group) recipients', () => { + const renderStaticRecipient = vi.fn(); + render( + {}} + renderStaticRecipient={renderStaticRecipient} + /> + ); + expect(renderStaticRecipient).not.toHaveBeenCalled(); + }); +}); + +describe('asset recipient variants', () => { + it('displays an asset recipient as its static sibling in the type select', () => { + render( + {}} + /> + ); + + expect( + screen.getByRole('combobox', { + name: `${labels.assignToLabel}: ${labels.recipientGroupNameLabel}`, + }) + ).toBeInTheDocument(); + }); + + it('edits assetName through the built-in fallback input', () => { + const onChange = vi.fn(); + const action = makeAction({ + recipient: { type: GuardrailRecipientType.AssetEmail, assetName: 'Old' }, + }); + + render(); + + const input = screen.getByPlaceholderText(labels.emailPlaceholder); + expect(input).toHaveValue('Old'); + fireEvent.change(input, { target: { value: 'New' } }); + expect(onChange).toHaveBeenCalledWith({ + ...action, + recipient: { type: GuardrailRecipientType.AssetEmail, assetName: 'New' }, + }); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/escalate-action-fields.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/escalate-action-fields.tsx new file mode 100644 index 0000000000..f7f86d1278 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/escalate-action-fields.tsx @@ -0,0 +1,273 @@ +import { + Alert, + AlertDescription, + FormField, + FormFieldError, + Input, + Label, + RequiredIndicator, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@uipath/apollo-wind'; +import { Info } from 'lucide-react'; +import { type ReactNode, useCallback } from 'react'; +import { + type GuardrailAction, + type GuardrailAppPickerContext, + type GuardrailEscalateRecipient, + type GuardrailRecipientSearchContext, + GuardrailRecipientType, + type GuardrailStaticRecipientContext, +} from '../builder-types'; +import type { GuardrailBuilderLabels } from '../i18n'; + +type EscalateAction = Extract; + +export interface EscalateActionFieldsProps { + action: EscalateAction; + onChange: (action: EscalateAction) => void; + /** Leading grid cell (the Action type select); this component owns the full escalate layout. */ + actionTypeSelect: ReactNode; + errors?: { recipient?: string; actionApp?: string }; + labels: GuardrailBuilderLabels; + renderRecipientSearch?: (ctx: GuardrailRecipientSearchContext) => ReactNode; + renderStaticRecipient?: (ctx: GuardrailStaticRecipientContext) => ReactNode | undefined; + renderAppPicker?: (ctx: GuardrailAppPickerContext) => ReactNode; + /** Rendered under the escalation grid (e.g. a marketplace help line). */ + escalateHelp?: ReactNode; +} + +/** + * Escalation action fields: recipient type + recipient value + action-app picker. The + * recipient autosuggest (User/Group) and the app picker are host capabilities injected via + * render props; plain-input / unavailable-note fallbacks keep the form usable without them. + */ +export function EscalateActionFields({ + action, + onChange, + actionTypeSelect, + errors, + labels, + renderRecipientSearch, + renderStaticRecipient, + renderAppPicker, + escalateHelp, +}: EscalateActionFieldsProps) { + const recipientTypeLabels: Record = { + [GuardrailRecipientType.User]: labels.recipientUserLabel, + [GuardrailRecipientType.Group]: labels.recipientGroupLabel, + [GuardrailRecipientType.StaticEmail]: labels.recipientEmailLabel, + [GuardrailRecipientType.StaticGroupName]: labels.recipientGroupNameLabel, + }; + + const recipientType = action.recipient.type; + // The type select offers the four base entries; asset variants (a host-slot concern) + // display as their static siblings so the selection never blanks. + const displayedRecipientType = + recipientType === GuardrailRecipientType.AssetEmail + ? GuardrailRecipientType.StaticEmail + : recipientType === GuardrailRecipientType.AssetGroupName + ? GuardrailRecipientType.StaticGroupName + : recipientType; + + const handleRecipientTypeChange = useCallback( + (value: string) => { + const newType = Number(value) as GuardrailEscalateRecipient['type']; + let recipient: GuardrailEscalateRecipient; + + switch (newType) { + case GuardrailRecipientType.Group: + recipient = { type: GuardrailRecipientType.Group, value: '', displayName: '' }; + break; + case GuardrailRecipientType.StaticEmail: + recipient = { type: GuardrailRecipientType.StaticEmail, value: '' }; + break; + case GuardrailRecipientType.StaticGroupName: + recipient = { type: GuardrailRecipientType.StaticGroupName, value: '' }; + break; + default: + recipient = { type: GuardrailRecipientType.User, value: '', displayName: '' }; + } + + onChange({ ...action, recipient }); + }, + [action, onChange] + ); + + const handleRecipientSelect = useCallback( + (selection: { value: string; displayName: string }) => { + const r = action.recipient; + if (r.type === GuardrailRecipientType.User || r.type === GuardrailRecipientType.Group) { + onChange({ + ...action, + recipient: { ...r, value: selection.value, displayName: selection.displayName }, + }); + } + }, + [action, onChange] + ); + + const handleRecipientClear = useCallback(() => { + const r = action.recipient; + if (r.type === GuardrailRecipientType.User || r.type === GuardrailRecipientType.Group) { + onChange({ ...action, recipient: { ...r, value: '', displayName: '' } }); + } + }, [action, onChange]); + + const handleTextValueChange = useCallback( + (value: string) => { + const r = action.recipient; + if ( + r.type === GuardrailRecipientType.StaticEmail || + r.type === GuardrailRecipientType.StaticGroupName + ) { + onChange({ ...action, recipient: { ...r, value } }); + } else if ( + r.type === GuardrailRecipientType.AssetEmail || + r.type === GuardrailRecipientType.AssetGroupName + ) { + onChange({ ...action, recipient: { ...r, assetName: value } }); + } + }, + [action, onChange] + ); + + const recipientValue = + 'value' in action.recipient ? action.recipient.value : action.recipient.assetName; + const recipientDisplayValue = + ('displayName' in action.recipient ? action.recipient.displayName : '') || recipientValue; + const isSearchable = + recipientType === GuardrailRecipientType.User || recipientType === GuardrailRecipientType.Group; + const searchKind = recipientType === GuardrailRecipientType.User ? 'user' : 'group'; + const searchPlaceholder = + searchKind === 'user' ? labels.userSearchPlaceholder : labels.groupSearchPlaceholder; + + const appPickerCtx: GuardrailAppPickerContext = { + app: action.app.name ? action.app : null, + onChange: (app) => onChange({ ...action, app: app ?? { id: '', version: '', name: '' } }), + label: labels.actionAppLabel, + error: errors?.actionApp, + }; + + const fields = ( + <> + {/* Recipient type */} + + + + + + {/* Recipient value */} + + + {isSearchable ? ( + <> + {renderRecipientSearch ? ( + renderRecipientSearch({ + kind: searchKind, + displayValue: recipientDisplayValue, + placeholder: searchPlaceholder, + invalid: Boolean(errors?.recipient), + onSelect: handleRecipientSelect, + onClear: handleRecipientClear, + }) + ) : ( + // Fallback without a host directory search: a plain input writing the value directly. + + handleRecipientSelect({ value: e.target.value, displayName: e.target.value }) + } + placeholder={searchPlaceholder} + aria-invalid={errors?.recipient ? true : undefined} + /> + )} + {errors?.recipient} + + ) : ( + (() => { + const staticNode = renderStaticRecipient?.({ + kind: + displayedRecipientType === GuardrailRecipientType.StaticEmail + ? 'email' + : 'groupName', + recipient: action.recipient, + label: recipientTypeLabels[displayedRecipientType] ?? labels.recipientFallbackLabel, + invalid: Boolean(errors?.recipient), + error: errors?.recipient, + onChange: (recipient) => onChange({ ...action, recipient }), + }); + if (staticNode !== undefined) { + return ( + <> + {staticNode} + {errors?.recipient} + + ); + } + return ( + handleTextValueChange(e.target.value)} + placeholder={ + displayedRecipientType === GuardrailRecipientType.StaticEmail + ? labels.emailPlaceholder + : labels.groupNamePlaceholder + } + error={errors?.recipient} + /> + ); + })() + )} + + + {/* Action app picker (host capability) */} + + {renderAppPicker ? ( + renderAppPicker(appPickerCtx) + ) : ( + <> + + + + {labels.appPickerUnavailable} + + + )} + + + ); + + return ( +
+
+ {actionTypeSelect} + {fields} +
+ {escalateHelp} +
+ ); +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/field-shell.test.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/field-shell.test.tsx new file mode 100644 index 0000000000..eb57307445 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/field-shell.test.tsx @@ -0,0 +1,13 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { FieldShell } from './field-shell'; + +describe('FieldShell', () => { + it('renders the error border only when invalid', () => { + const { rerender } = render(); + expect(screen.getByTestId('shell')).not.toHaveClass('border-error'); + + rerender(); + expect(screen.getByTestId('shell')).toHaveClass('border-error'); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/field-shell.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/field-shell.tsx new file mode 100644 index 0000000000..7658c73d11 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/field-shell.tsx @@ -0,0 +1,28 @@ +import { cn } from '@uipath/apollo-wind'; +import * as React from 'react'; + +export interface FieldShellProps extends React.ComponentPropsWithoutRef<'div'> { + /** Renders the error border (uses the `error` token, matching aria-invalid controls). */ + invalid?: boolean; +} + +/** Input-look container for non-input controls (chip groups) in the guardrail forms. */ +const FieldShell = React.forwardRef( + ({ invalid = false, className, children, ...props }, ref) => ( +
+ {children} +
+ ) +); +FieldShell.displayName = 'FieldShell'; + +export { FieldShell }; diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-action-section.test.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-action-section.test.tsx new file mode 100644 index 0000000000..b2a8a31283 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-action-section.test.tsx @@ -0,0 +1,121 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { axe } from 'jest-axe'; +import { describe, expect, it, vi } from 'vitest'; +import { type GuardrailAction, GuardrailRecipientType } from '../builder-types'; +import { GUARDRAIL_BUILDER_EN_LABELS } from '../i18n'; +import { GuardrailActionSection } from './guardrail-action-section'; + +const labels = GUARDRAIL_BUILDER_EN_LABELS; +const logAction: GuardrailAction = { $actionType: 'log', severityLevel: 'Info' }; + +describe('GuardrailActionSection', () => { + it('renders the action type select with the current value', () => { + render(); + + expect(screen.getByText('Action type')).toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: /action type/i })).toHaveTextContent('Log'); + }); + + it('resets the payload when the action type changes', async () => { + const onActionChange = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('combobox', { name: /action type/i })); + fireEvent.click(await screen.findByRole('option', { name: 'Block' })); + + expect(onActionChange).toHaveBeenCalledWith({ $actionType: 'block', reason: '' }); + }); + + it('offers Filter only when showFilter is set', async () => { + const { unmount } = render( + + ); + fireEvent.click(screen.getByRole('combobox', { name: /action type/i })); + expect(await screen.findByRole('option', { name: 'Log' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'Filter' })).not.toBeInTheDocument(); + unmount(); + + render( + + ); + fireEvent.click(screen.getByRole('combobox', { name: /action type/i })); + expect(await screen.findByRole('option', { name: 'Filter' })).toBeInTheDocument(); + }); + + it('renders the severity select for log actions and reports changes', async () => { + const onActionChange = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('combobox', { name: /severity level/i })); + fireEvent.click(await screen.findByRole('option', { name: 'Warning' })); + + expect(onActionChange).toHaveBeenCalledWith({ $actionType: 'log', severityLevel: 'Warning' }); + }); + + it('renders the blocking reason input with its error for block actions', () => { + const onActionChange = vi.fn(); + render( + + ); + + const input = screen.getByLabelText(/blocking reason/i); + fireEvent.change(input, { target: { value: 'no PII' } }); + expect(onActionChange).toHaveBeenCalledWith({ $actionType: 'block', reason: 'no PII' }); + expect(screen.getByText('Block reason is required')).toBeInTheDocument(); + }); + + it('renders filterContent and its error for filter actions', () => { + render( + } + errors={{ filterFields: 'Fields selection is required' }} + labels={labels} + /> + ); + + expect(screen.getByTestId('filter-slot')).toBeInTheDocument(); + expect(screen.getByText('Fields selection is required')).toBeInTheDocument(); + }); + + it('delegates escalate actions to the escalation fields', () => { + render( + + ); + + expect(screen.getByText('Assign to')).toBeInTheDocument(); + expect(screen.getByText('Action App')).toBeInTheDocument(); + }); + + it('has no accessibility violations', async () => { + const { container } = render( + + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-action-section.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-action-section.tsx new file mode 100644 index 0000000000..2fff55ac0c --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-action-section.tsx @@ -0,0 +1,153 @@ +import { + FormField, + FormFieldError, + Input, + Label, + RequiredIndicator, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@uipath/apollo-wind'; +import type { ReactNode } from 'react'; +import type { + GuardrailAction, + GuardrailAppPickerContext, + GuardrailRecipientSearchContext, + GuardrailStaticRecipientContext, +} from '../builder-types'; +import { createDefaultGuardrailAction } from '../builder-utils'; +import type { GuardrailBuilderLabels } from '../i18n'; +import { EscalateActionFields } from './escalate-action-fields'; + +export interface GuardrailActionSectionProps { + action: GuardrailAction; + onActionChange: (action: GuardrailAction) => void; + /** Whether to include 'filter' as an option (custom guardrails only) */ + showFilter?: boolean; + /** Content rendered as the second grid column when $actionType === 'filter' */ + filterContent?: ReactNode; + errors?: { blockReason?: string; filterFields?: string; recipient?: string; actionApp?: string }; + labels: GuardrailBuilderLabels; + renderRecipientSearch?: (ctx: GuardrailRecipientSearchContext) => ReactNode; + renderStaticRecipient?: (ctx: GuardrailStaticRecipientContext) => ReactNode | undefined; + renderAppPicker?: (ctx: GuardrailAppPickerContext) => ReactNode; + escalateHelp?: ReactNode; +} + +/** + * Action section of the guardrail builder: a 2-column grid of action-type select + the + * type-dependent secondary field. Switching the type resets the action payload. Escalate + * expands into the full escalation layout. + */ +export function GuardrailActionSection({ + action, + onActionChange, + showFilter = false, + filterContent, + errors, + labels, + renderRecipientSearch, + renderStaticRecipient, + renderAppPicker, + escalateHelp, +}: GuardrailActionSectionProps) { + const actionTypeSelect = ( + + + + + ); + + if (action.$actionType === 'escalate') { + return ( + + ); + } + + return ( +
+
+ {actionTypeSelect} + + {action.$actionType === 'log' && ( + + + + + )} + + {action.$actionType === 'block' && ( + + + + onActionChange({ ...action, reason: e.target.value } as GuardrailAction) + } + placeholder={labels.blockReasonPlaceholder} + error={errors?.blockReason} + /> + + )} + + {action.$actionType === 'filter' && ( + + {filterContent} + {errors?.filterFields} + + )} +
+
+ ); +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-chip.test.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-chip.test.tsx new file mode 100644 index 0000000000..da7948f865 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-chip.test.tsx @@ -0,0 +1,45 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { axe } from 'jest-axe'; +import { describe, expect, it, vi } from 'vitest'; +import { GuardrailChip } from './guardrail-chip'; + +describe('GuardrailChip', () => { + it('renders a pressed toggle button', () => { + render(Email); + expect(screen.getByRole('button', { name: 'Email', pressed: true })).toBeInTheDocument(); + }); + + it('reports presses', () => { + const onPressedChange = vi.fn(); + render( + + Email + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Email', pressed: false })); + expect(onPressedChange).toHaveBeenCalledWith(true); + }); + + it('renders the addable appearance with a dashed border', () => { + render( + + ToolB + + ); + expect(screen.getByRole('button', { name: 'ToolB' })).toHaveClass('border-dashed'); + }); + + it('has no accessibility violations', async () => { + const { container } = render( + <> + On + + Addable + + + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-chip.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-chip.tsx new file mode 100644 index 0000000000..81a35a2465 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-chip.tsx @@ -0,0 +1,47 @@ +import { cn, Toggle } from '@uipath/apollo-wind'; +import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; + +/** + * Pill geometry, shared with the read-only `GuardrailStatusChip` so the interactive and + * label chips stay one system. h-6/px-2.5/text-xs is the compact chip scale. + */ +export const GUARDRAIL_CHIP_GEOMETRY = + 'min-w-0 gap-1 rounded-full border px-2.5 text-xs font-medium [&_svg]:size-3'; + +const guardrailChipVariants = cva( + // Geometry over Toggle's base, which contributes the focus ring, disabled handling and the + // data-[state] hooks. + `h-6 ${GUARDRAIL_CHIP_GEOMETRY}`, + { + variants: { + appearance: { + default: + 'bg-background text-foreground border-border hover:bg-muted hover:text-foreground data-[state=on]:bg-brand-subtle data-[state=on]:text-foreground-accent data-[state=on]:border-brand-lighter', + // Dashed affordance for items that can be added but are not currently targeted. + addable: + 'border-dashed bg-background text-foreground-muted border-border hover:bg-muted hover:text-foreground', + }, + }, + defaultVariants: { appearance: 'default' }, + } +); + +export interface GuardrailChipProps + extends React.ComponentPropsWithoutRef, + VariantProps {} + +/** Toggleable pill used for scopes, entities, and tool targeting in the guardrail forms. */ +const GuardrailChip = React.forwardRef( + ({ appearance, className, ...props }, ref) => ( + + ) +); +GuardrailChip.displayName = 'GuardrailChip'; + +export { GuardrailChip, guardrailChipVariants }; diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-list-row.test.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-list-row.test.tsx new file mode 100644 index 0000000000..2af8d9010e --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-list-row.test.tsx @@ -0,0 +1,249 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { axe } from 'jest-axe'; +import { describe, expect, it, vi } from 'vitest'; +import { + BYO_GUARDRAIL, + CUSTOM_GUARDRAIL, + DEFINITIONS, + PII_GUARDRAIL, +} from '../__fixtures__/guardrail-list.fixtures'; +import { resolveGuardrailListItemState } from '../guardrail-list-utils'; +import { GUARDRAIL_LIST_EN_LABELS } from '../i18n'; +import type { GuardrailListItem } from '../list-types'; +import { GuardrailListRow, type GuardrailListRowProps } from './guardrail-list-row'; + +const labels = GUARDRAIL_LIST_EN_LABELS; + +function renderRow( + item: GuardrailListItem = PII_GUARDRAIL, + props: Partial = {}, + definitions = DEFINITIONS +) { + return render( + + ); +} + +describe('GuardrailListRow', () => { + it('renders the name, description, action and scopes', () => { + renderRow(); + + expect(screen.getByText('PII detection 1')).toBeInTheDocument(); + expect(screen.getByText('Scans agent output for personal data.')).toBeInTheDocument(); + expect(screen.getByText('log')).toBeInTheDocument(); + expect(screen.getByText('Agent, Tool')).toBeInTheDocument(); + }); + + it('renders the BYO connector as the provider line', () => { + renderRow(BYO_GUARDRAIL); + + expect(screen.getByText('Provider: Noma Security')).toBeInTheDocument(); + }); + + it('falls back to the unknown-action label when a row has no action', () => { + renderRow({ name: 'Legacy guardrail' }); + + expect(screen.getByText('Unknown')).toBeInTheDocument(); + }); + + it('renders no action or scopes line when both are formatted away', () => { + renderRow(PII_GUARDRAIL, { formatAction: () => null, formatScopes: () => null }); + + expect(screen.queryByText('log')).not.toBeInTheDocument(); + expect(screen.queryByText('Agent, Tool')).not.toBeInTheDocument(); + }); + + it('lets the host localize the scopes and the action', () => { + renderRow(PII_GUARDRAIL, { + formatScopes: (item) => `Scopes: ${item.selector?.scopes?.length}`, + formatAction: () => 'Protokollieren', + }); + + expect(screen.getByText('Scopes: 2')).toBeInTheDocument(); + expect(screen.getByText('Protokollieren')).toBeInTheDocument(); + }); + + describe('chips', () => { + it('renders no chips by default', () => { + renderRow(PII_GUARDRAIL, { statusChips: false, administration: 'governance' }); + + expect(screen.queryByText('Governance managed')).not.toBeInTheDocument(); + }); + + it('renders the status and administration chips when asked', () => { + renderRow(BYO_GUARDRAIL, { statusChips: true, administration: 'governance' }, [ + { validator: 'byo', status: 'Unauthorised', byoValidatorName: 'noma_prompt_injection' }, + ]); + + expect(screen.getByText('Unauthorized')).toBeInTheDocument(); + expect(screen.getByText('Governance managed')).toBeInTheDocument(); + }); + + it('renders the preview badge only for built-in validators, and only when asked', () => { + const { unmount } = renderRow(PII_GUARDRAIL, { previewChip: true }); + expect(screen.getByText('Preview')).toBeInTheDocument(); + unmount(); + + renderRow(CUSTOM_GUARDRAIL, { previewChip: true }); + expect(screen.queryByText('Preview')).not.toBeInTheDocument(); + }); + }); + + describe('BYO notices', () => { + it('announces a disabled configuration', () => { + renderRow(BYO_GUARDRAIL, {}, [ + { validator: 'byo', status: 'Disabled', byoValidatorName: 'noma_prompt_injection' }, + ]); + + expect(screen.getByRole('alert')).toHaveTextContent( + "This guardrail's configuration has been disabled" + ); + }); + + it('announces a configuration that no longer resolves', () => { + renderRow(BYO_GUARDRAIL, {}, [{ validator: 'pii_detection', status: 'Available' }]); + + expect(screen.getByRole('alert')).toHaveTextContent( + "This guardrail's configuration is no longer available" + ); + }); + + it('shows neither notice for a healthy row', () => { + renderRow(BYO_GUARDRAIL); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + }); + + describe('actions', () => { + it('renders only the actions the host wired up', () => { + renderRow(PII_GUARDRAIL, { onEdit: vi.fn() }); + + expect(screen.getByRole('button', { name: 'Edit guardrail' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Remove guardrail' })).not.toBeInTheDocument(); + }); + + it('reports edit and remove intents with the row', () => { + const onEdit = vi.fn(); + const onRemove = vi.fn(); + renderRow(PII_GUARDRAIL, { onEdit, onRemove }); + + fireEvent.click(screen.getByRole('button', { name: 'Edit guardrail' })); + fireEvent.click(screen.getByRole('button', { name: 'Remove guardrail' })); + + expect(onEdit).toHaveBeenCalledWith(PII_GUARDRAIL); + expect(onRemove).toHaveBeenCalledWith(PII_GUARDRAIL); + }); + + it('disables both actions when the list is read-only', () => { + const onEdit = vi.fn(); + renderRow(PII_GUARDRAIL, { onEdit, onRemove: vi.fn(), disabled: true }); + + const edit = screen.getByRole('button', { name: 'Edit guardrail' }); + expect(edit).toBeDisabled(); + fireEvent.click(edit); + expect(onEdit).not.toHaveBeenCalled(); + }); + + it('hands the slot the row, its position and the default actions', () => { + const renderItemActions = vi.fn(() => ); + renderRow(PII_GUARDRAIL, { onEdit: vi.fn(), index: 2, renderItemActions }); + + expect(screen.getByRole('button', { name: 'More options' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Edit guardrail' })).not.toBeInTheDocument(); + expect(renderItemActions).toHaveBeenCalledWith( + expect.objectContaining({ item: PII_GUARDRAIL, id: 'g1', index: 2, disabled: false }) + ); + }); + + it('lets a slot render the default actions alongside its own', () => { + renderRow(PII_GUARDRAIL, { + onEdit: vi.fn(), + renderItemActions: ({ defaultActions }) => ( + <> + {defaultActions} + + + ), + }); + + expect(screen.getByRole('button', { name: 'Edit guardrail' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'More options' })).toBeInTheDocument(); + }); + }); + + describe('rowActivatesEdit', () => { + it('does not make the body activatable by default', () => { + renderRow(PII_GUARDRAIL, { onEdit: vi.fn() }); + + expect( + screen.queryByRole('button', { name: 'Edit PII detection 1' }) + ).not.toBeInTheDocument(); + }); + + it('opens the editor on click and on Enter or Space', () => { + const onEdit = vi.fn(); + renderRow(PII_GUARDRAIL, { onEdit, rowActivatesEdit: true }); + + const body = screen.getByRole('button', { name: 'Edit PII detection 1' }); + fireEvent.click(body); + fireEvent.keyDown(body, { key: 'Enter' }); + fireEvent.keyDown(body, { key: ' ' }); + + expect(onEdit).toHaveBeenCalledTimes(3); + }); + + it('stays inert while read-only', () => { + renderRow(PII_GUARDRAIL, { onEdit: vi.fn(), rowActivatesEdit: true, disabled: true }); + + expect( + screen.queryByRole('button', { name: 'Edit PII detection 1' }) + ).not.toBeInTheDocument(); + }); + + it('keeps the row actions out of the activatable body, so no button nests in a button', async () => { + const { container } = renderRow(PII_GUARDRAIL, { + onEdit: vi.fn(), + onRemove: vi.fn(), + rowActivatesEdit: true, + }); + + const body = screen.getByRole('button', { name: 'Edit PII detection 1' }); + expect(body.querySelector('button')).toBeNull(); + expect(await axe(container)).toHaveNoViolations(); + }); + }); + + it('renders the host tooltip over the row body', () => { + renderRow(PII_GUARDRAIL, { renderRowTooltip: (item) => `About ${item.name}` }); + + // Radix keeps the content unmounted until it opens; what matters here is that a row + // brings its own provider, so mounting a tooltip outside one does not throw. + expect(screen.getByText('PII detection 1')).toBeInTheDocument(); + }); + + it('renders the drag handle the list passes in', () => { + renderRow(PII_GUARDRAIL, { handle: }); + + expect(screen.getByRole('button', { name: 'Reorder guardrail' })).toBeInTheDocument(); + }); + + it('has no accessibility violations', async () => { + const { container } = renderRow(BYO_GUARDRAIL, { + onEdit: vi.fn(), + onRemove: vi.fn(), + statusChips: true, + previewChip: true, + administration: 'governance', + }); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-list-row.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-list-row.tsx new file mode 100644 index 0000000000..0ee9cfbfd7 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-list-row.tsx @@ -0,0 +1,248 @@ +import { + Badge, + Button, + cn, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@uipath/apollo-wind'; +import { Pencil, Trash2 } from 'lucide-react'; +import * as React from 'react'; +import { getGuardrailListChips } from '../guardrail-list-utils'; +import { formatGuardrailFormMessage, type GuardrailListLabels } from '../i18n'; +import type { + GuardrailListAdministration, + GuardrailListItem, + GuardrailListItemActionsContext, + GuardrailListItemState, +} from '../list-types'; +import { GuardrailStatusChip } from './guardrail-status-chip'; + +export interface GuardrailListRowProps + extends Omit, 'children'> { + item: GuardrailListItem; + /** The row's resolved id, and its index in the rendered list. */ + id: string; + index: number; + /** What resolving the row against the definitions produced. */ + state: GuardrailListItemState; + labels: GuardrailListLabels; + /** Read-only: edit and remove are disabled. Reorder is the list's decision, not the row's. */ + disabled?: boolean; + /** Drag handle, already wired to the sortable. Absent when the list cannot be reordered. */ + handle?: React.ReactNode; + administration?: GuardrailListAdministration; + statusChips?: boolean; + previewChip?: boolean; + /** Makes the row body a button that opens the editor (Agents' behaviour). */ + rowActivatesEdit?: boolean; + onEdit?: (item: GuardrailListItem) => void; + onRemove?: (item: GuardrailListItem) => void; + renderItemActions?: (ctx: GuardrailListItemActionsContext) => React.ReactNode; + /** Hover content for the row body. Return nothing to leave the row untooltipped. */ + renderRowTooltip?: (item: GuardrailListItem) => React.ReactNode; + formatScopes?: (item: GuardrailListItem) => React.ReactNode; + formatAction?: (item: GuardrailListItem) => React.ReactNode; +} + +/** + * One guardrail row: drag handle, name with its lifecycle and status chips, BYO notices, + * description, provider line, action badge and scopes, then the row actions. + * + * Presentational and reorder-free. The sortable wrapper lives in `guardrail-list.tsx` and + * reaches the row through `ref`, `style` and `handle`, so this renders identically whether or + * not the list is reorderable, and the a11y suites can mount it without a `DndContext`. + */ +const GuardrailListRow = React.forwardRef( + ( + { + item, + id, + index, + state, + labels, + disabled = false, + handle, + administration, + statusChips = false, + previewChip = false, + rowActivatesEdit = false, + onEdit, + onRemove, + renderItemActions, + renderRowTooltip, + formatScopes, + formatAction, + className, + ...props + }, + ref + ) => { + const handleEdit = onEdit && !disabled ? () => onEdit(item) : undefined; + const handleRemove = onRemove && !disabled ? () => onRemove(item) : undefined; + + const chips = statusChips + ? getGuardrailListChips({ status: state.status, administration }, labels) + : []; + const isBuiltInValidator = item.$guardrailType === 'builtInValidator'; + + const scopes = item.selector?.scopes ?? []; + // Presence of the prop decides, never the value: a host hides the line by returning null + // (Agents renders scopes in its own tooltip instead), which `??` would swallow. + const scopesContent = formatScopes ? formatScopes(item) : scopes.join(', ') || null; + const actionContent = formatAction + ? formatAction(item) + : (item.action?.$actionType ?? labels.actionUnknown); + + const defaultActions = ( + <> + {onEdit && ( + + )} + {onRemove && ( + + )} + + ); + + const actions = renderItemActions + ? renderItemActions({ + item, + id, + index, + disabled, + onEdit: handleEdit, + onRemove: handleRemove, + defaultActions, + }) + : defaultActions; + + // Only the body carries the role: the handle and the actions are siblings, so an + // activatable row never nests interactive controls inside a button. + const bodyProps = + rowActivatesEdit && handleEdit + ? { + role: 'button', + tabIndex: 0, + 'aria-label': formatGuardrailFormMessage(labels.editRow, { name: item.name }), + onClick: handleEdit, + onKeyDown: (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleEdit(); + } + }, + } + : {}; + + const body = ( +
+
+ {item.name} + {previewChip && isBuiltInValidator && ( + {labels.preview} + )} + {chips.map((chip) => ( + + {chip.label} + + ))} +
+ {/* Both notices are ungated: both products already show them, and a guardrail that + cannot run is not an opt-in detail. */} + {state.byoDisabled && ( +
+ {labels.byoDisabledNotice} +
+ )} + {state.byoUnavailable && ( +
+ {labels.byoUnavailableNotice} +
+ )} + {item.description && ( +
{item.description}
+ )} + {state.provider !== undefined && ( +
+ {labels.provider}: {state.provider} +
+ )} + {(actionContent || scopesContent) && ( +
+ {actionContent && ( + + {actionContent} + + )} + {scopesContent && ( + {scopesContent} + )} +
+ )} +
+ ); + + const tooltip = renderRowTooltip?.(item); + + return ( +
+ {handle} + {tooltip ? ( + // Own provider: wind's `Tooltip` is a bare Radix root and throws outside one, and a + // row renders where none is guaranteed. Nesting inside an existing provider only + // rescopes the delays. + + + {body} + {tooltip} + + + ) : ( + body + )} +
{actions}
+
+ ); + } +); +GuardrailListRow.displayName = 'GuardrailListRow'; + +export { GuardrailListRow }; diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-palette-item.test.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-palette-item.test.tsx new file mode 100644 index 0000000000..4f41cbf841 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-palette-item.test.tsx @@ -0,0 +1,76 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { axe } from 'jest-axe'; +import { describe, expect, it, vi } from 'vitest'; +import { GuardrailPaletteItem } from './guardrail-palette-item'; +import { GuardrailStatusChip } from './guardrail-status-chip'; + +describe('GuardrailPaletteItem', () => { + it('renders the name, the description and the chips', () => { + render( + Preview} + onSelect={vi.fn()} + /> + ); + + const item = screen.getByRole('button'); + expect(item).toHaveTextContent('PII detection'); + expect(item).toHaveTextContent('Detects personally identifiable information.'); + expect(item).toHaveTextContent('Preview'); + }); + + it('is a button, so Enter and Space select it without a key handler of our own', () => { + const onSelect = vi.fn(); + render(); + + const item = screen.getByRole('button', { name: 'PII detection' }); + expect(item).toHaveAttribute('type', 'button'); + fireEvent.click(item); + expect(onSelect).toHaveBeenCalledTimes(1); + }); + + describe('when disabled', () => { + it('does not select', () => { + const onSelect = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Harmful content' })); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('stays focusable, so the chip saying why is still reachable', () => { + render( + Unauthorized} + disabled + onSelect={vi.fn()} + /> + ); + + const item = screen.getByRole('button', { name: /Harmful content/ }); + expect(item).toHaveAttribute('aria-disabled', 'true'); + expect(item).not.toBeDisabled(); + item.focus(); + expect(item).toHaveFocus(); + }); + }); + + it('has no accessibility violations', async () => { + const { container } = render( + <> + + Unauthorized} + disabled + onSelect={vi.fn()} + /> + + ); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-palette-item.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-palette-item.tsx new file mode 100644 index 0000000000..7dab854ab6 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-palette-item.tsx @@ -0,0 +1,63 @@ +import { cn } from '@uipath/apollo-wind'; +import * as React from 'react'; + +export interface GuardrailPaletteItemProps + extends Omit, 'children' | 'disabled' | 'onSelect'> { + /** Primary line: a definition's display name, or the create-custom label. */ + name: string; + /** Secondary line, wrapped rather than truncated: the palette is where it is read. */ + description?: string; + /** Chips rendered after the name (connector, lifecycle, status). */ + chips?: React.ReactNode; + /** Leading glyph. Only the create-custom entry has one; definitions carry no icons. */ + icon?: React.ReactNode; + /** + * Visible but not choosable (an `Unauthorised` definition). Implemented as `aria-disabled` + * rather than `disabled`, so the entry keeps its place in the tab order and a keyboard user + * still reaches the chip that says why it cannot be picked. + */ + disabled?: boolean; + onSelect: () => void; +} + +/** + * One palette entry: a real button, so it is reachable and operable from the keyboard. + * + * Presentational, and unaware of grouping. Agents' entry today is a `div` with + * `role="listitem"`, `tabIndex={0}` and its own key handler, which both puts a list item + * outside a list and reimplements what a button already does. + */ +const GuardrailPaletteItem = React.forwardRef( + ({ name, description, chips, icon, disabled = false, onSelect, className, ...props }, ref) => ( + + ) +); +GuardrailPaletteItem.displayName = 'GuardrailPaletteItem'; + +export { GuardrailPaletteItem }; diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-scope-selector.test.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-scope-selector.test.tsx new file mode 100644 index 0000000000..a6b1440dce --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-scope-selector.test.tsx @@ -0,0 +1,231 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { axe } from 'jest-axe'; +import { describe, expect, it, vi } from 'vitest'; +import type { GuardrailSelector } from '../builder-types'; +import { GUARDRAIL_BUILDER_EN_LABELS } from '../i18n'; +import { GuardrailScopeSelector } from './guardrail-scope-selector'; + +describe('GuardrailScopeSelector', () => { + const defaultProps = { + selector: { scopes: [] } as GuardrailSelector, + onChange: vi.fn(), + availableToolNames: ['ToolA', 'ToolB'], + labels: GUARDRAIL_BUILDER_EN_LABELS, + }; + + it('renders all scope buttons by default', () => { + render(); + + expect(screen.getByText('Agent')).toBeInTheDocument(); + expect(screen.getByText('LLM calls')).toBeInTheDocument(); + expect(screen.getByText('Tools')).toBeInTheDocument(); + }); + + it('renders only allowed scopes when provided', () => { + render(); + + expect(screen.queryByText('Agent')).not.toBeInTheDocument(); + expect(screen.queryByText('LLM calls')).not.toBeInTheDocument(); + expect(screen.getByText('Tools')).toBeInTheDocument(); + }); + + it('calls onChange when toggling a scope on', () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByText('Agent')); + expect(onChange).toHaveBeenCalledWith({ scopes: ['Agent'] }); + }); + + it('calls onChange when toggling a scope off', () => { + const onChange = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByText('Agent')); + expect(onChange).toHaveBeenCalledWith({ scopes: ['Llm'] }); + }); + + it('pre-selects all tools when toggling Tool scope on', () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByText('Tools')); + expect(onChange).toHaveBeenCalledWith({ scopes: ['Tool'], matchNames: ['ToolA', 'ToolB'] }); + }); + + it('shows tool selectors when Tool scope is selected', () => { + render( + + ); + + expect(screen.getByText('ToolA')).toBeInTheDocument(); + expect(screen.getByText('ToolB')).toBeInTheDocument(); + }); + + it('does not show tool selectors when Tool scope is not selected', () => { + render(); + + expect(screen.queryByText('ToolA')).not.toBeInTheDocument(); + expect(screen.queryByText('ToolB')).not.toBeInTheDocument(); + }); + + it('toggles individual tool when clicked', () => { + const onChange = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByText('ToolA')); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ matchNames: ['ToolB'] })); + }); + + it('marks only tools in matchNames as pressed; newly-added tools render unpressed (AL-428)', () => { + // A guardrail targeting only ToolA shouldn't read as "applied" for ToolB once ToolB + // joins the agent: ToolA stays pressed (targeted), ToolB renders unpressed (addable). + render( + + ); + + expect(screen.getByRole('button', { name: 'ToolA', pressed: true })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'ToolB', pressed: false })).toBeInTheDocument(); + }); + + it('clicking an unpressed tool adds it to matchNames', () => { + const onChange = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'ToolB', pressed: false })); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ matchNames: ['ToolA', 'ToolB'] }) + ); + }); + + it('shows Scopes label with required indicator', () => { + render(); + + expect(screen.getByText('Scopes')).toBeInTheDocument(); + expect(screen.getAllByText('*').length).toBeGreaterThan(0); + }); + + it('does not show tool selectors when no tools are available', () => { + render( + + ); + + expect(screen.queryByText('Tools', { selector: 'label' })).not.toBeInTheDocument(); + }); + + it('hides Tool scope button when no tools are available', () => { + render( + + ); + + expect(screen.getByText('Agent')).toBeInTheDocument(); + expect(screen.getByText('LLM calls')).toBeInTheDocument(); + expect(screen.queryByText('Tools')).not.toBeInTheDocument(); + }); + + it('strips Tool scope via onChange when tools disappear while Tool is selected', () => { + const onChange = vi.fn(); + const { rerender } = render( + + ); + + expect(onChange).not.toHaveBeenCalled(); + + rerender( + + ); + + expect(onChange).toHaveBeenCalledWith({ scopes: ['Agent'] }); + }); + + it('does not call onChange when tools disappear but Tool scope is not selected', () => { + const onChange = vi.fn(); + const { rerender } = render( + + ); + + rerender( + + ); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('renders scope and tool errors', () => { + render( + + ); + + expect(screen.getByText('At least one scope is required')).toBeInTheDocument(); + expect(screen.getByText('At least one tool is required')).toBeInTheDocument(); + }); + + it('has no accessibility violations', async () => { + const { container } = render( + + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-scope-selector.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-scope-selector.tsx new file mode 100644 index 0000000000..03cff2dafa --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-scope-selector.tsx @@ -0,0 +1,167 @@ +import { FormField, FormFieldError, Label, RequiredIndicator } from '@uipath/apollo-wind'; +import { Check, Plus } from 'lucide-react'; +import { useCallback, useEffect, useMemo } from 'react'; +import type { GuardrailScope, GuardrailSelector } from '../builder-types'; +import type { GuardrailBuilderLabels } from '../i18n'; +import { FieldShell } from './field-shell'; +import { GuardrailChip } from './guardrail-chip'; + +const ALL_SCOPES: GuardrailScope[] = ['Agent', 'Llm', 'Tool']; + +export interface GuardrailScopeSelectorProps { + selector: GuardrailSelector; + onChange: (selector: GuardrailSelector) => void; + /** Tool names available for targeting (shown when Tool scope is selected) */ + availableToolNames?: string[]; + /** When provided, only these scopes are shown as options */ + allowedScopes?: GuardrailScope[]; + errors?: { scopes?: string; toolNames?: string }; + labels: GuardrailBuilderLabels; +} + +/** + * Scope selector for guardrails: which scopes (Agent, LLM, Tool) and optionally which tools + * a guardrail targets. + */ +export function GuardrailScopeSelector({ + selector, + onChange, + availableToolNames = [], + allowedScopes, + errors, + labels, +}: GuardrailScopeSelectorProps) { + const scopeLabels = useMemo>( + () => ({ + Agent: labels.scopeAgentLabel, + Llm: labels.scopeLlmLabel, + Tool: labels.scopeToolLabel, + }), + [labels] + ); + const selectedScopes = useMemo(() => selector.scopes ?? [], [selector.scopes]); + const hasTools = availableToolNames.length > 0; + const visibleScopes = useMemo(() => { + const scopes = allowedScopes ?? ALL_SCOPES; + return hasTools ? scopes : scopes.filter((s) => s !== 'Tool'); + }, [allowedScopes, hasTools]); + + // When tools disappear while Tool scope is selected, strip it from the selector to avoid + // invisible invalid state the user can't fix via the UI. (Deliberate self-healing side + // effect, kept for parity with the original implementation.) + useEffect(() => { + if (!hasTools && selectedScopes.includes('Tool')) { + const cleaned = selectedScopes.filter((s) => s !== 'Tool'); + onChange({ scopes: cleaned }); + } + }, [hasTools, selectedScopes, onChange]); + + const handleToggleScope = useCallback( + (scope: GuardrailScope) => { + const isSelected = selectedScopes.includes(scope); + const newScopes = isSelected + ? selectedScopes.filter((s) => s !== scope) + : [...selectedScopes, scope]; + const hasToolScope = newScopes.includes('Tool'); + if (!hasToolScope) { + onChange({ scopes: newScopes }); + } else if (!isSelected && scope === 'Tool') { + // Toggling Tool ON: pre-select all available tools + onChange({ scopes: newScopes, matchNames: [...availableToolNames] }); + } else { + onChange({ scopes: newScopes, matchNames: selector.matchNames ?? [...availableToolNames] }); + } + }, + [selectedScopes, selector.matchNames, availableToolNames, onChange] + ); + + const handleToggleTool = useCallback( + (toolName: string) => { + const current = selector.matchNames ?? []; + const isSelected = current.includes(toolName); + const newMatchNames = isSelected + ? current.filter((n) => n !== toolName) + : [...current, toolName]; + onChange({ ...selector, matchNames: newMatchNames }); + }, + [selector, onChange] + ); + + const selectedTools = useMemo(() => selector.matchNames ?? [], [selector.matchNames]); + const hasToolScope = selectedScopes.includes('Tool'); + + // Split tools into "currently targeted" (in matchNames) and "available to add" (in + // availableToolNames but not in matchNames). Distinct groups with Check vs Plus icons + // avoid a newly-added tool appearing "selected" merely by rendering alongside targeted + // ones with subtle styling differences. + const targetedTools = useMemo( + () => selectedTools.filter((name) => availableToolNames.includes(name)), + [selectedTools, availableToolNames] + ); + const addableTools = useMemo( + () => availableToolNames.filter((name) => !selectedTools.includes(name)), + [availableToolNames, selectedTools] + ); + + return ( +
+ {/* Scope multi-select, always visible */} + + + +
+ {visibleScopes.map((scope) => ( + handleToggleScope(scope)} + > + {scopeLabels[scope]} + + ))} +
+
+ {errors?.scopes} +
+ + {/* Tool name multi-select (only when Tool scope is selected) */} + {hasToolScope && availableToolNames.length > 0 && ( + + + +
+ {targetedTools.map((name) => ( + handleToggleTool(name)} + > + + ))} + {addableTools.map((name) => ( + handleToggleTool(name)} + > + + ))} +
+
+ {errors?.toolNames} +
+ )} +
+ ); +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-status-banner.test.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-status-banner.test.tsx new file mode 100644 index 0000000000..e146b457d7 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-status-banner.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from '@testing-library/react'; +import { axe } from 'jest-axe'; +import { describe, expect, it } from 'vitest'; +import { GuardrailStatusBanner } from './guardrail-status-banner'; + +describe('GuardrailStatusBanner', () => { + it('renders an error banner with role="alert"', () => { + render(); + + const banner = screen.getByRole('alert'); + expect(banner).toHaveTextContent('This configuration has been disabled.'); + }); + + it('renders a warning banner as a polite status region, not an alert', () => { + render( + + ); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('You are not entitled to use guardrails.'); + }); + + it('has no accessibility violations', async () => { + const { container } = render(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-status-banner.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-status-banner.tsx new file mode 100644 index 0000000000..79604ef56e --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-status-banner.tsx @@ -0,0 +1,27 @@ +import { Alert, AlertDescription } from '@uipath/apollo-wind'; +import { AlertCircle, AlertTriangle } from 'lucide-react'; + +export interface GuardrailStatusBannerProps { + tone: 'error' | 'warning'; + message: string; +} + +/** Status banner shown above the guardrail form (definition disabled / unauthorized / feature off). */ +export function GuardrailStatusBanner({ tone, message }: GuardrailStatusBannerProps) { + if (tone === 'error') { + return ( + + + {message} + + ); + } + return ( + // A warning is a persistent notice, not an interruption: role="status" (polite live + // region) instead of Alert's default role="alert". + + + {message} + + ); +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-status-chip.test.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-status-chip.test.tsx new file mode 100644 index 0000000000..199e0c70b1 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-status-chip.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from '@testing-library/react'; +import { axe } from 'jest-axe'; +import { describe, expect, it } from 'vitest'; +import { GuardrailStatusChip } from './guardrail-status-chip'; + +describe('GuardrailStatusChip', () => { + it('renders a label, not a control', () => { + render(Governance managed); + + expect(screen.getByText('Governance managed')).toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('carries the chip family geometry', () => { + render(Disabled); + + expect(screen.getByText('Disabled')).toHaveClass('rounded-full'); + }); + + it.each([ + ['neutral', 'bg-secondary'], + ['warning', 'bg-warning-background'], + ['error', 'bg-error-background'], + ] as const)('maps the %s tone onto the badge variant', (tone, expected) => { + render(Status); + + expect(screen.getByText('Status')).toHaveClass(expected); + }); + + it('has no accessibility violations', async () => { + const { container } = render( + <> + Feature disabled + Unavailable + + ); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-status-chip.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-status-chip.tsx new file mode 100644 index 0000000000..e39eb5f9e3 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/guardrail-status-chip.tsx @@ -0,0 +1,37 @@ +import { Badge, cn } from '@uipath/apollo-wind'; +import * as React from 'react'; +import { GUARDRAIL_CHIP_GEOMETRY } from './guardrail-chip'; + +export interface GuardrailStatusChipProps extends React.ComponentPropsWithoutRef<'div'> { + /** Maps onto the wind `Badge` variants: a neutral fact, a warning, or a blocking error. */ + tone?: 'neutral' | 'warning' | 'error'; +} + +const TONE_VARIANT = { + neutral: 'secondary', + warning: 'warning', + error: 'error', +} as const; + +/** + * Read-only status label for a guardrail row (definition status, governance administration). + * + * Deliberately **not** `GuardrailChip`: that wraps a Radix `Toggle`, so it is a focusable + * button carrying pressed state. These are labels, and rendering them as toggles would put + * fake buttons in the tab order and misreport them to screen readers. It reuses the chip + * family's pill geometry instead, so the two still read as one system. + */ +const GuardrailStatusChip = React.forwardRef( + ({ tone = 'neutral', className, ...props }, ref) => ( + + ) +); +GuardrailStatusChip.displayName = 'GuardrailStatusChip'; + +export { GuardrailStatusChip }; diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/map-enum-field.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/map-enum-field.tsx new file mode 100644 index 0000000000..2271dff87a --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/map-enum-field.tsx @@ -0,0 +1,85 @@ +import type { CustomFieldComponentProps } from '@uipath/apollo-wind'; +import { FormField, FormFieldError, Input, Label, useWatch } from '@uipath/apollo-wind'; +import { useCallback, useMemo } from 'react'; +import type { GuardrailValidatorFormLabels } from '../i18n'; +import type { GuardrailParameterDefinition } from '../types'; +import { ParameterLabel } from './parameter-label'; + +/** + * One numeric input per key selected in the sibling `keySource` enum-list parameter (e.g. a + * threshold per selected PII entity), registered as the `guardrail-map-enum` custom + * component. Reads the sibling's live selection through the form context (`useWatch`), + * falling back to the source definition's default selection before the sibling registers. + * Renders nothing while the source has no selection. Edits never prune keys removed from the + * source; reconcile at save time with `syncMapEnumParameters`. `paramDef`, `sourceDef` and + * `labels` arrive via `componentProps` from `buildGuardrailFormSchema`. + */ +export function MapEnumField(props: CustomFieldComponentProps) { + const { value, onChange, error } = props; + const paramDef = props.paramDef as GuardrailParameterDefinition; + const sourceDef = props.sourceDef as GuardrailParameterDefinition | undefined; + const labels = props.labels as GuardrailValidatorFormLabels; + const sourceSelection = props.sourceSelection as string[] | undefined; + + // Live sibling selection once the sibling emits its first change; until then `useWatch` + // returns undefined, so fall back to the mount-time selection from the schema builder, + // then to the source definition's default selection. + const watchedSelection = useWatch({ name: paramDef.keySource ?? '__guardrail-no-key-source' }); + const keys = + (Array.isArray(watchedSelection) ? (watchedSelection as string[]) : undefined) ?? + sourceSelection ?? + (sourceDef?.defaultValue as string[] | undefined) ?? + []; + + const currentMap = useMemo( + () => + ((value && typeof value === 'object' && !Array.isArray(value) + ? value + : paramDef.defaultValue) as Record | undefined) ?? {}, + [value, paramDef.defaultValue] + ); + + // Per-entity defaults shipped by the backend (e.g. every PII entity at 0.5). Used as the + // displayed value for a freshly selected entity that isn't in the current map yet, so the + // user sees the real default rather than a misleading 0. Mirrors the save-time fallback in + // `syncMapEnumParameters`. + const defaults = useMemo( + () => (paramDef.defaultValue as Record | undefined) ?? {}, + [paramDef.defaultValue] + ); + + const handleThresholdChange = useCallback( + (key: string, threshold: number) => { + onChange({ ...currentMap, [key]: threshold }); + }, + [currentMap, onChange] + ); + + if (keys.length === 0) return null; + + return ( + + +
+ {keys.map((key) => ( +
+ + handleThresholdChange(key, Number.parseFloat(e.target.value) || 0)} + min={paramDef.min} + max={paramDef.max} + step={paramDef.step} + className="flex-1" + /> +
+ ))} +
+ {error} +
+ ); +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/mixed-scopes-banner.test.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/mixed-scopes-banner.test.tsx new file mode 100644 index 0000000000..7b1299a15a --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/mixed-scopes-banner.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from '@testing-library/react'; +import { axe } from 'jest-axe'; +import { describe, expect, it } from 'vitest'; +import { GUARDRAIL_BUILDER_EN_LABELS } from '../i18n'; +import { MixedScopesBanner } from './mixed-scopes-banner'; + +const labels = GUARDRAIL_BUILDER_EN_LABELS; + +describe('MixedScopesBanner', () => { + it('renders nothing when otherAppliedScopes is null', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders the pre-localized scope labels', () => { + render( + + ); + + expect(screen.getByText('Agent')).toBeInTheDocument(); + expect(screen.getByText('LLM calls')).toBeInTheDocument(); + }); + + it('renders the tool names', () => { + render( + + ); + + expect(screen.getByText('Tool B')).toBeInTheDocument(); + expect(screen.getByText('Tool C')).toBeInTheDocument(); + }); + + it('renders scopes and tools together as list items', () => { + render( + + ); + + const items = screen.getAllByRole('listitem'); + expect(items.map((li) => li.textContent)).toEqual(['Agent', 'Tool B']); + }); + + it('renders the header and the save-as-new hint', () => { + render( + + ); + + expect(screen.getByText('This guardrail is also applied to:')).toBeInTheDocument(); + expect( + screen.getByText('Use "Save as new" to create a separate copy for this tool only.') + ).toBeInTheDocument(); + }); + + it('has no accessibility violations', async () => { + const { container } = render( + + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/mixed-scopes-banner.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/mixed-scopes-banner.tsx new file mode 100644 index 0000000000..cbdf7855cc --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/mixed-scopes-banner.tsx @@ -0,0 +1,35 @@ +import { Alert, AlertDescription } from '@uipath/apollo-wind'; +import { AlertCircle } from 'lucide-react'; +import type { GuardrailBuilderLabels } from '../i18n'; + +export interface MixedScopesBannerProps { + /** Pre-localized scope labels + tool names the guardrail also applies to; null hides the banner. */ + otherAppliedScopes: { scopes: string[]; tools: string[] } | null; + labels: Pick; +} + +/** + * Info banner shown when a guardrail has mixed scopes (e.g. Agent + Tool): lists the other + * scopes/tools it applies to and suggests "Save as new". + */ +export function MixedScopesBanner({ otherAppliedScopes, labels }: MixedScopesBannerProps) { + if (!otherAppliedScopes) return null; + + return ( + + + +

{labels.mixedScopesAlsoApplied}

+
    + {otherAppliedScopes.scopes.map((s) => ( +
  • {s}
  • + ))} + {otherAppliedScopes.tools.map((tool) => ( +
  • {tool}
  • + ))} +
+

{labels.mixedScopesSaveAsNewHint}

+
+
+ ); +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/components/parameter-label.tsx b/packages/apollo-react/src/canvas/components/Guardrails/components/parameter-label.tsx new file mode 100644 index 0000000000..8d24c75257 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/components/parameter-label.tsx @@ -0,0 +1,36 @@ +import { InfoTooltip, Label, RequiredIndicator } from '@uipath/apollo-wind'; +import type { GuardrailValidatorFormLabels } from '../i18n'; +import type { GuardrailParameterDefinition } from '../types'; + +export interface ParameterLabelProps { + paramDef: GuardrailParameterDefinition; + labels: GuardrailValidatorFormLabels; + htmlFor?: string; + asTextHeader?: boolean; +} + +/** Parameter display label with required marker and optional info tooltip. */ +export function ParameterLabel({ + paramDef, + labels, + htmlFor, + asTextHeader = false, +}: ParameterLabelProps) { + const content = ( + <> + {paramDef.label} + {paramDef.required && } + {paramDef.tooltip && ( + + )} + + ); + if (asTextHeader) { + return ( +
+ {content} +
+ ); + } + return ; +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/definitions-copy.test.ts b/packages/apollo-react/src/canvas/components/Guardrails/definitions-copy.test.ts new file mode 100644 index 0000000000..8e8654eb7a --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/definitions-copy.test.ts @@ -0,0 +1,129 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { PII_ENTITY_OPTIONS } from './__fixtures__/definitions-wire.fixtures'; +import { + CURATED_GUARDRAIL_VALIDATORS, + GUARDRAIL_COPY_EN, + GUARDRAIL_COPY_EN_MESSAGES, + useGuardrailDefinitionCopy, +} from './definitions-copy'; + +const ID_PATTERN = + /^guardrails\.definitions\.[a-z0-9_]+\.(display-name|description|usage-note|param\.[A-Za-z0-9]+\.(label|tooltip)|option\.[A-Za-z0-9]+\.[A-Za-z0-9]+)$/; + +describe('GUARDRAIL_COPY_EN', () => { + it('covers every curated validator', () => { + expect(Object.keys(GUARDRAIL_COPY_EN).sort()).toEqual([...CURATED_GUARDRAIL_VALIDATORS].sort()); + }); + + it('labels every PII entity the backend can return', () => { + const labels = GUARDRAIL_COPY_EN.pii_detection?.optionLabels?.entities ?? {}; + + expect(Object.keys(labels).sort()).toEqual([...PII_ENTITY_OPTIONS].sort()); + }); + + it('resolves to English without a lingui provider', () => { + expect(GUARDRAIL_COPY_EN.llm_as_judge?.displayName).toBe('LLM as Judge'); + expect(GUARDRAIL_COPY_EN.harmful_content?.paramLabels.harmfulContentEntities).toBe( + 'Content categories' + ); + }); +}); + +describe('message ids', () => { + it('all follow the documented convention', () => { + const offenders = Object.keys(GUARDRAIL_COPY_EN_MESSAGES).filter((id) => !ID_PATTERN.test(id)); + + expect(offenders).toEqual([]); + }); + + it('use the raw wire value for option segments, never a transcribed slug', () => { + // Transcribing is how the two products ended up with `finNationalId` and `fiNationalId` + // for the same entity; the raw value cannot drift because it is the value we persist. + for (const option of PII_ENTITY_OPTIONS) { + expect(GUARDRAIL_COPY_EN_MESSAGES).toHaveProperty( + `guardrails.definitions.pii_detection.option.entities.${option}` + ); + } + }); + + it('are unique across the table', () => { + const table = GUARDRAIL_COPY_EN; + let stringCount = 0; + for (const copy of Object.values(table)) { + stringCount += 2 + (copy.usageNote === undefined ? 0 : 1); + stringCount += Object.keys(copy.paramLabels).length; + stringCount += Object.keys(copy.paramTooltips ?? {}).length; + for (const options of Object.values(copy.optionLabels ?? {})) { + stringCount += Object.keys(options).length; + } + } + + // A duplicated id would silently collapse two strings into one entry. + expect(Object.keys(GUARDRAIL_COPY_EN_MESSAGES)).toHaveLength(stringCount); + }); +}); + +describe('GUARDRAIL_COPY_EN_MESSAGES', () => { + it('holds the same English as the table it was built from', () => { + expect(GUARDRAIL_COPY_EN_MESSAGES['guardrails.definitions.pii_detection.display-name']).toBe( + GUARDRAIL_COPY_EN.pii_detection?.displayName + ); + expect( + GUARDRAIL_COPY_EN_MESSAGES['guardrails.definitions.llm_as_judge.param.threshold.tooltip'] + ).toBe(GUARDRAIL_COPY_EN.llm_as_judge?.paramTooltips?.threshold); + }); + + it('is frozen, so a host cannot mutate the CI baseline it diffs against', () => { + expect(Object.isFrozen(GUARDRAIL_COPY_EN_MESSAGES)).toBe(true); + }); +}); + +describe('the shared canvas catalog', () => { + // `src/canvas` uses no lingui macros, so `lingui extract` does not feed this catalog: the + // entries are hand-authored. This test is what `extract` would otherwise be doing, and it + // is the only thing standing between a new message and an untranslatable string. + const catalog: Record = JSON.parse( + readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../../locales/en.json'), 'utf8') + ); + + it('carries every definition message with the same English', () => { + const missing: string[] = []; + const drifted: string[] = []; + for (const [id, message] of Object.entries(GUARDRAIL_COPY_EN_MESSAGES)) { + if (!(id in catalog)) missing.push(id); + else if (catalog[id] !== message) + drifted.push(`${id}\n src: ${message}\n en: ${catalog[id]}`); + } + + expect({ missing, drifted }).toEqual({ missing: [], drifted: [] }); + }); + + it('carries no definition message the source no longer declares', () => { + const orphans = Object.keys(catalog) + .filter((id) => id.startsWith('guardrails.definitions.')) + .filter((id) => !(id in GUARDRAIL_COPY_EN_MESSAGES)); + + expect(orphans).toEqual([]); + }); +}); + +describe('useGuardrailDefinitionCopy', () => { + it('returns the English table when no provider is mounted', () => { + const { result } = renderHook(() => useGuardrailDefinitionCopy()); + + expect(result.current).toEqual(GUARDRAIL_COPY_EN); + }); + + it('keeps a stable reference across renders, so enrichment does not rerun', () => { + const { result, rerender } = renderHook(() => useGuardrailDefinitionCopy()); + const first = result.current; + + rerender(); + + expect(result.current).toBe(first); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/definitions-copy.ts b/packages/apollo-react/src/canvas/components/Guardrails/definitions-copy.ts new file mode 100644 index 0000000000..2fa37eba03 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/definitions-copy.ts @@ -0,0 +1,415 @@ +import { useMemo } from 'react'; +import { useSafeLingui } from '../../../i18n'; + +/** + * The canonical display copy for the built-in guardrail validators. + * + * Until now this table lived twice, once in each product, and the two drifted. Here it is a + * set of lingui messages in the shared canvas catalog, so both hosts get the same wording and + * the same translations, and the strings sit in the real localization pipeline instead of a + * host-side constant. + * + * Message ids follow the family's dotted convention: + * + * ``` + * guardrails.definitions..display-name + * guardrails.definitions..description + * guardrails.definitions..usage-note + * guardrails.definitions..param..label + * guardrails.definitions..param..tooltip + * guardrails.definitions..option.. + * ``` + * + * Validator, parameter and option segments are the **raw wire values** + * (`pii_detection`, `entityThresholds`, `USSocialSecurityNumber`), never a transcribed slug. + * Transcribing is how the two products ended up keying the same Finland entity as + * `finNationalId` and `fiNationalId`. + * + * One builder holds every message, so the English source, the flat record hosts diff in CI + * and the runtime lingui path cannot drift, and `lingui extract` still sees static calls. + * Where the two products' English differed, the choice and its reason are recorded in + * `definitions-parity.test.ts`. + */ + +/** Curated copy for one validator. Absent entries fall back to the wire, then to the id. */ +export interface GuardrailValidatorCopy { + displayName: string; + description: string; + /** Informational note rendered above the form, e.g. a cost caveat. */ + usageNote?: string; + /** Keyed by raw parameter id. */ + paramLabels: Record; + /** Keyed by raw parameter id. */ + paramTooltips?: Record; + /** Keyed by raw parameter id, then by raw wire option value. */ + optionLabels?: Record>; +} + +/** Curated copy keyed by raw validator id. */ +export type GuardrailCopyTable = Record; + +/** The subset of `useSafeLingui`'s translator this module needs. */ +type CopyTranslate = (descriptor: { id: string; message: string }) => string; + +function buildGuardrailCopy(_: CopyTranslate): GuardrailCopyTable { + return { + pii_detection: { + displayName: _({ + id: 'guardrails.definitions.pii_detection.display-name', + message: 'PII detection', + }), + description: _({ + id: 'guardrails.definitions.pii_detection.description', + message: 'Detect personally identifiable information using Azure Cognitive Services.', + }), + paramLabels: { + entities: _({ + id: 'guardrails.definitions.pii_detection.param.entities.label', + message: 'Entities to detect', + }), + entityThresholds: _({ + id: 'guardrails.definitions.pii_detection.param.entityThresholds.label', + message: 'Detection thresholds', + }), + }, + paramTooltips: { + entityThresholds: _({ + id: 'guardrails.definitions.pii_detection.param.entityThresholds.tooltip', + message: + 'Value between 0 and 1. The sensitivity level for PII detection. Higher thresholds detect more potential PII but may result in more false positives.', + }), + }, + optionLabels: { + entities: { + Person: _({ + id: 'guardrails.definitions.pii_detection.option.entities.Person', + message: 'Person', + }), + Address: _({ + id: 'guardrails.definitions.pii_detection.option.entities.Address', + message: 'Address', + }), + Date: _({ + id: 'guardrails.definitions.pii_detection.option.entities.Date', + message: 'Date', + }), + PhoneNumber: _({ + id: 'guardrails.definitions.pii_detection.option.entities.PhoneNumber', + message: 'Phone Number', + }), + EugpsCoordinates: _({ + id: 'guardrails.definitions.pii_detection.option.entities.EugpsCoordinates', + message: 'EU GPS Coordinates', + }), + Email: _({ + id: 'guardrails.definitions.pii_detection.option.entities.Email', + message: 'Email', + }), + CreditCardNumber: _({ + id: 'guardrails.definitions.pii_detection.option.entities.CreditCardNumber', + message: 'Credit Card Number', + }), + InternationalBankingAccountNumber: _({ + id: 'guardrails.definitions.pii_detection.option.entities.InternationalBankingAccountNumber', + message: 'International Banking Account Number (IBAN)', + }), + SwiftCode: _({ + id: 'guardrails.definitions.pii_detection.option.entities.SwiftCode', + message: 'SWIFT Code', + }), + ABARoutingNumber: _({ + id: 'guardrails.definitions.pii_detection.option.entities.ABARoutingNumber', + message: 'ABA Routing Number', + }), + USDriversLicenseNumber: _({ + id: 'guardrails.definitions.pii_detection.option.entities.USDriversLicenseNumber', + message: "US Driver's License Number", + }), + UKDriversLicenseNumber: _({ + id: 'guardrails.definitions.pii_detection.option.entities.UKDriversLicenseNumber', + message: "UK Driver's License Number", + }), + USIndividualTaxpayerIdentification: _({ + id: 'guardrails.definitions.pii_detection.option.entities.USIndividualTaxpayerIdentification', + message: 'US Individual Taxpayer Identification Number (ITIN)', + }), + UKUniqueTaxpayerNumber: _({ + id: 'guardrails.definitions.pii_detection.option.entities.UKUniqueTaxpayerNumber', + message: 'UK Unique Taxpayer Number (UTR)', + }), + USBankAccountNumber: _({ + id: 'guardrails.definitions.pii_detection.option.entities.USBankAccountNumber', + message: 'US Bank Account Number', + }), + USSocialSecurityNumber: _({ + id: 'guardrails.definitions.pii_detection.option.entities.USSocialSecurityNumber', + message: 'US Social Security Number (SSN)', + }), + UsukPassportNumber: _({ + id: 'guardrails.definitions.pii_detection.option.entities.UsukPassportNumber', + message: 'US/UK Passport Number', + }), + NOIdentityNumber: _({ + id: 'guardrails.definitions.pii_detection.option.entities.NOIdentityNumber', + message: 'Norway Identity Number', + }), + FINationalID: _({ + id: 'guardrails.definitions.pii_detection.option.entities.FINationalID', + message: 'Finland National ID', + }), + FIPassportNumber: _({ + id: 'guardrails.definitions.pii_detection.option.entities.FIPassportNumber', + message: 'Finland Passport Number', + }), + SENationalID: _({ + id: 'guardrails.definitions.pii_detection.option.entities.SENationalID', + message: 'Sweden National ID', + }), + DKPersonalIdentificationNumber: _({ + id: 'guardrails.definitions.pii_detection.option.entities.DKPersonalIdentificationNumber', + message: 'Danish Personal Identification Number', + }), + NLCitizensServiceNumber: _({ + id: 'guardrails.definitions.pii_detection.option.entities.NLCitizensServiceNumber', + message: 'Netherlands Citizens Service Number', + }), + URL: _({ + id: 'guardrails.definitions.pii_detection.option.entities.URL', + message: 'URL', + }), + IPAddress: _({ + id: 'guardrails.definitions.pii_detection.option.entities.IPAddress', + message: 'IP Address', + }), + }, + }, + }, + + prompt_injection: { + displayName: _({ + id: 'guardrails.definitions.prompt_injection.display-name', + message: 'Prompt injection', + }), + // The one description that keeps its "This validator is provided by..." form: the + // third-party vendor attribution is load-bearing and only Agents carries it. + description: _({ + id: 'guardrails.definitions.prompt_injection.description', + message: + 'This validator is provided by Noma Security and is built to detect malicious attack attempts (e.g. prompt injection, jailbreak) in LLM calls.', + }), + paramLabels: { + threshold: _({ + id: 'guardrails.definitions.prompt_injection.param.threshold.label', + message: 'Detection threshold', + }), + }, + paramTooltips: { + threshold: _({ + id: 'guardrails.definitions.prompt_injection.param.threshold.tooltip', + message: + 'Value between 0 and 1. The sensitivity level for Prompt Injection detection. Higher thresholds detect more potential Prompt Injection but may result in more false positives.', + }), + }, + }, + + harmful_content: { + displayName: _({ + id: 'guardrails.definitions.harmful_content.display-name', + message: 'Harmful content', + }), + description: _({ + id: 'guardrails.definitions.harmful_content.description', + message: 'Detect harmful content (e.g. hate, violence) using Azure AI Content Safety.', + }), + paramLabels: { + harmfulContentEntities: _({ + id: 'guardrails.definitions.harmful_content.param.harmfulContentEntities.label', + message: 'Content categories', + }), + harmfulContentEntityThresholds: _({ + id: 'guardrails.definitions.harmful_content.param.harmfulContentEntityThresholds.label', + message: 'Severity thresholds', + }), + }, + paramTooltips: { + harmfulContentEntityThresholds: _({ + id: 'guardrails.definitions.harmful_content.param.harmfulContentEntityThresholds.tooltip', + message: + 'Integer value between 0 and 6 (step 2). The severity threshold for harmful content detection. Higher values require more severe content before triggering.', + }), + }, + optionLabels: { + harmfulContentEntities: { + Hate: _({ + id: 'guardrails.definitions.harmful_content.option.harmfulContentEntities.Hate', + message: 'Hate', + }), + SelfHarm: _({ + id: 'guardrails.definitions.harmful_content.option.harmfulContentEntities.SelfHarm', + message: 'Self-harm', + }), + Sexual: _({ + id: 'guardrails.definitions.harmful_content.option.harmfulContentEntities.Sexual', + message: 'Sexual', + }), + Violence: _({ + id: 'guardrails.definitions.harmful_content.option.harmfulContentEntities.Violence', + message: 'Violence', + }), + }, + }, + }, + + user_prompt_attacks: { + displayName: _({ + id: 'guardrails.definitions.user_prompt_attacks.display-name', + message: 'User prompt attacks', + }), + description: _({ + id: 'guardrails.definitions.user_prompt_attacks.description', + message: + 'Detect user prompt attacks that attempt to bypass system instructions using Azure AI Content Safety.', + }), + paramLabels: {}, + }, + + intellectual_property: { + displayName: _({ + id: 'guardrails.definitions.intellectual_property.display-name', + message: 'Intellectual property', + }), + description: _({ + id: 'guardrails.definitions.intellectual_property.description', + message: + 'Detect potential intellectual property violations in text and code using Azure AI Content Safety.', + }), + paramLabels: { + ipEntities: _({ + id: 'guardrails.definitions.intellectual_property.param.ipEntities.label', + message: 'Content types', + }), + }, + optionLabels: { + ipEntities: { + Text: _({ + id: 'guardrails.definitions.intellectual_property.option.ipEntities.Text', + message: 'Text', + }), + Code: _({ + id: 'guardrails.definitions.intellectual_property.option.ipEntities.Code', + message: 'Code', + }), + }, + }, + }, + + llm_as_judge: { + displayName: _({ + id: 'guardrails.definitions.llm_as_judge.display-name', + message: 'LLM as Judge', + }), + description: _({ + id: 'guardrails.definitions.llm_as_judge.description', + message: 'Detect violations of a rule you define, using an LLM as the judge.', + }), + usageNote: _({ + id: 'guardrails.definitions.llm_as_judge.usage-note', + message: + "Judge model calls consume Agent units the same way the agent's own LLM calls do. Apply selectively to keep cost predictable, and choose the judge model with cost in mind — rates vary by model.", + }), + paramLabels: { + guardrailText: _({ + id: 'guardrails.definitions.llm_as_judge.param.guardrailText.label', + message: 'Rule prompt', + }), + model: _({ + id: 'guardrails.definitions.llm_as_judge.param.model.label', + message: 'Judge model', + }), + positiveExamples: _({ + id: 'guardrails.definitions.llm_as_judge.param.positiveExamples.label', + message: 'Positive examples', + }), + negativeExamples: _({ + id: 'guardrails.definitions.llm_as_judge.param.negativeExamples.label', + message: 'Negative examples', + }), + threshold: _({ + id: 'guardrails.definitions.llm_as_judge.param.threshold.label', + message: 'Strictness', + }), + }, + paramTooltips: { + guardrailText: _({ + id: 'guardrails.definitions.llm_as_judge.param.guardrailText.tooltip', + message: + 'Describe the rule the judge will enforce. Be specific about what should pass and what should fail.', + }), + model: _({ + id: 'guardrails.definitions.llm_as_judge.param.model.tooltip', + message: 'The model used to evaluate the policy against each payload.', + }), + positiveExamples: _({ + id: 'guardrails.definitions.llm_as_judge.param.positiveExamples.tooltip', + message: + 'Optional payloads that should pass the policy. Used by the judge as calibration anchors.', + }), + negativeExamples: _({ + id: 'guardrails.definitions.llm_as_judge.param.negativeExamples.tooltip', + message: + 'Optional payloads that should fail the policy. Used by the judge as calibration anchors.', + }), + threshold: _({ + id: 'guardrails.definitions.llm_as_judge.param.threshold.tooltip', + message: + 'Strictness on a 0–6 scale. Lower values are stricter — the judge flags anything that hints at a violation. Higher values are more lenient — only clear, unambiguous violations are flagged.', + }), + }, + }, + }; +} + +/** The raw validator ids this package ships curated copy for. */ +export const CURATED_GUARDRAIL_VALIDATORS: readonly string[] = Object.freeze([ + 'pii_detection', + 'prompt_injection', + 'harmful_content', + 'user_prompt_attacks', + 'intellectual_property', + 'llm_as_judge', +]); + +/** + * The English copy, resolved without a lingui provider. This is the default + * `enrichGuardrailDefinitions` uses, so the pure layer stays usable outside React. + */ +export const GUARDRAIL_COPY_EN: GuardrailCopyTable = buildGuardrailCopy( + (descriptor) => descriptor.message +); + +function collectEnglishMessages(): Record { + const messages: Record = {}; + buildGuardrailCopy((descriptor) => { + messages[descriptor.id] = descriptor.message; + return descriptor.message; + }); + return messages; +} + +/** + * The same copy flattened to message id to English string. Exported so hosts can diff their + * remaining local tables against it in CI while they migrate off them. + */ +export const GUARDRAIL_COPY_EN_MESSAGES: Readonly> = Object.freeze( + collectEnglishMessages() +); + +/** + * The curated copy in the active locale. Recomputes when the lingui context changes, which is + * what makes a locale switch re-enrich in `useGuardrailDefinitions`. Without a provider every + * string resolves to its English default. + */ +export function useGuardrailDefinitionCopy(): GuardrailCopyTable { + const { _ } = useSafeLingui(); + return useMemo(() => buildGuardrailCopy(_), [_]); +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/definitions-enrich.test.ts b/packages/apollo-react/src/canvas/components/Guardrails/definitions-enrich.test.ts new file mode 100644 index 0000000000..dcf1abbf62 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/definitions-enrich.test.ts @@ -0,0 +1,318 @@ +import { describe, expect, it } from 'vitest'; +import { + BYO_WIRE, + HARMFUL_CONTENT_WIRE, + LLM_AS_JUDGE_WIRE, + PII_DETECTION_WIRE, + PROMPT_INJECTION_WIRE, + UNCURATED_WIRE, +} from './__fixtures__/definitions-wire.fixtures'; +import type { GuardrailDefinition } from './builder-types'; +import { GUARDRAIL_COPY_EN, type GuardrailCopyTable } from './definitions-copy'; +import { + type EnrichedGuardrailDefinition, + enrichGuardrailDefinitions, + humanizeGuardrailParameterId, + isByoGuardrailDefinition, + withGuardrailFolderMetadata, +} from './definitions-enrich'; +import type { GuardrailDefinitionWire } from './definitions-wire'; + +function enrichOne(wire: GuardrailDefinitionWire, copy?: GuardrailCopyTable) { + const [definition] = enrichGuardrailDefinitions([wire], copy ? { copy } : undefined); + if (!definition) throw new Error('expected one enriched definition'); + return definition; +} + +function param(definition: EnrichedGuardrailDefinition, id: string) { + const found = definition.parameters.find((p) => p.id === id); + if (!found) throw new Error(`no parameter ${id}`); + return found; +} + +describe('enrichGuardrailDefinitions', () => { + it('feeds GuardrailBuilder without mapping', () => { + // The whole point of extending the family's display type: an enriched definition is a + // GuardrailDefinition, so no host adapter has to translate between the two. + const definition: GuardrailDefinition = enrichOne(PII_DETECTION_WIRE); + + expect(definition.displayName).toBe('PII detection'); + expect(definition.status).toBe('Available'); + expect(definition.allowedScopes).toEqual(['Agent', 'Llm', 'Tool']); + }); + + it('preserves payload order', () => { + const result = enrichGuardrailDefinitions([PROMPT_INJECTION_WIRE, PII_DETECTION_WIRE]); + + expect(result.map((d) => d.validator)).toEqual(['prompt_injection', 'pii_detection']); + }); + + describe('copy precedence', () => { + it('prefers curated over wire at definition level for a UiPath validator', () => { + const definition = enrichOne({ + ...PII_DETECTION_WIRE, + displayName: 'Backend name', + description: 'Backend description', + }); + + expect(definition.displayName).toBe('PII detection'); + expect(definition.description).toBe( + 'Detect personally identifiable information using Azure Cognitive Services.' + ); + }); + + it('falls back to the wire, then the validator id, when nothing is curated', () => { + expect(enrichOne({ ...UNCURATED_WIRE, displayName: 'Topic drift' }).displayName).toBe( + 'Topic drift' + ); + expect(enrichOne(UNCURATED_WIRE).displayName).toBe('topic_drift'); + expect(enrichOne(UNCURATED_WIRE).description).toBe(''); + }); + + it('prefers wire over curated at parameter level', () => { + const definition = enrichOne({ + ...PII_DETECTION_WIRE, + parameters: [ + { + id: 'entities', + type: 'enum-list', + required: true, + defaultValue: [], + options: ['Email'], + displayName: 'Fields to scan', + description: 'Chosen by the manifest.', + }, + ], + }); + + expect(param(definition, 'entities').label).toBe('Fields to scan'); + expect(param(definition, 'entities').tooltip).toBe('Chosen by the manifest.'); + }); + + it('uses curated parameter copy when the wire says nothing', () => { + const definition = enrichOne(PII_DETECTION_WIRE); + + expect(param(definition, 'entities').label).toBe('Entities to detect'); + expect(param(definition, 'entityThresholds').label).toBe('Detection thresholds'); + expect(param(definition, 'entityThresholds').tooltip).toMatch(/sensitivity level for PII/); + }); + + it('humanizes an uncurated parameter id rather than showing it raw', () => { + const definition = enrichOne(UNCURATED_WIRE); + + expect(param(definition, 'maxDriftScore').label).toBe('Max drift score'); + }); + + it('omits the tooltip entirely when there is none', () => { + expect(param(enrichOne(PII_DETECTION_WIRE), 'entities')).not.toHaveProperty('tooltip'); + }); + + it('attaches the curated usage note', () => { + expect(enrichOne(LLM_AS_JUDGE_WIRE).usageNote).toMatch(/consume Agent units/); + expect(enrichOne(PII_DETECTION_WIRE)).not.toHaveProperty('usageNote'); + }); + }); + + describe('option labels', () => { + it('resolves curated labels for every wire option', () => { + const labels = param(enrichOne(PII_DETECTION_WIRE), 'entities').optionLabels; + + expect(labels?.USSocialSecurityNumber).toBe('US Social Security Number (SSN)'); + expect(labels?.SwiftCode).toBe('SWIFT Code'); + }); + + it('merges wire labels over curated ones, per option', () => { + const definition = enrichOne({ + ...HARMFUL_CONTENT_WIRE, + parameters: [ + { + id: 'harmfulContentEntities', + type: 'enum-list', + required: true, + defaultValue: [], + options: ['Hate', 'SelfHarm'], + optionLabels: { Hate: 'Hateful content' }, + }, + ], + }); + + const labels = param(definition, 'harmfulContentEntities').optionLabels; + expect(labels).toEqual({ + Hate: 'Hateful content', + SelfHarm: 'Self-harm', + Sexual: 'Sexual', + Violence: 'Violence', + }); + }); + + it('leaves unlabelled options out, since the editors fall back to the raw value', () => { + const labels = param(enrichOne(UNCURATED_WIRE), 'allowedTopics'); + + expect(labels).not.toHaveProperty('optionLabels'); + expect(labels.options).toEqual(['finance', 'legal']); + }); + }); + + describe('bring-your-own definitions', () => { + it('takes zero curated copy even when the validator id collides with a UiPath one', () => { + // BYO_WIRE deliberately reuses `pii_detection` as its validator id. + const definition = enrichOne(BYO_WIRE); + + expect(definition.displayName).toBe('Acme PII scan'); + expect(definition.description).toBe('Runs the Acme detector over prompts and completions.'); + expect(definition).not.toHaveProperty('usageNote'); + }); + + it('carries the BYO and folder fields through', () => { + const definition = enrichOne({ ...BYO_WIRE, folderPath: 'Shared/AI', folderKey: 'fk' }); + + expect(definition).toMatchObject({ + byoValidatorName: 'acme-pii', + byoConnectorName: 'Acme AI Guardrails', + byoConnectorKey: 'acme', + byoGuardrailConnectionId: 'conn-1', + byoConfigurationId: 'cfg-1', + folderPath: 'Shared/AI', + folderKey: 'fk', + }); + }); + + it('omits absent optional fields instead of setting them undefined', () => { + const definition = enrichOne(PII_DETECTION_WIRE); + + expect(definition).not.toHaveProperty('byoValidatorName'); + expect(definition).not.toHaveProperty('folderPath'); + }); + + it('uses manifest parameter copy and merges its partial option labels', () => { + const definition = enrichOne(BYO_WIRE); + + expect(param(definition, 'sensitivity').label).toBe('Sensitivity'); + expect(param(definition, 'sensitivity').optionLabels).toEqual({ low: 'Low', high: 'High' }); + }); + }); + + describe('parameter constraints', () => { + it('gives an unbounded map-enum the 0..1 step 0.1 the thresholds need', () => { + const thresholds = param(enrichOne(PII_DETECTION_WIRE), 'entityThresholds'); + + expect(thresholds).toMatchObject({ keySource: 'entities', min: 0, max: 1, step: 0.1 }); + }); + + it('keeps map-enum bounds the backend does send', () => { + const thresholds = param(enrichOne(HARMFUL_CONTENT_WIRE), 'harmfulContentEntityThresholds'); + + expect(thresholds).toMatchObject({ min: 0, max: 6, step: 2 }); + }); + + it('copies number, text and text-list constraints only when present', () => { + const judge = enrichOne(LLM_AS_JUDGE_WIRE); + + expect(param(judge, 'threshold')).toMatchObject({ min: 0, max: 6, step: 2 }); + expect(param(judge, 'guardrailText')).toMatchObject({ maxLength: 4000 }); + expect(param(judge, 'positiveExamples')).toMatchObject({ maxItems: 5, maxLength: 1000 }); + expect(param(enrichOne(UNCURATED_WIRE), 'maxDriftScore')).not.toHaveProperty('min'); + }); + + it('passes defaultValue through untouched, nulls included', () => { + const judge = enrichOne(LLM_AS_JUDGE_WIRE); + + // `seedGuardrailParameters` is what coerces these into the value union at form time. + expect(param(judge, 'guardrailText').defaultValue).toBeNull(); + expect(param(judge, 'positiveExamples').defaultValue).toBeUndefined(); + expect(param(judge, 'threshold').defaultValue).toBe(4); + }); + }); + + describe('hiddenValidators', () => { + it('hides nothing by default', () => { + const result = enrichGuardrailDefinitions([PROMPT_INJECTION_WIRE, PII_DETECTION_WIRE]); + + expect(result).toHaveLength(2); + }); + + it('drops the named UiPath validators', () => { + const result = enrichGuardrailDefinitions([PROMPT_INJECTION_WIRE, PII_DETECTION_WIRE], { + hiddenValidators: ['prompt_injection'], + }); + + expect(result.map((d) => d.validator)).toEqual(['pii_detection']); + }); + + it('never hides a BYO definition, whatever its validator id', () => { + const result = enrichGuardrailDefinitions([BYO_WIRE], { + hiddenValidators: ['pii_detection'], + }); + + expect(result).toHaveLength(1); + }); + }); + + it('resolves copy from the supplied table, not the English one', () => { + const localized: GuardrailCopyTable = { + ...GUARDRAIL_COPY_EN, + pii_detection: { + ...(GUARDRAIL_COPY_EN.pii_detection ?? { + displayName: '', + description: '', + paramLabels: {}, + }), + displayName: 'Erkennung personenbezogener Daten', + }, + }; + + expect(enrichOne(PII_DETECTION_WIRE, localized).displayName).toBe( + 'Erkennung personenbezogener Daten' + ); + }); +}); + +describe('isByoGuardrailDefinition', () => { + it('is exactly "names a BYO validator"', () => { + expect(isByoGuardrailDefinition(BYO_WIRE)).toBe(true); + expect(isByoGuardrailDefinition(PII_DETECTION_WIRE)).toBe(false); + }); +}); + +describe('humanizeGuardrailParameterId', () => { + it.each([ + ['entityThresholds', 'Entity thresholds'], + ['threshold', 'Threshold'], + ['maxDriftScore', 'Max drift score'], + ['harmfulContentEntities', 'Harmful content entities'], + ])('%s reads as %s', (id, expected) => { + expect(humanizeGuardrailParameterId(id)).toBe(expected); + }); +}); + +describe('withGuardrailFolderMetadata', () => { + const [byo] = enrichGuardrailDefinitions([BYO_WIRE]); + const [builtIn] = enrichGuardrailDefinitions([PII_DETECTION_WIRE]); + if (!byo || !builtIn) throw new Error('fixture setup failed'); + + it('stamps the resolved placement onto BYO definitions', () => { + const [stamped] = withGuardrailFolderMetadata([byo], (id) => + id === 'conn-1' ? { folderPath: 'Shared/AI', folderKey: 'fk-1' } : undefined + ); + + expect(stamped).toMatchObject({ folderPath: 'Shared/AI', folderKey: 'fk-1' }); + }); + + it('leaves definitions without a connection id alone', () => { + const [passed] = withGuardrailFolderMetadata([builtIn], () => ({ folderPath: 'Shared/X' })); + + expect(passed).toBe(builtIn); + }); + + it('keeps a definition the host could not resolve rather than dropping it', () => { + const result = withGuardrailFolderMetadata([byo], () => undefined); + + expect(result).toEqual([byo]); + }); + + it('does not mutate its input', () => { + withGuardrailFolderMetadata([byo], () => ({ folderPath: 'Shared/AI' })); + + expect(byo).not.toHaveProperty('folderPath'); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/definitions-enrich.ts b/packages/apollo-react/src/canvas/components/Guardrails/definitions-enrich.ts new file mode 100644 index 0000000000..517bcdf304 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/definitions-enrich.ts @@ -0,0 +1,220 @@ +import type { GuardrailDefinition } from './builder-types'; +import { + GUARDRAIL_COPY_EN, + type GuardrailCopyTable, + type GuardrailValidatorCopy, +} from './definitions-copy'; +import type { GuardrailDefinitionWire, GuardrailParameterDefinitionWire } from './definitions-wire'; +import type { GuardrailParameterDefinition } from './types'; + +/** + * Turn validated wire definitions into the display-ready shape the builder renders. + * + * Pure and React-free on purpose: hosts outside React (Flow's vsix bridge, node-side tests) + * call it directly, and `useGuardrailDefinitions` is only a thin composition over it. + */ + +/** + * A `GuardrailDefinition` plus the wire fields the builder does not read but hosts do + * (palette grouping, BYO connection notices, folder resolution). Because it extends the + * family's display type it feeds `GuardrailBuilder` and `GuardrailValidatorForm` unchanged. + */ +export interface EnrichedGuardrailDefinition extends GuardrailDefinition { + /** Resolved description, `''` when neither the curated table nor the wire has one. */ + description: string; + /** Narrowed from `GuardrailDefinition`'s `ReactNode`: enrichment only ever yields text. */ + usageNote?: string; + byoConnectorName?: string; + byoConnectorKey?: string; + byoGuardrailConnectionId?: string; + byoConfigurationId?: string; + folderPath?: string; + folderKey?: string; +} + +export interface EnrichGuardrailDefinitionsOptions { + /** + * Copy table to resolve curated strings from. Defaults to `GUARDRAIL_COPY_EN`; pass + * `useGuardrailDefinitionCopy()`'s table for the active locale. + */ + copy?: GuardrailCopyTable; + /** + * Non-BYO validators to drop entirely, e.g. Flow hides `prompt_injection`. Defaults to + * none: which validators a product exposes is an entitlement decision, not a copy one. + * BYO definitions are never hidden, since their validator id is not the product's to judge. + */ + hiddenValidators?: readonly string[]; +} + +/** + * A definition is bring-your-own exactly when it names a BYO validator. Both products + * already use this test; it decides copy resolution, palette grouping and the BYO notices. + */ +export function isByoGuardrailDefinition( + definition: T +): definition is T & { byoValidatorName: string } { + return definition.byoValidatorName !== undefined; +} + +/** + * Fallback label for a parameter no curated table names: `entityThresholds` reads as + * "Entity thresholds". Better than surfacing the raw id, which is what a host would + * otherwise show for a validator the UI has not learned yet. + */ +export function humanizeGuardrailParameterId(id: string): string { + const spaced = id.replace(/([A-Z])/g, ' $1').trim(); + return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase(); +} + +function toParameterDefinition( + param: GuardrailParameterDefinitionWire, + curated: GuardrailValidatorCopy | undefined +): GuardrailParameterDefinition { + const definition: GuardrailParameterDefinition = { + id: param.id, + type: param.type, + // Wire wins at parameter level: a BYO manifest and a newly shipped backend parameter + // both describe themselves, and only they know their own copy. + label: + param.displayName ?? curated?.paramLabels[param.id] ?? humanizeGuardrailParameterId(param.id), + required: param.required, + defaultValue: param.defaultValue, + }; + + const tooltip = param.description ?? curated?.paramTooltips?.[param.id]; + if (tooltip !== undefined) definition.tooltip = tooltip; + + const curatedOptionLabels = curated?.optionLabels?.[param.id]; + const wireOptionLabels = + param.type === 'enum' || param.type === 'enum-list' ? param.optionLabels : undefined; + if (curatedOptionLabels !== undefined || wireOptionLabels !== undefined) { + // Merged, wire last: a manifest may relabel a subset of options without restating the + // rest. Options nobody labelled are deliberately absent, since the editors already fall + // back to the raw wire value. + definition.optionLabels = { ...curatedOptionLabels, ...wireOptionLabels }; + } + + switch (param.type) { + case 'enum': + case 'enum-list': + definition.options = param.options; + break; + case 'map-enum': + definition.keySource = param.keySource; + // The threshold maps arrive without bounds from some backends, and an unbounded + // numeric editor for a 0..1 confidence score is a data-entry hazard. + definition.min = param.min ?? 0; + definition.max = param.max ?? 1; + definition.step = param.step ?? 0.1; + break; + case 'number': + if (param.min != null) definition.min = param.min; + if (param.max != null) definition.max = param.max; + if (param.step != null) definition.step = param.step; + break; + case 'text': + if (param.maxLength != null) definition.maxLength = param.maxLength; + break; + case 'text-list': + if (param.maxLength != null) definition.maxLength = param.maxLength; + if (param.maxItems != null) definition.maxItems = param.maxItems; + break; + default: + break; + } + + return definition; +} + +function enrichOne( + wire: GuardrailDefinitionWire, + copy: GuardrailCopyTable +): EnrichedGuardrailDefinition { + const isByo = isByoGuardrailDefinition(wire); + // A BYO guardrail gets zero curated copy at every level: its validator id is the + // customer's, and a collision with a UiPath id must not borrow UiPath's wording. + const curated = isByo ? undefined : copy[wire.validator]; + + const enriched: EnrichedGuardrailDefinition = { + validator: wire.validator, + // Curated wins at definition level for non-BYO: the backend ships terse internal names + // and this table is what product and localization actually review. + displayName: + (isByo ? wire.displayName : (curated?.displayName ?? wire.displayName)) ?? wire.validator, + description: (isByo ? wire.description : (curated?.description ?? wire.description)) ?? '', + allowedScopes: wire.allowedScopes, + parameters: wire.parameters.map((param) => toParameterDefinition(param, curated)), + status: wire.status, + }; + + if (curated?.usageNote !== undefined) enriched.usageNote = curated.usageNote; + if (wire.byoValidatorName !== undefined) enriched.byoValidatorName = wire.byoValidatorName; + if (wire.byoConnectorName !== undefined) enriched.byoConnectorName = wire.byoConnectorName; + if (wire.byoConnectorKey !== undefined) enriched.byoConnectorKey = wire.byoConnectorKey; + if (wire.byoGuardrailConnectionId !== undefined) { + enriched.byoGuardrailConnectionId = wire.byoGuardrailConnectionId; + } + if (wire.byoConfigurationId !== undefined) enriched.byoConfigurationId = wire.byoConfigurationId; + if (wire.folderPath !== undefined) enriched.folderPath = wire.folderPath; + if (wire.folderKey !== undefined) enriched.folderKey = wire.folderKey; + + return enriched; +} + +/** + * Resolve display copy onto validated wire definitions, in payload order. + * + * Copy precedence, re-derived from what both products do today: + * + * | level | non-BYO | BYO | + * | --- | --- | --- | + * | display name | curated, wire, `validator` | wire, `validator` | + * | description | curated, wire, `''` | wire, `''` | + * | usage note | curated only | none | + * | parameter label | **wire**, curated, humanized id | wire, humanized id | + * | parameter tooltip | **wire**, curated | wire | + * | option labels | curated merged under wire | wire only | + */ +export function enrichGuardrailDefinitions( + definitions: readonly GuardrailDefinitionWire[], + options: EnrichGuardrailDefinitionsOptions = {} +): EnrichedGuardrailDefinition[] { + const copy = options.copy ?? GUARDRAIL_COPY_EN; + const hidden = new Set(options.hiddenValidators ?? []); + const result: EnrichedGuardrailDefinition[] = []; + for (const wire of definitions) { + if (!isByoGuardrailDefinition(wire) && hidden.has(wire.validator)) continue; + result.push(enrichOne(wire, copy)); + } + return result; +} + +/** Folder placement for one BYO guardrail connection. */ +export interface GuardrailFolderMetadata { + folderPath?: string; + folderKey?: string; +} + +/** + * Stamp folder placement onto BYO definitions from a host lookup. + * + * Resolution stays host-side because it needs each product's connections API, which this + * package deliberately does not reach for: Agents pages `fetchResources`, Flow calls + * `getConnectionById`. Definitions without a connection id, and connections the host cannot + * resolve, pass through untouched rather than being dropped. + */ +export function withGuardrailFolderMetadata( + definitions: readonly T[], + resolve: (connectionId: string) => GuardrailFolderMetadata | undefined +): T[] { + return definitions.map((definition) => { + const connectionId = definition.byoGuardrailConnectionId; + if (connectionId === undefined) return definition; + const metadata = resolve(connectionId); + if (metadata === undefined) return definition; + const next: T = { ...definition }; + if (metadata.folderPath !== undefined) next.folderPath = metadata.folderPath; + if (metadata.folderKey !== undefined) next.folderKey = metadata.folderKey; + return next; + }); +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/definitions-parity.test.ts b/packages/apollo-react/src/canvas/components/Guardrails/definitions-parity.test.ts new file mode 100644 index 0000000000..d2cd03d41d --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/definitions-parity.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from 'vitest'; +import { AGENTS_COPY_EN, FLOW_COPY_EN } from './__fixtures__/host-copy-baselines'; +import { + CURATED_GUARDRAIL_VALIDATORS, + GUARDRAIL_COPY_EN, + type GuardrailCopyTable, +} from './definitions-copy'; + +/** + * Pins this package's English copy against what each product ships today. + * + * Two rules, both enforced mechanically: + * + * 1. Where the products already agree, this package says the same thing. Inventing a third + * wording is how a shared table stops being adoptable. + * 2. Where they disagree, the choice is declared below with a reason, and this package says + * exactly what the chosen product says, so its existing translations harvest cleanly. + * + * When a product changes its copy, update `__fixtures__/host-copy-baselines.ts` first: this + * suite then says whether the choice still holds. + */ + +type Host = 'agents' | 'flow'; + +interface CopyDivergence { + /** Dotted path into the flattened copy table. */ + path: string; + chosen: Host; + reason: string; +} + +const EXPECTED_DIVERGENCES: CopyDivergence[] = [ + // Descriptions: Flow's are one sentence and say what the validator does. Agents' open with + // "This validator is designed to..." / "...is provided by X and is built to...", which + // costs a line of panel space to say nothing. + { + path: 'pii_detection.description', + chosen: 'flow', + reason: 'Concise, drops the "This validator is designed to" preamble', + }, + { + path: 'harmful_content.description', + chosen: 'flow', + reason: 'Concise; keeps the Azure AI Content Safety attribution', + }, + { + path: 'intellectual_property.description', + chosen: 'flow', + reason: 'Concise; keeps the Azure AI Content Safety attribution', + }, + { + path: 'user_prompt_attacks.description', + chosen: 'flow', + reason: 'Concise; keeps the Azure AI Content Safety attribution', + }, + { + path: 'prompt_injection.description', + chosen: 'agents', + reason: + 'The deliberate exception to the concision rule: Flow drops the Noma Security attribution, and naming the third-party vendor behind a validator is load-bearing', + }, + + // Parameter labels: Flow's name the thing being configured; Agents reuses one generic + // label across validators, so two different parameters read identically in one panel. + { + path: 'pii_detection.paramLabels.entityThresholds', + chosen: 'flow', + reason: 'Plural: there is one threshold per selected entity', + }, + { + path: 'harmful_content.paramLabels.harmfulContentEntities', + chosen: 'flow', + reason: '"Content categories" beats "Entities to detect", which PII already uses', + }, + { + path: 'harmful_content.paramLabels.harmfulContentEntityThresholds', + chosen: 'flow', + reason: 'It is a severity scale, and there is one per category', + }, + { + path: 'intellectual_property.paramLabels.ipEntities', + chosen: 'flow', + reason: '"Content types" beats "Entities to detect", which PII already uses', + }, + { + path: 'llm_as_judge.paramLabels.threshold', + chosen: 'flow', + reason: '"Strictness" says which direction the number moves; "Threshold" does not', + }, + { + path: 'llm_as_judge.paramTooltips.threshold', + chosen: 'flow', + reason: 'Matches the "Strictness" label it explains', + }, + + // Option labels. + { + path: 'harmful_content.optionLabels.harmfulContentEntities.SelfHarm', + chosen: 'flow', + reason: 'Agents shows the raw enum value "SelfHarm"', + }, + { + path: 'pii_detection.optionLabels.entities.FIPassportNumber', + chosen: 'agents', + reason: 'Flow omits an entity the backend returns, so Flow renders the raw value today', + }, + + // Strings only one product has. Adopting them makes both products better off. + { + path: 'pii_detection.paramTooltips.entityThresholds', + chosen: 'agents', + reason: 'Real guidance on the 0..1 scale; Flow ships no tooltip', + }, + { + path: 'prompt_injection.paramTooltips.threshold', + chosen: 'agents', + reason: 'Real guidance on the 0..1 scale; Flow ships no tooltip', + }, + { + path: 'harmful_content.paramTooltips.harmfulContentEntityThresholds', + chosen: 'agents', + reason: 'Explains the 0..6 step-2 severity scale; Flow ships no tooltip', + }, + { + path: 'llm_as_judge.usageNote', + chosen: 'flow', + reason: 'Agent-unit cost caveat; Agents users need it just as much', + }, +]; + +function flatten(table: GuardrailCopyTable): Map { + const flat = new Map(); + for (const [validator, copy] of Object.entries(table)) { + flat.set(`${validator}.displayName`, copy.displayName); + flat.set(`${validator}.description`, copy.description); + if (copy.usageNote !== undefined) flat.set(`${validator}.usageNote`, copy.usageNote); + for (const [id, label] of Object.entries(copy.paramLabels)) { + flat.set(`${validator}.paramLabels.${id}`, label); + } + for (const [id, tooltip] of Object.entries(copy.paramTooltips ?? {})) { + flat.set(`${validator}.paramTooltips.${id}`, tooltip); + } + for (const [paramId, options] of Object.entries(copy.optionLabels ?? {})) { + for (const [option, label] of Object.entries(options)) { + flat.set(`${validator}.optionLabels.${paramId}.${option}`, label); + } + } + } + return flat; +} + +const agents = flatten(AGENTS_COPY_EN); +const flow = flatten(FLOW_COPY_EN); +const ours = flatten(GUARDRAIL_COPY_EN); +const allPaths = [...new Set([...agents.keys(), ...flow.keys(), ...ours.keys()])].sort(); +const divergenceByPath = new Map(EXPECTED_DIVERGENCES.map((d) => [d.path, d])); + +describe('canonical copy parity with both products', () => { + it('covers the same validators both products curate', () => { + expect([...CURATED_GUARDRAIL_VALIDATORS].sort()).toEqual(Object.keys(AGENTS_COPY_EN).sort()); + expect([...CURATED_GUARDRAIL_VALIDATORS].sort()).toEqual(Object.keys(FLOW_COPY_EN).sort()); + expect(Object.keys(GUARDRAIL_COPY_EN).sort()).toEqual([...CURATED_GUARDRAIL_VALIDATORS].sort()); + }); + + it('matches both products wherever they already agree', () => { + const invented = allPaths + .filter((path) => agents.get(path) === flow.get(path)) + .filter((path) => ours.get(path) !== agents.get(path)) + .map((path) => `${path}\n ours: ${ours.get(path)}\n hosts: ${agents.get(path)}`); + + expect(invented).toEqual([]); + }); + + it('declares every disagreement between the products', () => { + const undeclared = allPaths + .filter((path) => agents.get(path) !== flow.get(path)) + .filter((path) => !divergenceByPath.has(path)) + .map((path) => `${path}\n agents: ${agents.get(path)}\n flow: ${flow.get(path)}`); + + expect(undeclared).toEqual([]); + }); + + it('has no stale divergence entries', () => { + const settled = EXPECTED_DIVERGENCES.filter( + ({ path }) => agents.get(path) === flow.get(path) + ).map(({ path }) => path); + + expect(settled).toEqual([]); + }); + + it.each(EXPECTED_DIVERGENCES)('$path takes the $chosen wording: $reason', ({ path, chosen }) => { + const expected = chosen === 'agents' ? agents.get(path) : flow.get(path); + + expect(expected).toBeDefined(); + expect(ours.get(path)).toBe(expected); + }); + + it('takes every string from one product or the other', () => { + // A string in neither baseline would be copy nobody has translated and nobody reviewed. + const unsourced = allPaths.filter( + (path) => ours.has(path) && !agents.has(path) && !flow.has(path) + ); + + expect(unsourced).toEqual([]); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/definitions-parse.test.ts b/packages/apollo-react/src/canvas/components/Guardrails/definitions-parse.test.ts new file mode 100644 index 0000000000..e41b39ec1e --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/definitions-parse.test.ts @@ -0,0 +1,244 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { + ALL_BUILT_IN_WIRE, + LLM_AS_JUDGE_WIRE, + PII_DETECTION_WIRE, + PROMPT_INJECTION_WIRE, + RAW_PAYLOAD_WITH_NOISE, +} from './__fixtures__/definitions-wire.fixtures'; +import { parseGuardrailDefinitions } from './definitions-parse'; + +describe('parseGuardrailDefinitions', () => { + it('returns every definition of a well-formed payload, in order', () => { + const result = parseGuardrailDefinitions(ALL_BUILT_IN_WIRE); + + expect(result.definitions.map((d) => d.validator)).toEqual([ + 'pii_detection', + 'prompt_injection', + 'harmful_content', + 'user_prompt_attacks', + 'intellectual_property', + 'llm_as_judge', + ]); + expect(result.invalid).toEqual([]); + expect(result.inputError).toBeUndefined(); + }); + + it('round-trips a definition unchanged', () => { + const result = parseGuardrailDefinitions([PII_DETECTION_WIRE]); + + expect(result.definitions[0]).toEqual(PII_DETECTION_WIRE); + }); + + describe('never throws', () => { + // The payload crosses a trust boundary (a backend, or Flow's postMessage bridge), and a + // properties panel must not disappear because it arrived malformed. + it.each([ + ['null', null], + ['undefined', undefined], + ['a string', 'not json'], + ['a number', 42], + ['an object', { definitions: [] }], + ])('reports %s as an input error rather than throwing', (_label, input) => { + const result = parseGuardrailDefinitions(input); + + expect(result.definitions).toEqual([]); + expect(result.invalid).toEqual([]); + expect(result.inputError).toMatch(/Expected an array/); + }); + + it('survives hostile entries', () => { + const hostile = [ + null, + undefined, + 'string', + [], + { __proto__: { validator: 'injected' } }, + { validator: 'x', allowedScopes: 'not-an-array', status: 'Available' }, + ]; + + const result = parseGuardrailDefinitions(hostile); + + expect(result.definitions).toEqual([]); + expect(result.invalid).toHaveLength(hostile.length); + // The `__proto__` entry must not have reached the prototype chain. + expect(Object.prototype).not.toHaveProperty('validator'); + }); + }); + + it('drops a bad definition whole and keeps the rest', () => { + const result = parseGuardrailDefinitions(RAW_PAYLOAD_WITH_NOISE); + + expect(result.definitions.map((d) => d.validator)).toEqual([ + 'prompt_injection', + 'user_prompt_attacks', + ]); + expect(result.invalid).toEqual([ + { index: 1, validator: 'broken_validator', message: expect.stringContaining('status') }, + ]); + }); + + it('drops a definition when a single parameter is malformed', () => { + const result = parseGuardrailDefinitions([ + { + ...PROMPT_INJECTION_WIRE, + parameters: [{ id: 'threshold', type: 'number', required: true, defaultValue: 'high' }], + }, + ]); + + expect(result.definitions).toEqual([]); + expect(result.invalid[0]).toMatchObject({ index: 0, validator: 'prompt_injection' }); + }); + + it('omits the validator on an issue when the entry has no readable one', () => { + const result = parseGuardrailDefinitions([{ allowedScopes: ['Llm'] }]); + + expect(result.invalid[0]).toEqual({ index: 0, message: expect.any(String) }); + expect(result.invalid[0]).not.toHaveProperty('validator'); + }); + + it('strips keys this package does not model', () => { + const result = parseGuardrailDefinitions(RAW_PAYLOAD_WITH_NOISE); + + expect(result.definitions[0]).not.toHaveProperty('executionStage'); + expect(result.definitions[0]).not.toHaveProperty('internalRanking'); + }); + + describe('normalization', () => { + it('treats an empty display string as absent, so it cannot beat curated copy', () => { + const result = parseGuardrailDefinitions(RAW_PAYLOAD_WITH_NOISE); + + expect(result.definitions[0]?.displayName).toBeUndefined(); + expect(result.definitions[0]?.description).toBeUndefined(); + }); + + it('treats an empty parameter displayName as absent', () => { + const result = parseGuardrailDefinitions([ + { + ...PROMPT_INJECTION_WIRE, + parameters: [ + { + id: 'threshold', + type: 'number', + required: true, + defaultValue: 0.7, + displayName: '', + description: '', + }, + ], + }, + ]); + + const param = result.definitions[0]?.parameters[0]; + expect(param?.displayName).toBeUndefined(); + expect(param?.description).toBeUndefined(); + }); + + it('treats an empty byoValidatorName as "not bring-your-own"', () => { + const result = parseGuardrailDefinitions([ + { ...PROMPT_INJECTION_WIRE, byoValidatorName: '' }, + ]); + + expect(result.definitions[0]?.byoValidatorName).toBeUndefined(); + }); + + it('defaults a missing parameters array to empty', () => { + const result = parseGuardrailDefinitions([ + { validator: 'x', allowedScopes: ['Llm'], status: 'Available' }, + ]); + + expect(result.definitions[0]?.parameters).toEqual([]); + }); + }); + + describe('accepts both products nullability variants', () => { + it('accepts null text and enum defaults, and a missing text-list default', () => { + const result = parseGuardrailDefinitions([LLM_AS_JUDGE_WIRE]); + + const byId = new Map(result.definitions[0]?.parameters.map((p) => [p.id, p])); + expect(byId.get('guardrailText')).toMatchObject({ defaultValue: null }); + expect(byId.get('model')).toMatchObject({ defaultValue: null }); + expect(byId.get('positiveExamples')).not.toHaveProperty('defaultValue'); + expect(byId.get('negativeExamples')).toMatchObject({ defaultValue: null }); + }); + + it('accepts null numeric constraints', () => { + const result = parseGuardrailDefinitions([ + { + ...PROMPT_INJECTION_WIRE, + parameters: [ + { + id: 'threshold', + type: 'number', + required: true, + defaultValue: 0.7, + min: null, + max: null, + step: null, + }, + ], + }, + ]); + + expect(result.definitions).toHaveLength(1); + }); + }); + + it('rejects a scope the display layer cannot render', () => { + const result = parseGuardrailDefinitions([ + { ...PROMPT_INJECTION_WIRE, allowedScopes: ['Llm', 'Workflow'] }, + ]); + + expect(result.definitions).toEqual([]); + }); +}); + +describe('zod boundary', () => { + const folder = dirname(fileURLToPath(import.meta.url)); + + function sourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = join(dir, entry.name); + if (entry.isDirectory()) return sourceFiles(full); + return /\.tsx?$/.test(entry.name) ? [full] : []; + }); + } + + it('is confined to definitions-parse.ts', () => { + // Anything else importing zod would put schema types in this folder's emitted + // declarations and force the dependency on every consumer of the subpath. + const importers = sourceFiles(folder) + .filter((file) => !file.includes('.test.')) + .filter((file) => /from ['"]zod['"]|require\(['"]zod['"]\)/.test(readFileSync(file, 'utf8'))) + .map((file) => file.slice(folder.length + 1).replace(/\\/g, '/')); + + expect(importers).toEqual(['definitions-parse.ts']); + }); + + it('produces exactly the keys the public wire type declares', () => { + // Assignability alone would not catch a schema field the public type never named, so + // the key set is pinned at runtime too. + const everyField = { + validator: 'x', + allowedScopes: ['Llm'], + parameters: [], + status: 'Available', + displayName: 'X', + description: 'D', + byoConnectorName: 'C', + byoConnectorKey: 'ck', + byoValidatorName: 'bv', + byoGuardrailConnectionId: 'bc', + byoConfigurationId: 'bcfg', + folderPath: 'Shared/A', + folderKey: 'fk', + }; + + const result = parseGuardrailDefinitions([everyField]); + + expect(Object.keys(result.definitions[0] ?? {}).sort()).toEqual(Object.keys(everyField).sort()); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/definitions-parse.ts b/packages/apollo-react/src/canvas/components/Guardrails/definitions-parse.ts new file mode 100644 index 0000000000..022bc8d31c --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/definitions-parse.ts @@ -0,0 +1,225 @@ +import { z } from 'zod'; +import type { GuardrailDefinitionWire } from './definitions-wire'; + +/** + * Runtime validation for the guardrail definitions payload. + * + * zod is private to this module. Everything exported is annotated with the hand-written + * types from `definitions-wire.ts`, so the emitted declarations for this folder carry no + * schema types and consumers never take a zod dependency (asserted by the source guard in + * `definitions-parse.test.ts` and by grepping the built `.d.ts`). + */ + +/** + * Optional display string that treats `''` as absent. The empty string is load-bearing: it + * would otherwise win the copy precedence and render a blank label where the curated table + * or a humanized id belongs. `.optional()` must wrap the transform, not the reverse, or the + * inferred key stops being optional. + */ +const emptyToUndefined = z + .string() + .transform((value) => (value === '' ? undefined : value)) + .optional(); + +const parameterDisplayFields = { + displayName: emptyToUndefined, + description: emptyToUndefined, +}; + +const enumParameterSchema = z.object({ + ...parameterDisplayFields, + id: z.string().min(1), + type: z.literal('enum'), + defaultValue: z.string().nullish(), + required: z.boolean(), + options: z.array(z.string()), + optionLabels: z.record(z.string(), z.string()).optional(), +}); + +const enumListParameterSchema = z.object({ + ...parameterDisplayFields, + id: z.string().min(1), + type: z.literal('enum-list'), + defaultValue: z.array(z.string()), + required: z.boolean(), + options: z.array(z.string()), + optionLabels: z.record(z.string(), z.string()).optional(), +}); + +const mapEnumParameterSchema = z.object({ + ...parameterDisplayFields, + id: z.string().min(1), + type: z.literal('map-enum'), + defaultValue: z.record(z.string(), z.number()), + required: z.boolean(), + keySource: z.string().min(1), + min: z.number().nullish(), + max: z.number().nullish(), + step: z.number().nullish(), +}); + +const numberParameterSchema = z.object({ + ...parameterDisplayFields, + id: z.string().min(1), + type: z.literal('number'), + defaultValue: z.number(), + required: z.boolean(), + min: z.number().nullish(), + max: z.number().nullish(), + step: z.number().nullish(), +}); + +const textParameterSchema = z.object({ + ...parameterDisplayFields, + id: z.string().min(1), + type: z.literal('text'), + defaultValue: z.string().nullish(), + required: z.boolean(), + maxLength: z.number().nullish(), +}); + +const textListParameterSchema = z.object({ + ...parameterDisplayFields, + id: z.string().min(1), + type: z.literal('text-list'), + defaultValue: z.array(z.string()).nullish(), + required: z.boolean(), + maxItems: z.number().nullish(), + maxLength: z.number().nullish(), +}); + +const booleanParameterSchema = z.object({ + ...parameterDisplayFields, + id: z.string().min(1), + type: z.literal('boolean'), + defaultValue: z.boolean(), + required: z.boolean(), +}); + +const parameterSchema = z.discriminatedUnion('type', [ + enumParameterSchema, + enumListParameterSchema, + mapEnumParameterSchema, + numberParameterSchema, + textParameterSchema, + textListParameterSchema, + booleanParameterSchema, +]); + +const definitionSchema = z.object({ + validator: z.string().min(1), + allowedScopes: z.array(z.enum(['Agent', 'Llm', 'Tool'])).min(1), + parameters: z.array(parameterSchema).default([]), + status: z.enum(['Available', 'FeatureDisabled', 'Unauthorised', 'Disabled']), + displayName: emptyToUndefined, + description: emptyToUndefined, + byoConnectorName: emptyToUndefined, + byoConnectorKey: emptyToUndefined, + // `.min(1)` rather than `emptyToUndefined`: an empty string here would make a UiPath + // validator read as bring-your-own, which changes copy resolution and palette grouping. + byoValidatorName: z.string().min(1).optional(), + byoGuardrailConnectionId: z.string().min(1).optional(), + byoConfigurationId: emptyToUndefined, + folderPath: emptyToUndefined, + folderKey: emptyToUndefined, +}); + +type DefinitionSchemaOutput = z.infer; + +/** + * The bidirectional pin between the private schema and the public mirror: a field the schema + * gained but `GuardrailDefinitionWire` did not, or the reverse, fails to compile here. Both + * directions sit on `parseGuardrailDefinitions`' hot path, so neither can be dropped as dead + * code, and that function's explicit return type keeps the schema types out of the `.d.ts`. + */ +function toWireDefinition(parsed: DefinitionSchemaOutput): GuardrailDefinitionWire { + const asWire: GuardrailDefinitionWire = parsed; + const asSchemaOutput: DefinitionSchemaOutput = asWire; + return asSchemaOutput; +} + +/** One definition the payload carried that could not be validated. */ +export interface GuardrailDefinitionParseIssue { + /** Position in the input array, so a host can correlate with its own logging. */ + index: number; + /** The entry's `validator`, when it at least had a readable one. */ + validator?: string; + /** Human-readable summary of what failed, for logs. Not user-facing copy. */ + message: string; +} + +export interface GuardrailDefinitionsParseResult { + /** Every definition that validated, in payload order. */ + definitions: GuardrailDefinitionWire[]; + /** Every definition that did not, dropped whole. */ + invalid: GuardrailDefinitionParseIssue[]; + /** Set when the payload was not an array at all; `definitions` is then empty. */ + inputError?: string; +} + +function describeInput(input: unknown): string { + if (input === null) return 'null'; + if (Array.isArray(input)) return 'array'; + return typeof input; +} + +function readValidator(entry: unknown): string | undefined { + if (typeof entry !== 'object' || entry === null) return undefined; + const validator = (entry as { validator?: unknown }).validator; + return typeof validator === 'string' && validator !== '' ? validator : undefined; +} + +function describeIssues(error: z.ZodError): string { + return error.issues + .slice(0, 5) + .map((issue) => { + const path = issue.path.join('.'); + return path ? `${path}: ${issue.message}` : issue.message; + }) + .join('; '); +} + +/** + * Validate an unknown definitions payload. + * + * **Never throws.** A payload that is not an array yields an empty result plus `inputError`; + * an individual definition that fails validation is dropped whole and reported in `invalid`, + * which is what both products already do entry by entry today. Unknown keys are stripped. + * + * The only normalizations applied are the two that would otherwise corrupt display: empty + * display strings become `undefined`, and an empty `byoValidatorName` is treated as absent. + * Nothing else is reshaped here. Turning the wire shape into something renderable is + * `enrichGuardrailDefinitions`' job. + */ +export function parseGuardrailDefinitions(input: unknown): GuardrailDefinitionsParseResult { + if (!Array.isArray(input)) { + return { + definitions: [], + invalid: [], + inputError: `Expected an array of guardrail definitions, received ${describeInput(input)}.`, + }; + } + + const definitions: GuardrailDefinitionWire[] = []; + const invalid: GuardrailDefinitionParseIssue[] = []; + + input.forEach((entry: unknown, index: number) => { + // A hostile payload must not be able to take a host panel down, so even an unexpected + // throw out of zod degrades to a dropped definition. + let message: string; + try { + const result = definitionSchema.safeParse(entry); + if (result.success) { + definitions.push(toWireDefinition(result.data)); + return; + } + message = describeIssues(result.error); + } catch (error: unknown) { + message = error instanceof Error ? error.message : String(error); + } + const validator = readValidator(entry); + invalid.push(validator === undefined ? { index, message } : { index, validator, message }); + }); + + return { definitions, invalid }; +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/definitions-wire.ts b/packages/apollo-react/src/canvas/components/Guardrails/definitions-wire.ts new file mode 100644 index 0000000000..59ad752b91 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/definitions-wire.ts @@ -0,0 +1,101 @@ +import type { GuardrailDefinitionStatus, GuardrailScope } from './builder-types'; + +/** + * Hand-written mirrors of the `GET /api/execution/guardrails/definitions` payload. + * + * This is the public shape of the transport: `parseGuardrailDefinitions` validates unknown + * input into it, and hosts that keep their own transport (Agents' SWR, Flow's react-query, + * Flow vsix's postMessage) assign their equivalents straight across. zod stays private to + * `definitions-parse.ts` so no schema types reach the emitted declarations; the two are + * pinned to each other by a compile-time assignability check there. + * + * Where the two products' schemas differ in strictness the mirror admits both: Agents marks + * the numeric constraints and the `text`/`enum` defaults `nullish`, Flow marks the same + * defaults `nullable` and omits `folderPath`. Scope and status are reused from + * `builder-types` rather than redeclared, so the wire and display layers cannot drift. + */ + +/** The fields every parameter kind carries. */ +export interface GuardrailParameterWireBase { + id: string; + required: boolean; + /** + * Display copy shipped by the manifest. BYO guardrails carry their own; UiPath-managed + * validators omit it and resolve from the curated copy table instead. + */ + displayName?: string; + description?: string; +} + +/** + * One configurable parameter as the backend describes it, discriminated on `type`. The seven + * kinds match `GuardrailValidatorParameter`'s `$parameterType` one for one. + */ +export type GuardrailParameterDefinitionWire = + | (GuardrailParameterWireBase & { + type: 'enum'; + defaultValue?: string | null; + options: string[]; + optionLabels?: Record; + }) + | (GuardrailParameterWireBase & { + type: 'enum-list'; + defaultValue: string[]; + options: string[]; + optionLabels?: Record; + }) + | (GuardrailParameterWireBase & { + type: 'map-enum'; + defaultValue: Record; + /** Id of the sibling `enum-list` parameter whose selection provides the map's keys. */ + keySource: string; + min?: number | null; + max?: number | null; + step?: number | null; + }) + | (GuardrailParameterWireBase & { + type: 'number'; + defaultValue: number; + min?: number | null; + max?: number | null; + step?: number | null; + }) + | (GuardrailParameterWireBase & { + type: 'text'; + defaultValue?: string | null; + maxLength?: number | null; + }) + | (GuardrailParameterWireBase & { + type: 'text-list'; + /** Absent on some validators (Agents' schema has no such field at all). */ + defaultValue?: string[] | null; + maxItems?: number | null; + maxLength?: number | null; + }) + | (GuardrailParameterWireBase & { + type: 'boolean'; + defaultValue: boolean; + }); + +/** One guardrail validator definition as the backend returns it. */ +export interface GuardrailDefinitionWire { + validator: string; + allowedScopes: GuardrailScope[]; + parameters: GuardrailParameterDefinitionWire[]; + status: GuardrailDefinitionStatus; + displayName?: string; + description?: string; + byoConnectorName?: string; + byoConnectorKey?: string; + /** Present only for bring-your-own guardrails; its presence is what makes one BYO. */ + byoValidatorName?: string; + byoGuardrailConnectionId?: string; + byoConfigurationId?: string; + /** + * Not returned by the definitions endpoint. Both products resolve it from their + * connections API and stamp it on afterwards; `withGuardrailFolderMetadata` is the shared + * helper for that step. + */ + folderPath?: string; + folderKey?: string; +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/form-schema-builder.test.ts b/packages/apollo-react/src/canvas/components/Guardrails/form-schema-builder.test.ts new file mode 100644 index 0000000000..76732ff97e --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/form-schema-builder.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from 'vitest'; +import { + buildGuardrailFormSchema, + coerceGuardrailParameterValue, + GUARDRAIL_ENUM_LIST_CHIPS_COMPONENT, + GUARDRAIL_MAP_ENUM_COMPONENT, + GUARDRAIL_RENDER_PARAMETER_COMPONENT, + MAX_INLINE_ENUM_OPTIONS, +} from './form-schema-builder'; +import { GUARDRAIL_FORM_EN_LABELS } from './i18n'; +import type { GuardrailParameterDefinition, GuardrailValidatorParameter } from './types'; + +const LABELS = GUARDRAIL_FORM_EN_LABELS; + +function fieldFor( + def: GuardrailParameterDefinition, + options?: Parameters[2], + siblings: GuardrailParameterDefinition[] = [] +) { + const schema = buildGuardrailFormSchema([def, ...siblings], LABELS, options); + const field = schema.sections?.[0]?.fields.find((f) => f.name === def.id); + if (!field) throw new Error(`no field built for ${def.id}`); + return field; +} + +describe('buildGuardrailFormSchema', () => { + it('builds a submit-less single-section schema', () => { + const schema = buildGuardrailFormSchema([], LABELS); + expect(schema.actions).toEqual([]); + expect(schema.sections).toHaveLength(1); + }); + + it('maps common metadata: label, tooltip, required asterisk config', () => { + const field = fieldFor({ + id: 'threshold', + type: 'number', + label: 'Threshold', + required: true, + tooltip: 'How strict.', + defaultValue: 0.5, + }); + expect(field).toMatchObject({ + name: 'threshold', + label: 'Threshold', + tooltip: 'How strict.', + tooltipAriaLabel: LABELS.moreInformation, + validation: { required: true }, + }); + }); + + it('maps number -> number with constraints and a 0 fallback default', () => { + const field = fieldFor({ + id: 'n', + type: 'number', + label: 'N', + required: false, + defaultValue: null, + min: 0, + max: 1, + step: 0.1, + }); + expect(field).toMatchObject({ type: 'number', min: 0, max: 1, step: 0.1, defaultValue: 0 }); + }); + + it('maps text -> textarea with minRows and maxLength', () => { + const field = fieldFor({ + id: 't', + type: 'text', + label: 'T', + required: false, + defaultValue: null, + maxLength: 100, + }); + expect(field).toMatchObject({ type: 'textarea', minRows: 3, maxLength: 100, defaultValue: '' }); + }); + + it('maps boolean -> switch and text-list -> string-list with localized chrome', () => { + expect( + fieldFor({ id: 'b', type: 'boolean', label: 'B', required: false, defaultValue: true }) + ).toMatchObject({ type: 'switch', defaultValue: true }); + + expect( + fieldFor({ + id: 'phrases', + type: 'text-list', + label: 'Phrases', + required: false, + defaultValue: null, + maxItems: 3, + maxLength: 50, + }) + ).toMatchObject({ + type: 'string-list', + maxItems: 3, + maxLength: 50, + minRows: 2, + addItemLabel: LABELS.addItem, + removeItemAriaLabel: LABELS.removeItem, + defaultValue: [], + }); + }); + + it('maps enum -> select and appends a synthetic option for a stale stored value', () => { + const def: GuardrailParameterDefinition = { + id: 'model', + type: 'enum', + label: 'Model', + required: true, + defaultValue: 'gpt-4o', + options: ['gpt-4o', 'gpt-4o-mini'], + optionLabels: { 'gpt-4o': 'GPT-4o' }, + }; + + const plain = fieldFor(def); + expect(plain).toMatchObject({ type: 'select', placeholder: LABELS.enumPlaceholder }); + if (plain.type !== 'select') throw new Error('expected select'); + expect(plain.options).toEqual([ + { value: 'gpt-4o', label: 'GPT-4o' }, + { value: 'gpt-4o-mini', label: 'gpt-4o-mini' }, + ]); + + const withStale = fieldFor(def, { enumSyntheticValues: { model: 'retired-model' } }); + if (withStale.type !== 'select') throw new Error('expected select'); + expect(withStale.options).toContainEqual({ value: 'retired-model', label: 'retired-model' }); + }); + + it('splits enum-list at the inline-chip cap: chips custom component vs multiselect', () => { + const small: GuardrailParameterDefinition = { + id: 'few', + type: 'enum-list', + label: 'Few', + required: true, + defaultValue: [], + options: Array.from({ length: MAX_INLINE_ENUM_OPTIONS }, (_, i) => `o${i}`), + }; + const large: GuardrailParameterDefinition = { + ...small, + id: 'many', + options: Array.from({ length: MAX_INLINE_ENUM_OPTIONS + 1 }, (_, i) => `o${i}`), + }; + + expect(fieldFor(small)).toMatchObject({ + type: 'custom', + component: GUARDRAIL_ENUM_LIST_CHIPS_COMPONENT, + }); + const many = fieldFor(large); + expect(many).toMatchObject({ type: 'multiselect', placeholder: LABELS.enumListPlaceholder }); + }); + + it('maps map-enum to its custom component with the source definition and mount-time selection', () => { + const entities: GuardrailParameterDefinition = { + id: 'entities', + type: 'enum-list', + label: 'Entities', + required: true, + defaultValue: ['Email'], + options: ['Email', 'Address'], + }; + const thresholds: GuardrailParameterDefinition = { + id: 'thresholds', + type: 'map-enum', + label: 'Thresholds', + required: false, + defaultValue: { Email: 0.5, Address: 0.5 }, + keySource: 'entities', + }; + const initialParameters: GuardrailValidatorParameter[] = [ + { $parameterType: 'enum-list', id: 'entities', value: ['Email', 'Address'] }, + ]; + + const field = fieldFor(thresholds, { initialParameters }, [entities]); + expect(field).toMatchObject({ type: 'custom', component: GUARDRAIL_MAP_ENUM_COMPONENT }); + if (field.type !== 'custom') throw new Error('expected custom'); + expect(field.componentProps).toMatchObject({ + sourceDef: entities, + sourceSelection: ['Email', 'Address'], + }); + }); + + it('routes overridden ids to the renderParameter bridge regardless of type', () => { + const field = fieldFor( + { + id: 'model', + type: 'enum', + label: 'Model', + required: true, + defaultValue: 'a', + options: ['a'], + }, + { overriddenIds: new Set(['model']) } + ); + expect(field).toMatchObject({ + type: 'custom', + component: GUARDRAIL_RENDER_PARAMETER_COMPONENT, + }); + }); + + it('seeds field defaults from initialParameters over definition defaults', () => { + const field = fieldFor( + { id: 'n', type: 'number', label: 'N', required: false, defaultValue: 0.5 }, + { + initialParameters: [{ $parameterType: 'number', id: 'n', value: 0.8 }], + } + ); + expect(field.defaultValue).toBe(0.8); + }); +}); + +describe('coerceGuardrailParameterValue', () => { + const def = (type: GuardrailParameterDefinition['type']): GuardrailParameterDefinition => ({ + id: 'x', + type, + label: 'X', + required: false, + defaultValue: null, + }); + + it('coerces a cleared number input (NaN) to 0, like the pre-convergence editor', () => { + expect(coerceGuardrailParameterValue(Number.NaN, def('number'))).toBe(0); + expect(coerceGuardrailParameterValue(0.7, def('number'))).toBe(0.7); + }); + + it('never persists null/undefined for text, enum, or boolean', () => { + expect(coerceGuardrailParameterValue(undefined, def('text'))).toBe(''); + expect(coerceGuardrailParameterValue(undefined, def('enum'))).toBe(''); + expect(coerceGuardrailParameterValue(undefined, def('boolean'))).toBe(false); + }); + + it('passes arrays and maps through untouched', () => { + expect(coerceGuardrailParameterValue(['a'], def('enum-list'))).toEqual(['a']); + expect(coerceGuardrailParameterValue({ a: 1 }, def('map-enum'))).toEqual({ a: 1 }); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/form-schema-builder.ts b/packages/apollo-react/src/canvas/components/Guardrails/form-schema-builder.ts new file mode 100644 index 0000000000..6f2e125a9e --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/form-schema-builder.ts @@ -0,0 +1,224 @@ +import type { FieldMetadata, FormSchema } from '@uipath/apollo-wind'; +import type { GuardrailValidatorFormLabels } from './i18n'; +import type { GuardrailParameterDefinition, GuardrailValidatorParameter } from './types'; + +/** Registered custom-component names for the parameter editors that stay guardrail-owned. */ +export const GUARDRAIL_ENUM_LIST_CHIPS_COMPONENT = 'guardrail-enum-list-chips'; +export const GUARDRAIL_MAP_ENUM_COMPONENT = 'guardrail-map-enum'; +export const GUARDRAIL_RENDER_PARAMETER_COMPONENT = 'guardrail-render-parameter'; + +/** enum-list option sets at or below this render as inline chips; larger sets as MultiSelect. */ +export const MAX_INLINE_ENUM_OPTIONS = 8; + +export interface BuildGuardrailFormSchemaOptions { + /** Definition ids claimed by the host's `renderParameter` override. */ + overriddenIds?: ReadonlySet; + /** + * Mount-time parameter values: they seed each field's `defaultValue` so the first paint is + * correct before the controlled `values` sync-in runs. Later external changes arrive + * through `values`, never through a schema rebuild. + */ + initialParameters?: GuardrailValidatorParameter[]; + /** + * Per-enum-definition stored values missing from the option catalog (e.g. an option removed + * after the guardrail was saved); each is appended as a synthetic option so the select never + * silently blanks. + */ + enumSyntheticValues?: Record; +} + +/** + * Map guardrail parameter definitions onto the forms/ `MetadataForm` schema. Five of the + * seven parameter types map to first-class field types (`number`, `textarea`, `switch`, + * `select`, `multiselect`/`string-list`); enum-lists small enough for the chip UX, `map-enum` + * (keySource-driven rows), and host `renderParameter` overrides register as custom + * components. `required` drives only the asterisk — the host owns validation, so the form is + * mounted with `disableValidation`. + */ +export function buildGuardrailFormSchema( + definitions: GuardrailParameterDefinition[], + labels: GuardrailValidatorFormLabels, + options?: BuildGuardrailFormSchemaOptions +): FormSchema { + const initialById = new Map((options?.initialParameters ?? []).map((p) => [p.id, p])); + + const fields = definitions.map((def) => + buildGuardrailField(def, definitions, labels, initialById, options) + ); + + return { + id: 'guardrail-validator-form', + title: '', + actions: [], + sections: [{ id: 'parameters', fields }], + }; +} + +function buildGuardrailField( + def: GuardrailParameterDefinition, + definitions: GuardrailParameterDefinition[], + labels: GuardrailValidatorFormLabels, + initialById: Map, + options?: BuildGuardrailFormSchemaOptions +): FieldMetadata { + const initial = initialById.get(def.id); + const base = { + name: def.id, + label: def.label, + tooltip: def.tooltip, + tooltipAriaLabel: labels.moreInformation, + validation: def.required ? { required: true } : undefined, + }; + + if (options?.overriddenIds?.has(def.id)) { + return { + ...base, + type: 'custom', + component: GUARDRAIL_RENDER_PARAMETER_COMPONENT, + defaultValue: initial?.value, + }; + } + + switch (def.type) { + case 'number': + return { + ...base, + type: 'number', + min: def.min, + max: def.max, + step: def.step, + defaultValue: + (initial?.$parameterType === 'number' ? initial.value : (def.defaultValue as number)) ?? + 0, + }; + + case 'text': + return { + ...base, + type: 'textarea', + minRows: 3, + maxLength: def.maxLength, + defaultValue: + initial?.$parameterType === 'text' + ? initial.value + : ((def.defaultValue as string | null) ?? ''), + }; + + case 'boolean': + return { + ...base, + type: 'switch', + defaultValue: + initial?.$parameterType === 'boolean' + ? initial.value + : ((def.defaultValue as boolean | null | undefined) ?? false), + }; + + case 'enum': { + const current = + initial?.$parameterType === 'enum' + ? initial.value + : ((def.defaultValue as string | null) ?? ''); + const catalog = (def.options ?? []).map((opt) => ({ + value: opt, + label: def.optionLabels?.[opt] ?? opt, + })); + const synthetic = options?.enumSyntheticValues?.[def.id]; + const opts = + synthetic && !catalog.some((opt) => opt.value === synthetic) + ? [...catalog, { value: synthetic, label: def.optionLabels?.[synthetic] ?? synthetic }] + : catalog; + return { + ...base, + type: 'select', + placeholder: labels.enumPlaceholder, + options: opts, + defaultValue: current, + }; + } + + case 'enum-list': { + const selected = + initial?.$parameterType === 'enum-list' + ? initial.value + : ((def.defaultValue as string[]) ?? []); + if ((def.options ?? []).length <= MAX_INLINE_ENUM_OPTIONS) { + return { + ...base, + type: 'custom', + component: GUARDRAIL_ENUM_LIST_CHIPS_COMPONENT, + defaultValue: selected, + componentProps: { paramDef: def, labels }, + }; + } + return { + ...base, + type: 'multiselect', + placeholder: labels.enumListPlaceholder, + options: (def.options ?? []).map((opt) => ({ + value: opt, + label: def.optionLabels?.[opt] ?? opt, + })), + defaultValue: selected, + }; + } + + case 'text-list': + return { + ...base, + type: 'string-list', + maxItems: def.maxItems, + maxLength: def.maxLength, + minRows: 2, + addItemLabel: labels.addItem, + removeItemAriaLabel: labels.removeItem, + defaultValue: + initial?.$parameterType === 'text-list' + ? initial.value + : ((def.defaultValue as string[] | null | undefined) ?? []), + }; + + case 'map-enum': { + const sourceDef = def.keySource ? definitions.find((d) => d.id === def.keySource) : undefined; + // The sibling's mount-time selection: `useWatch` in the editor returns undefined + // until the sibling field's first change event, so the initial row keys are seeded + // through componentProps. + const sourceInitial = def.keySource ? initialById.get(def.keySource) : undefined; + const sourceSelection = + sourceInitial?.$parameterType === 'enum-list' + ? sourceInitial.value + : (sourceDef?.defaultValue as string[] | undefined); + return { + ...base, + type: 'custom', + component: GUARDRAIL_MAP_ENUM_COMPONENT, + // No fallback to def.defaultValue here: the editor falls back at render time (and + // never prunes), exactly like the pre-convergence editor. + defaultValue: initial?.$parameterType === 'map-enum' ? initial.value : undefined, + componentProps: { paramDef: def, sourceDef, labels, sourceSelection }, + }; + } + } +} + +/** + * Coerce a form-emitted value to the parameter's wire shape, mirroring the pre-convergence + * editors: number inputs emit NaN when cleared (`parseFloat('')`) which persisted as 0; + * text/enum never persist null/undefined. + */ +export function coerceGuardrailParameterValue( + value: unknown, + def: GuardrailParameterDefinition +): unknown { + switch (def.type) { + case 'number': + return typeof value === 'number' && !Number.isNaN(value) ? value : 0; + case 'text': + case 'enum': + return value ?? ''; + case 'boolean': + return value ?? false; + default: + return value; + } +} diff --git a/packages/apollo-react/src/canvas/components/Guardrails/guardrail-builder.stories.tsx b/packages/apollo-react/src/canvas/components/Guardrails/guardrail-builder.stories.tsx new file mode 100644 index 0000000000..0c8d02fddf --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/guardrail-builder.stories.tsx @@ -0,0 +1,345 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { + Button, + FormField, + FormFieldError, + FormFieldLabel, + Input, + TooltipProvider, +} from '@uipath/apollo-wind'; +import { useMemo, useState } from 'react'; +import { ApI18nProvider } from '../../../i18n'; +import type { GuardrailBuilderValue, GuardrailDefinition } from './builder-types'; +import { GuardrailBuilder } from './guardrail-builder'; + +const meta = { + title: 'Components/UiPath/Guardrail Builder', + component: GuardrailBuilder, + parameters: { + layout: 'padded', + docs: { + description: { + component: ` +The complete Add/Edit screen for an OOTB guardrail validator: status banners, usage note, +name, description, validator parameters, scope selector, action (including escalation via +host slots), evaluations toggle, mixed-scopes banner, and the Save/Cancel footer. + +The builder owns its form state (initialized at mount: remount with a new key to reset) and +its validation (it gates Save). Hosts override any message via the labels prop or any field +via the errors prop. The escalation recipient search and app picker are host capabilities +injected through render props. + `, + }, + }, + }, + tags: ['autodocs'], + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const piiDefinition: GuardrailDefinition = { + validator: 'pii_detection', + displayName: 'PII detection', + allowedScopes: ['Agent', 'Llm', 'Tool'], + status: 'Available', + parameters: [ + { + id: 'entities', + type: 'enum-list', + label: 'Entities to detect', + required: true, + defaultValue: ['Email'], + options: ['Person', 'Address', 'Email', 'PhoneNumber'], + optionLabels: { + Person: 'Person name', + Address: 'Street address', + Email: 'Email address', + PhoneNumber: 'Phone number', + }, + }, + { + id: 'entityThresholds', + type: 'map-enum', + label: 'Detection thresholds', + required: false, + keySource: 'entities', + min: 0, + max: 1, + step: 0.1, + defaultValue: { Person: 0.5, Address: 0.5, Email: 0.5, PhoneNumber: 0.5 }, + }, + ], +}; + +const existingGuardrail: GuardrailBuilderValue = { + id: 'g1', + $guardrailType: 'builtInValidator', + name: 'PII detection 1', + description: 'Scans agent output for personal data.', + selector: { scopes: ['Agent', 'Tool'], matchNames: ['Send email'] }, + action: { $actionType: 'log', severityLevel: 'Warning' }, + enabledForEvals: true, + validatorType: 'pii_detection', + validatorParameters: [ + { $parameterType: 'enum-list', id: 'entities', value: ['Email', 'PhoneNumber'] }, + { $parameterType: 'map-enum', id: 'entityThresholds', value: { Email: 0.7, PhoneNumber: 0.5 } }, + ], +}; + +/** The Add screen as the palette embeds it: inline, header owned by the host. */ +export const AddInline: Story = { + args: { + open: true, + inline: true, + hideHeader: true, + definition: piiDefinition, + scope: 'Agent', + defaultName: 'PII detection 1', + availableToolNames: ['Send email', 'Search web'], + onSave: () => {}, + onCancel: () => {}, + }, +}; + +/** Edit mode as an inline panel with the back-button header. */ +export const EditInline: Story = { + args: { + open: true, + inline: true, + definition: piiDefinition, + scope: 'Agent', + guardrail: existingGuardrail, + availableToolNames: ['Send email', 'Search web'], + onSave: () => {}, + onCancel: () => {}, + }, +}; + +/** Edit mode in the modal dialog shell. */ +export const EditModal: Story = { + decorators: [ + (Story) => ( + + + + ), + ], + args: { + open: true, + definition: piiDefinition, + scope: 'Agent', + guardrail: existingGuardrail, + availableToolNames: ['Send email'], + onSave: () => {}, + onCancel: () => {}, + }, +}; + +/** A definition-level usage note renders as an informational banner. */ +export const WithUsageNote: Story = { + args: { + ...AddInline.args, + definition: { + ...piiDefinition, + usageNote: 'Each evaluation of this guardrail is billed like an agent LLM call.', + }, + }, +}; + +export const StatusUnauthorised: Story = { + args: { ...AddInline.args, definition: { ...piiDefinition, status: 'Unauthorised' } }, +}; + +export const StatusFeatureDisabled: Story = { + args: { ...AddInline.args, definition: { ...piiDefinition, status: 'FeatureDisabled' } }, +}; + +export const StatusByoDisabled: Story = { + args: { + ...AddInline.args, + definition: { + ...piiDefinition, + displayName: 'Vendor PII', + byoValidatorName: 'vendor-pii', + status: 'Disabled', + }, + }, +}; + +/** + * The escalation recipient search and app picker are host capabilities. This story injects + * demo implementations: a static user list and a fake picker button. + */ +export const EscalateWithSlots: Story = { + args: { ...AddInline.args }, + render: (args) => { + function EscalateExample() { + const users = useMemo( + () => [ + { value: 'u1', displayName: 'Ada Lovelace' }, + { value: 'u2', displayName: 'Grace Hopper' }, + ], + [] + ); + return ( + ( +
+ {users.map((u) => ( + + ))} +
+ )} + renderAppPicker={(ctx) => ( + + {ctx.label} + + {ctx.error} + + )} + escalateHelp={ +

+ Escalation uses apps compatible with the guardrails app template.{' '} + + Import one from Marketplace + +

+ } + /> + ); + } + return ; + }, +}; + +/** Mixed scopes: the banner lists other applications and the footer gains Save as new. */ +export const MixedScopesWithSaveAsNew: Story = { + args: { + open: true, + inline: true, + hideHeader: true, + definition: piiDefinition, + scope: 'Tool', + guardrail: existingGuardrail, + toolName: 'Send email', + otherAppliedScopes: { scopes: ['Agent'], tools: ['Search web'] }, + onSave: () => {}, + onSaveAsNew: () => {}, + onCancel: () => {}, + }, +}; + +/** Pressing Save with empty required fields surfaces the builder's internal validation. */ +export const ValidationErrors: Story = { + args: { + open: true, + inline: true, + hideHeader: true, + definition: { + ...piiDefinition, + parameters: [ + { id: 'prompt', type: 'text', label: 'Rule prompt', required: true, defaultValue: null }, + ...piiDefinition.parameters, + ], + }, + scope: 'Agent', + defaultName: '', + guardrail: undefined, + onSave: () => {}, + onCancel: () => {}, + }, + play: async ({ canvas, userEvent }) => { + await userEvent.click(canvas.getByRole('button', { name: 'Save' })); + }, +}; + +/** Agents-style layout: the evaluations toggle lives in the footer row. */ +export const FooterEvalsToggle: Story = { + args: { ...AddInline.args, evalsTogglePlacement: 'footer' }, +}; + +/** Chrome strings resolved from the canvas lingui catalog (Japanese). */ +export const Localized: Story = { + args: { ...AddInline.args }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +/** Host errors merge over internal validation and display immediately. */ +export const HostErrorsOverride: Story = { + args: { ...EditInline.args, hideHeader: true }, + render: (args) => { + function HostErrorsExample() { + const [errors, setErrors] = useState<{ name?: string } | undefined>({ + name: 'A guardrail with this name exists on the server', + }); + return ( +
+
+ + setErrors( + e.target.value + ? undefined + : { name: 'A guardrail with this name exists on the server' } + ) + } + /> +
+
+ +
+
+ ); + } + return ; + }, +}; diff --git a/packages/apollo-react/src/canvas/components/Guardrails/guardrail-builder.test.tsx b/packages/apollo-react/src/canvas/components/Guardrails/guardrail-builder.test.tsx new file mode 100644 index 0000000000..c9593e6ea1 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/guardrail-builder.test.tsx @@ -0,0 +1,1031 @@ +import { fireEvent, render as rtlRender, screen } from '@testing-library/react'; +import { TooltipProvider } from '@uipath/apollo-wind'; +import { axe } from 'jest-axe'; +import type { ReactElement } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import type { GuardrailBuilderValue, GuardrailDefinition } from './builder-types'; +import { GuardrailBuilder } from './guardrail-builder'; + +// The form renders a radix tooltip for the "Enable for evaluations" info icon, which +// requires a TooltipProvider ancestor. +function render(ui: ReactElement) { + return rtlRender({ui}); +} + +function makeDef(overrides?: Partial): GuardrailDefinition { + return { + validator: 'pii_detection', + displayName: 'PII Detection', + allowedScopes: ['Agent', 'Llm', 'Tool'], + parameters: [], + status: 'Available', + ...overrides, + }; +} + +function makeGuardrail(overrides?: Partial): GuardrailBuilderValue { + return { + id: 'g1', + $guardrailType: 'builtInValidator', + name: 'My PII guardrail', + selector: { scopes: ['Agent'] }, + action: { $actionType: 'log', severityLevel: 'Info' }, + enabledForEvals: true, + validatorType: 'pii_detection', + validatorParameters: [], + ...overrides, + }; +} + +describe('GuardrailBuilder', () => { + it('shows the guardrail type as a read-only field when editing', () => { + render( + + ); + + const typeInput = screen.getByLabelText('Guardrail type'); + expect(typeInput).toBeInTheDocument(); + expect(typeInput.value).toBe('PII Detection'); + expect(typeInput).toBeDisabled(); + }); + + it('does not show the guardrail type field when creating a new guardrail', () => { + render( + + ); + + expect(screen.queryByLabelText('Guardrail type')).not.toBeInTheDocument(); + }); + + describe('usageNote banner', () => { + it('renders the note when the definition declares one', () => { + const def = makeDef({ usageNote: 'Heads up: same billing as agent LLM calls.' }); + render( + + ); + expect(screen.getByText('Heads up: same billing as agent LLM calls.')).toBeInTheDocument(); + }); + + it('renders nothing when the definition omits the note', () => { + render( + + ); + expect(screen.queryByText(/agent llm calls/i)).not.toBeInTheDocument(); + }); + }); + + describe('disabled BYOG configuration', () => { + const disabledByoDefinition = (): GuardrailDefinition => + makeDef({ + validator: 'vendor_validator', + displayName: 'Vendor PII', + byoValidatorName: 'my-pii', + status: 'Disabled', + }); + + it('renders an error and disables Save', () => { + render( + + ); + + expect(screen.getByRole('alert')).toHaveTextContent( + "This guardrail's configuration has been disabled" + ); + expect(screen.getByRole('button', { name: /^save$/i })).toBeDisabled(); + }); + + it('disables Save as new for a mixed-scope guardrail', () => { + render( + + ); + + expect(screen.getByRole('button', { name: /save as new/i })).toBeDisabled(); + }); + }); + + describe('status banners', () => { + it('warns when the definition is unauthorised', () => { + render( + + ); + expect(screen.getByText(/not entitled to use guardrails/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^save$/i })).toBeDisabled(); + }); + + it('warns when the feature is disabled', () => { + render( + + ); + expect(screen.getByText(/guardrail feature is disabled/i)).toBeInTheDocument(); + }); + }); + + describe('required-param validation blocks Save', () => { + const llmJudgeDefinition = (): GuardrailDefinition => + makeDef({ + validator: 'llm_as_judge', + displayName: 'LLM as Judge', + parameters: [ + { + id: 'guardrailText', + type: 'text', + label: 'Rule prompt', + required: true, + defaultValue: null, + }, + { + id: 'model', + type: 'enum', + label: 'Judge model', + required: true, + defaultValue: null, + options: ['m1'], + }, + { + id: 'positiveExamples', + type: 'text-list', + label: 'Positive examples', + required: true, + defaultValue: [], + maxItems: 3, + }, + ], + }); + + it('does not call onSave when a required text param is empty', () => { + const onSave = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /^save$/i })); + expect(onSave).not.toHaveBeenCalled(); + expect(screen.getAllByText('Value is required').length).toBeGreaterThan(0); + }); + + it('does not call onSave when a required text param is whitespace-only', () => { + const onSave = vi.fn(); + const existing = makeGuardrail({ + validatorType: 'llm_as_judge', + validatorParameters: [ + { $parameterType: 'text', id: 'guardrailText', value: ' ' }, + { $parameterType: 'enum', id: 'model', value: 'm1' }, + { $parameterType: 'text-list', id: 'positiveExamples', value: ['ok'] }, + ], + }); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /^save$/i })); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('does not call onSave when a required enum param is empty', () => { + const onSave = vi.fn(); + const existing = makeGuardrail({ + validatorType: 'llm_as_judge', + validatorParameters: [ + { $parameterType: 'text', id: 'guardrailText', value: 'rule' }, + { $parameterType: 'enum', id: 'model', value: '' }, + { $parameterType: 'text-list', id: 'positiveExamples', value: ['ok'] }, + ], + }); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /^save$/i })); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('does not call onSave when a required text-list has only whitespace entries', () => { + const onSave = vi.fn(); + const existing = makeGuardrail({ + validatorType: 'llm_as_judge', + validatorParameters: [ + { $parameterType: 'text', id: 'guardrailText', value: 'rule' }, + { $parameterType: 'enum', id: 'model', value: 'm1' }, + { $parameterType: 'text-list', id: 'positiveExamples', value: ['', ' '] }, + ], + }); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /^save$/i })); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('calls onSave when every required param has real content', () => { + const onSave = vi.fn(); + const existing = makeGuardrail({ + validatorType: 'llm_as_judge', + validatorParameters: [ + { $parameterType: 'text', id: 'guardrailText', value: 'rule' }, + { $parameterType: 'enum', id: 'model', value: 'm1' }, + { $parameterType: 'text-list', id: 'positiveExamples', value: ['ok'] }, + ], + }); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /^save$/i })); + expect(onSave).toHaveBeenCalled(); + }); + + it('strips empty / whitespace-only entries from text-list params on save', () => { + const onSave = vi.fn(); + const definitionWithOptionalList = makeDef({ + validator: 'llm_as_judge', + displayName: 'LLM as Judge', + parameters: [ + { + id: 'guardrailText', + type: 'text', + label: 'Rule prompt', + required: true, + defaultValue: null, + }, + { + id: 'model', + type: 'enum', + label: 'Judge model', + required: true, + defaultValue: null, + options: ['m1'], + }, + { + id: 'positiveExamples', + type: 'text-list', + label: 'Positive examples', + required: false, + defaultValue: [], + maxItems: 3, + }, + ], + }); + const existing = makeGuardrail({ + validatorType: 'llm_as_judge', + validatorParameters: [ + { $parameterType: 'text', id: 'guardrailText', value: 'rule' }, + { $parameterType: 'enum', id: 'model', value: 'm1' }, + { $parameterType: 'text-list', id: 'positiveExamples', value: ['', 'kept', ' '] }, + ], + }); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /^save$/i })); + expect(onSave).toHaveBeenCalledTimes(1); + const saved = onSave.mock.calls[0][0] as GuardrailBuilderValue; + const examplesParam = saved.validatorParameters.find((p) => p.id === 'positiveExamples'); + expect(examplesParam).toEqual({ + $parameterType: 'text-list', + id: 'positiveExamples', + value: ['kept'], + }); + }); + }); + + describe('name validation', () => { + it('blocks Save and shows a message when the name is empty', () => { + const onSave = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /^save$/i })); + expect(onSave).not.toHaveBeenCalled(); + expect(screen.getByText('Guardrail name is required')).toBeInTheDocument(); + }); + + it('blocks Save on a case-insensitive duplicate name', () => { + const onSave = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /^save$/i })); + expect(onSave).not.toHaveBeenCalled(); + expect(screen.getByText('A guardrail with this name already exists')).toBeInTheDocument(); + }); + }); + + describe('scope validation', () => { + it('blocks Save when no scope is selected (Agent context only)', () => { + const onSave = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /^save$/i })); + expect(onSave).not.toHaveBeenCalled(); + expect(screen.getByText('At least one scope is required')).toBeInTheDocument(); + }); + + it('ignores selector validation when opened from a tool node', () => { + const onSave = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /^save$/i })); + expect(onSave).toHaveBeenCalled(); + }); + }); + + describe('host errors override', () => { + it('displays host errors immediately and gates Save', () => { + const onSave = vi.fn(); + render( + + ); + + expect(screen.getByText('Backend rejected this name')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /^save$/i })); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('lets the host message win over the internal one per field', () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /^save$/i })); + expect(screen.getByText('Host-specific message')).toBeInTheDocument(); + expect(screen.queryByText('Guardrail name is required')).not.toBeInTheDocument(); + }); + }); + + describe('BYOG guardrails', () => { + const makeByoDef = (overrides?: Partial): GuardrailDefinition => + makeDef({ + validator: 'vendor_validator', + displayName: 'Vendor PII', + byoValidatorName: 'my-pii', + ...overrides, + }); + + it('persists the byo sentinel and validator name on save', () => { + const onSave = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /^save$/i })); + + expect(onSave).toHaveBeenCalledTimes(1); + const saved = onSave.mock.calls[0][0] as GuardrailBuilderValue; + expect(saved.validatorType).toBe('byo'); + expect(saved.byoValidatorName).toBe('my-pii'); + }); + + it('does not stamp byo fields for a UiPath-managed definition', () => { + const onSave = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /^save$/i })); + + const saved = onSave.mock.calls[0][0] as GuardrailBuilderValue; + expect(saved.validatorType).toBe('pii_detection'); + expect(saved.byoValidatorName).toBeUndefined(); + }); + + it('drops optional params left empty on save but keeps filled ones', () => { + const onSave = vi.fn(); + const definition = makeByoDef({ + parameters: [ + { + id: 'optionalText', + type: 'text', + label: 'Optional text', + required: false, + defaultValue: '', + }, + { + id: 'strictMode', + type: 'boolean', + label: 'Strict mode', + required: false, + defaultValue: true, + }, + ], + }); + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /^save$/i })); + + const saved = onSave.mock.calls[0][0] as GuardrailBuilderValue; + expect(saved.validatorParameters).toEqual([ + { $parameterType: 'boolean', id: 'strictMode', value: true }, + ]); + }); + + it('renders a toggle for boolean params and persists the flipped value', () => { + const onSave = vi.fn(); + const definition = makeByoDef({ + parameters: [ + { + id: 'strictMode', + type: 'boolean', + label: 'Strict mode', + required: false, + defaultValue: false, + }, + ], + }); + render( + + ); + + fireEvent.click(screen.getByRole('switch', { name: /strict mode/i })); + fireEvent.click(screen.getByRole('button', { name: /^save$/i })); + + const saved = onSave.mock.calls[0][0] as GuardrailBuilderValue; + expect(saved.validatorParameters).toEqual([ + { $parameterType: 'boolean', id: 'strictMode', value: true }, + ]); + }); + }); + + describe('required map-enum validation', () => { + it('does not call onSave when a required map-enum param is empty', () => { + const onSave = vi.fn(); + const definition = makeDef({ + parameters: [ + { + id: 'thresholds', + type: 'map-enum', + label: 'Thresholds', + required: true, + defaultValue: {}, + keySource: 'entities', + min: 0, + max: 1, + step: 0.1, + }, + ], + }); + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /^save$/i })); + expect(onSave).not.toHaveBeenCalled(); + }); + }); + + describe('layout options', () => { + it('renders the evals toggle in the footer when evalsTogglePlacement is footer', () => { + render( + + ); + + const toggle = screen.getByRole('switch', { name: /enable guardrail for evaluations/i }); + const footer = toggle.closest('div.border-t'); + expect(footer).not.toBeNull(); + }); + + it('renders a custom title node when provided', () => { + render( + Fancy title} + onSave={vi.fn()} + onCancel={vi.fn()} + /> + ); + + expect(screen.getByTestId('custom-title')).toBeInTheDocument(); + }); + + it('computes the Add title from the definition display name', () => { + render( + + ); + expect(screen.getByText('Add PII Detection guardrail')).toBeInTheDocument(); + }); + }); + + describe('form interactions', () => { + it('saves edited name, description, evals flag, and scope selection', () => { + const onSave = vi.fn(); + render( + + ); + + fireEvent.change(screen.getByLabelText(/guardrail name/i), { target: { value: 'Renamed' } }); + fireEvent.change(screen.getByLabelText(/guardrail description/i), { + target: { value: 'Why it exists' }, + }); + fireEvent.click(screen.getByRole('switch', { name: /enable guardrail for evaluations/i })); + fireEvent.click(screen.getByText('LLM calls')); + fireEvent.click(screen.getByRole('button', { name: /^save$/i })); + + expect(onSave).toHaveBeenCalledTimes(1); + const saved = onSave.mock.calls[0][0] as GuardrailBuilderValue; + expect(saved.name).toBe('Renamed'); + expect(saved.description).toBe('Why it exists'); + expect(saved.enabledForEvals).toBe(false); + expect(saved.selector.scopes).toEqual(['Agent', 'Llm']); + expect(saved.id).toMatch(/^guardrail-/); + }); + + it('calls onSaveAsNew with the current value when valid', () => { + const onSaveAsNew = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /save as new/i })); + expect(onSaveAsNew).toHaveBeenCalledTimes(1); + expect((onSaveAsNew.mock.calls[0][0] as GuardrailBuilderValue).name).toBe('My PII guardrail'); + }); + + it('blocks onSaveAsNew and surfaces errors when invalid', () => { + const onSaveAsNew = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /save as new/i })); + expect(onSaveAsNew).not.toHaveBeenCalled(); + expect(screen.getByText('Guardrail name is required')).toBeInTheDocument(); + }); + + it('merges host parameter errors into the validator form', () => { + const definition = makeDef({ + parameters: [ + { id: 'prompt', type: 'text', label: 'Prompt', required: false, defaultValue: 'ok' }, + ], + }); + render( + + ); + + expect(screen.getByText('Backend rejected this prompt')).toBeInTheDocument(); + }); + + it('renders the mixed-scopes banner from otherAppliedScopes', () => { + render( + + ); + + expect(screen.getByText('This guardrail is also applied to:')).toBeInTheDocument(); + expect(screen.getByText('Other tool')).toBeInTheDocument(); + }); + + it('passes the escalate slots through to the action section', () => { + render( +
} + renderAppPicker={() =>
} + escalateHelp={

} + onSave={vi.fn()} + onCancel={vi.fn()} + /> + ); + + expect(screen.getByTestId('host-directory-search')).toBeInTheDocument(); + expect(screen.getByTestId('host-app-picker')).toBeInTheDocument(); + expect(screen.getByTestId('host-help')).toBeInTheDocument(); + }); + + it('renders in modal mode with a dialog', () => { + render( + + ); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Add PII Detection guardrail')).toBeInTheDocument(); + }); + }); + + describe('accessibility', () => { + it('has no accessibility violations on a full definition', async () => { + const definition = makeDef({ + usageNote: 'Runs on every LLM call.', + parameters: [ + { + id: 'prompt', + type: 'text', + label: 'Prompt', + required: true, + defaultValue: 'check tone', + }, + { + id: 'strict', + type: 'boolean', + label: 'Strict mode', + required: false, + defaultValue: true, + }, + ], + }); + const { container } = render( + + ); + + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + }); +}); + +describe('renderParameter passthrough', () => { + it('forwards renderParameter to the internal validator form', () => { + const def = makeDef({ + parameters: [ + { + id: 'model', + type: 'enum', + label: 'Judge model', + required: true, + defaultValue: '', + options: ['gpt-4o'], + }, + ], + }); + + render( + {}} + onCancel={() => {}} + renderParameter={(ctx) => + ctx.definition.id === 'model' ?

: undefined + } + /> + ); + + expect(screen.getByTestId('host-picker')).toBeInTheDocument(); + // The default enum select for the overridden parameter must not render. + expect(screen.queryByRole('combobox', { name: /Judge model/ })).not.toBeInTheDocument(); + }); +}); + +describe('saveDisabled passthrough', () => { + it('disables Save while the host gate is on, on top of internal validation', () => { + const { rerender } = render( + {}} + onCancel={() => {}} + saveDisabled + /> + ); + + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + + rerender( + + {}} + onCancel={() => {}} + saveDisabled={false} + /> + + ); + expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled(); + }); +}); diff --git a/packages/apollo-react/src/canvas/components/Guardrails/guardrail-builder.tsx b/packages/apollo-react/src/canvas/components/Guardrails/guardrail-builder.tsx new file mode 100644 index 0000000000..8ddb3b2618 --- /dev/null +++ b/packages/apollo-react/src/canvas/components/Guardrails/guardrail-builder.tsx @@ -0,0 +1,436 @@ +import { + Alert, + AlertDescription, + cn, + FormField, + FormFieldLabel, + InfoTooltip, + Input, + Label, + Separator, + Switch, + Textarea, +} from '@uipath/apollo-wind'; +import { Info } from 'lucide-react'; +import { type ReactNode, useCallback, useMemo, useState } from 'react'; +import { + GUARDRAIL_BYO_VALIDATOR_TYPE, + type GuardrailAppPickerContext, + type GuardrailBuilderErrors, + type GuardrailBuilderValue, + type GuardrailDefinition, + type GuardrailRecipientSearchContext, + type GuardrailScope, + type GuardrailSelector, + type GuardrailStaticRecipientContext, +} from './builder-types'; +import { + type GuardrailBuilderFormData, + getGuardrailActionErrorFields, + getGuardrailSelectorErrorFields, + initGuardrailBuilderFormData, +} from './builder-utils'; +import { GuardrailActionSection } from './components/guardrail-action-section'; +import { GuardrailScopeSelector } from './components/guardrail-scope-selector'; +import { GuardrailStatusBanner } from './components/guardrail-status-banner'; +import { MixedScopesBanner } from './components/mixed-scopes-banner'; +import { GuardrailFormLayout } from './guardrail-form-layout'; +import { GuardrailValidatorForm } from './guardrail-validator-form'; +import { + formatGuardrailFormMessage, + type GuardrailBuilderLabels, + useGuardrailBuilderLabels, +} from './i18n'; +import type { GuardrailValidatorFormProps } from './types'; +import { + dropEmptyOptionalParameters, + getRequiredEmptyParameterIds, + syncMapEnumParameters, +} from './utils'; + +export interface GuardrailBuilderProps { + /** + * Drives the Dialog in modal mode only. Form state initializes from + * `definition`/`guardrail`/`defaultName` at mount — remount (with a new `key`) to reset. + */ + open: boolean; + definition: GuardrailDefinition; + /** Where the screen was opened from. The scope selector renders only when 'Agent'. */ + scope: GuardrailScope; + /** Edit mode: initial value, copied verbatim into form state. */ + guardrail?: GuardrailBuilderValue; + /** Default name for new guardrails (host-generated unique name). */ + defaultName?: string; + /** Other guardrail names, for case-insensitive duplicate validation. */ + existingNames?: string[]; + /** Current tool node name — pre-selected as matchNames when creating at Tool scope. */ + toolName?: string; + /** Tool names available for targeting (shown when Tool scope is selected). */ + availableToolNames?: string[]; + onSave: (guardrail: GuardrailBuilderValue) => void; + onCancel: () => void; + /** Presence renders the "Save as new" secondary action (same validation gate as Save). */ + onSaveAsNew?: (guardrail: GuardrailBuilderValue) => void; + /** Pre-localized labels; renders the mixed-scopes banner when non-null. */ + otherAppliedScopes?: { scopes: string[]; tools: string[] } | null; + inline?: boolean; + hideHeader?: boolean; + dialogMaxWidth?: number; + /** Overrides the computed "Add/Edit {{name}} guardrail" title. */ + title?: ReactNode; + /** Where the enable-for-evaluations switch renders. Default 'form'. */ + evalsTogglePlacement?: 'form' | 'footer'; + renderRecipientSearch?: (ctx: GuardrailRecipientSearchContext) => ReactNode; + /** + * Replace the editor for static/asset recipients (types 3/4/5/6). Return `undefined` to + * fall through to the built-in plain input. + */ + renderStaticRecipient?: (ctx: GuardrailStaticRecipientContext) => ReactNode | undefined; + renderAppPicker?: (ctx: GuardrailAppPickerContext) => ReactNode; + /** Rendered under the escalation grid (e.g. a marketplace help line). */ + escalateHelp?: ReactNode; + /** + * Host-supplied validation errors, merged over internal validation (host wins per field). + * Host errors display immediately and gate Save like internal ones. + */ + errors?: Partial; + /** + * Forwarded to the internal `GuardrailValidatorForm`: replace the editor for individual + * validator parameters (e.g. mounting a product model picker for a judge-model parameter). + */ + renderParameter?: GuardrailValidatorFormProps['renderParameter']; + /** + * Extra host-owned Save gate, OR'd with the builder's own (e.g. while an async resolution + * a slot started is still in flight). + */ + saveDisabled?: boolean; + labels?: Partial; + className?: string; +} + +/** + * The complete Add/Edit screen for an OOTB guardrail validator: status banners, usage note, + * type display (edit mode), name, description, validator parameters, scope selector, action + * (incl. escalation via host slots), evaluations toggle, mixed-scopes banner, and the + * Save/Cancel/Save-as-new footer. + * + * Owns its form state (initialized at mount — remount via `key` to reset) and its validation + * (gating Save); hosts can override any message via `labels` or any field via `errors`. + * Requires an ancestor `TooltipProvider`. + */ +export function GuardrailBuilder({ + open, + definition, + scope, + guardrail, + defaultName, + existingNames, + toolName, + availableToolNames, + onSave, + onCancel, + onSaveAsNew, + otherAppliedScopes = null, + inline = false, + hideHeader = false, + dialogMaxWidth, + title, + evalsTogglePlacement = 'form', + renderRecipientSearch, + renderStaticRecipient, + renderAppPicker, + escalateHelp, + errors: hostErrors, + renderParameter, + saveDisabled: hostSaveDisabled = false, + labels: labelOverrides, + className, +}: GuardrailBuilderProps) { + const labels = useGuardrailBuilderLabels(labelOverrides); + + const [formData, setFormData] = useState(() => { + const data = initGuardrailBuilderFormData(definition, scope, guardrail, toolName); + if (!guardrail && defaultName) data.name = defaultName; + return data; + }); + const [showErrors, setShowErrors] = useState(false); + + const showScopeSelector = scope === 'Agent'; + + const updateField = useCallback( + (field: K, value: GuardrailBuilderFormData[K]) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }, + [] + ); + + const handleSelectorChange = useCallback( + (selector: GuardrailSelector) => updateField('selector', selector), + [updateField] + ); + + const guardrailResult = useMemo(() => { + // Reconcile map-enum values with their source enum-list selection, then drop empty / + // whitespace-only text-list entries — otherwise reopening the guardrail surfaces phantom + // rows the author never filled in. Finally drop optional params left empty: runtimes + // require persisted values to be non-empty. + const trimmed = syncMapEnumParameters(formData.validatorParameters, definition.parameters).map( + (param) => + param.$parameterType === 'text-list' + ? { ...param, value: param.value.filter((entry) => entry.trim().length > 0) } + : param + ); + const validatorParameters = dropEmptyOptionalParameters(trimmed, definition.parameters); + const isByo = definition.byoValidatorName !== undefined; + return { + id: formData.id, + $guardrailType: 'builtInValidator', + name: formData.name, + description: formData.description || undefined, + selector: formData.selector, + action: formData.action, + enabledForEvals: formData.enabledForEvals, + validatorType: isByo ? GUARDRAIL_BYO_VALIDATOR_TYPE : definition.validator, + validatorParameters, + ...(isByo ? { byoValidatorName: definition.byoValidatorName } : {}), + }; + }, [formData, definition]); + + const internalErrors = useMemo(() => { + const e: GuardrailBuilderErrors = {}; + if (!formData.name.trim()) { + e.name = labels.nameRequiredError; + } else if (existingNames?.some((n) => n.toLowerCase() === formData.name.trim().toLowerCase())) { + e.name = labels.nameDuplicateError; + } + + if (showScopeSelector) { + for (const field of getGuardrailSelectorErrorFields(formData.selector)) { + e[field] = field === 'scopes' ? labels.scopesRequiredError : labels.toolsRequiredError; + } + } + + const emptyParamIds = getRequiredEmptyParameterIds( + definition.parameters, + formData.validatorParameters + ); + if (emptyParamIds.length > 0) { + e.parameters = Object.fromEntries( + emptyParamIds.map((id) => [id, labels.parameterRequiredError]) + ); + } + + for (const field of getGuardrailActionErrorFields(formData.action)) { + e[field] = { + blockReason: labels.blockReasonRequiredError, + filterFields: labels.filterFieldsRequiredError, + recipient: labels.recipientRequiredError, + actionApp: labels.actionAppRequiredError, + }[field]; + } + return e; + }, [formData, showScopeSelector, definition.parameters, existingNames, labels]); + + // Host errors display immediately and win per field; internal errors display after a + // failed save attempt. Both gate Save. + const displayErrors = useMemo(() => { + const base: GuardrailBuilderErrors = showErrors ? { ...internalErrors } : {}; + if (hostErrors) { + for (const [key, value] of Object.entries(hostErrors)) { + if (value !== undefined) { + (base as Record)[key] = + key === 'parameters' + ? { ...base.parameters, ...(value as Record) } + : value; + } + } + } + return base; + }, [showErrors, internalErrors, hostErrors]); + + const hasHostErrors = Boolean( + hostErrors && + Object.values(hostErrors).some( + (v) => v !== undefined && (typeof v !== 'object' || Object.keys(v).length > 0) + ) + ); + const isValid = Object.keys(internalErrors).length === 0 && !hasHostErrors; + const isDefinitionAvailable = definition.status === 'Available'; + + const handleSave = useCallback(() => { + if (!isDefinitionAvailable) return; + if (!isValid) { + setShowErrors(true); + return; + } + onSave(guardrailResult); + }, [guardrailResult, onSave, isValid, isDefinitionAvailable]); + + const handleSaveAsNew = useCallback(() => { + if (!onSaveAsNew) return; + if (!isDefinitionAvailable) return; + if (!isValid) { + setShowErrors(true); + return; + } + onSaveAsNew(guardrailResult); + }, [guardrailResult, onSaveAsNew, isValid, isDefinitionAvailable]); + + const isByoConfigurationDisabled = + definition.byoValidatorName !== undefined && definition.status === 'Disabled'; + + const computedTitle = guardrail + ? formatGuardrailFormMessage(labels.editTitle, { name: definition.displayName }) + : formatGuardrailFormMessage(labels.addTitle, { name: definition.displayName }); + + const statusBanner = isByoConfigurationDisabled ? ( + + ) : definition.status === 'Unauthorised' ? ( + + ) : definition.status === 'FeatureDisabled' ? ( + + ) : null; + + const usageNote = definition.usageNote ? ( + + + {definition.usageNote} + + ) : null; + + const evalsToggle = ( +
+ updateField('enabledForEvals', checked)} + /> + + +
+ ); + + const validatorFormErrors = displayErrors.parameters; + + const formBody = ( +
+ {statusBanner} + {usageNote} + + {/* Guardrail type (read-only in edit mode — matches the type selector in the create flow) */} + {guardrail && ( + + + + + )} + + {/* Name */} + + + {labels.nameLabel} + + updateField('name', e.target.value)} + placeholder={labels.namePlaceholder} + error={displayErrors.name} + /> + + + {/* Description */} + + +