diff --git a/apps/ai-studio/README.md b/apps/ai-studio/README.md index 7430391d7..854845bda 100644 --- a/apps/ai-studio/README.md +++ b/apps/ai-studio/README.md @@ -15,6 +15,8 @@ A complete, runnable AI workflow product built on top of the Workflow Builder SD - A run that stops for a person: `ai-studio/human-decision` parks the run (its executor returns `{ waiting: true }`) until `POST /api/executions/:id/decision` delivers a decision; the "Refund Review" template shows the loop. The node renders through its own template, keyed by the palette type in `nodeTemplates`, with one output handle per action of its `decisionRequest` that carries a port. - The author picks what the decider's form shows. The panel lists the fields the proposal source (the named `proposalSourceNodeId`, else the single predecessor) declares under `properties.outputSchema` that the form can show (text, number, yes/no, and nullable text or yes/no; integers, nullable numbers, objects, arrays and keys with a dot or bracket are left out), each at one of four levels: Hidden, Read-only, Editable, or Editable and required. The control lives in `src/components/human-decision/decision-fields/`; from the moment the backend starts a run until Reset it is hidden, so the panel shows only the run (the decision form, then the record). - The picks are `decisionRequest.schema` itself, in the contract's format, so the canvas, the Run payload and the decider's form read one schema: a field it leaves out is Hidden, and Editable and required drops `null` from a nullable type, so a null the model left holds Approve back. A required field the decider empties holds it back too, because the backend refuses it. A required text field emptied or left with only whitespace shows no error of its own, and nothing on screen says why Approve is disabled. A field the source no longer declares stays in the schema, listed as "not in the source", until the author hides it. A stored field of a type the form cannot show gets no row, and the next pick drops it from the schema, `readOnly` and `required` included. Hidden keeps a field off the form and refuses edits to it; the value stays in the source's output, which the log panel shows and later nodes read. +- The author picks what the decider can do (`src/components/human-decision/decision-actions/`), hidden from Run until Reset like the fields. Approve is always available. Turning Reject off removes the `reject` action and its port; turning it on again restores the default reject action, so earlier changes to it are lost. Requiring a reason sets `reasonRequired`. +- The panel's hints are neutral for guidance, warning for a gap the author has to close, and info for how the node behaves. Only the actions hint is a live region, so a screen reader announces its changes. - A rejection ends the run as a result, not a dead end: the run closes `completed`, the log panel names the outcome and who settled it, and the reject handle needs no edge. The node's output carries `resolvedBy` beside the other decision fields. The run's pill stays `completed` on purpose, a rejection being a result and not a failure; whether the panel marks it visually is for the design pass. - The person decides in the node's properties sidebar: the editor's own form over `decisionRequest.schema`, filled from the proposal source's output, with read-only fields disabled and only the changed editable fields sent as `edits`. Approve and "Reject…" sit in the panel's footer. The panel shows no Delete button for any selection, deliberately for now; deleting stays on the Delete and Backspace keys. From the moment the backend starts a run until Reset the canvas is read-only, so the form reads the graph the run executes; `use-run-locks-canvas.ts` lists the exceptions. Should undo change the picks under an open form, the form keeps the fields it opened with. - An AI Agent node that answers as structured fields: the Response format dropdown sets `properties.outputSchema` to a preset JSON Schema, the worker asks the model for that shape (see [apps/execution-worker/README.md](../execution-worker/README.md#ai-agent-structured-output)), and in Refund Review the draft's fields fill the decision form, and the reply the person approves, edits included, is what the customer gets. Plain text stays the default and returns `{ response }`. The draft's `internalReasoning` stays off the form because the decision request's schema leaves it out, and out of the confirmation only because the second agent's prompt says so; nothing downstream enforces that. A node switched to a structured format has no `response`, so a downstream `{{ nodes..response }}` fails the run as `template_unresolved`, while the editor still suggests `response` for every AI Agent. diff --git a/apps/ai-studio/src/app/app.tsx b/apps/ai-studio/src/app/app.tsx index 50ffc1a00..d4147cbb8 100644 --- a/apps/ai-studio/src/app/app.tsx +++ b/apps/ai-studio/src/app/app.tsx @@ -10,6 +10,7 @@ import { AiStudioControls } from '../components/controls/ai-studio-controls'; import { DisclaimerModal } from '../components/disclaimer/disclaimer-modal'; import { ExecutionHighlighting } from '../components/execution/highlighting'; import { ExecutionLogPanel } from '../components/execution/log-panel'; +import { decisionActionsRenderer } from '../components/human-decision/decision-actions/decision-actions-control'; import { decisionFieldsRenderer } from '../components/human-decision/decision-fields/decision-fields-control'; import { decisionFormRenderer } from '../components/human-decision/decision-form/decision-form-control'; import { HumanDecisionNodeTemplate } from '../components/human-decision/node-template/human-decision-template'; @@ -26,7 +27,9 @@ const flagship = supportTriageFlow.value; // Module-level: `nodeTemplates` must keep the same reference across renders. const nodeTemplates = { [humanDecisionNodeType]: HumanDecisionNodeTemplate }; -const jsonForm = { renderers: [decisionFormRenderer, responseControlRenderer, decisionFieldsRenderer] }; +const jsonForm = { + renderers: [decisionFormRenderer, responseControlRenderer, decisionFieldsRenderer, decisionActionsRenderer], +}; // A start node is where the run begins, so it can never be a connection target. const isValidConnection: WorkflowBuilderIsValidConnection = ({ targetNode }) => !targetNode.data.isStartNode; diff --git a/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.module.css b/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.module.css new file mode 100644 index 000000000..ae9f246b6 --- /dev/null +++ b/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.module.css @@ -0,0 +1,34 @@ +.actions { + display: flex; + flex-direction: column; + gap: var(--wb-ds-space-100); +} + +.row { + display: flex; + gap: var(--wb-ds-space-100); + align-items: center; + min-height: 2.25rem; +} + +.label { + composes: wb-text-body-s from global; + + flex: 1; + min-width: 0; + color: var(--wb-ds-ui-text-default); +} + +.note { + composes: wb-text-label-s from global; + + color: var(--wb-ds-ui-text-muted-default); + white-space: nowrap; +} + +.static { + composes: wb-text-body-s from global; + + color: var(--wb-ds-ui-text-muted-default); + white-space: nowrap; +} diff --git a/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.test.tsx b/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.test.tsx new file mode 100644 index 000000000..feba09ebc --- /dev/null +++ b/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.test.tsx @@ -0,0 +1,320 @@ +import { getHandleId, registerFunctionDecorator, useChangesTrackerStore, useStore } from '@workflowbuilder/sdk'; +import type { WorkflowBuilderEdge, WorkflowBuilderNode } from '@workflowbuilder/sdk'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// SDK internals by path: the public API mounts these only inside a whole . +import { registerCustomRenderers } from '../../../../../../packages/sdk/src/features/json-form/extension-registry'; +import { NodeProperties } from '../../../../../../packages/sdk/src/features/properties-bar/components/node-properties/node-properties'; +// The real receiver, not a copy, as in ../../../nodes/human-decision/decision-request-contract.test.ts +// (follow-up: decision-request-contract-test-home). +import { decisionRequestSchema } from '../../../../../backend/src/domain/decision/decision-request-schema'; +import { refundReviewFlow } from '../../../data/refund-review-flow'; +import { useRunLocksCanvas } from '../../../hooks/use-run-locks-canvas'; +import { humanDecisionNodeType, humanDecisionPaletteItem } from '../../../nodes/human-decision'; +import { defaultDecisionRequest, defaultRejectAction } from '../../../nodes/human-decision/default-properties-data'; +import { trackFutureChangeDecorator } from '../../../plugins/undo-redo/functions/decorators'; +import { undo, useUndoRedoStore } from '../../../plugins/undo-redo/stores/use-undo-redo-store'; +import { executionEvent as event } from '../../../stores/execution-event.fixture'; +import { applyEvent, resetExecution, setExecutionStarted } from '../../../stores/use-execution-store'; +import { decisionFieldsRenderer } from '../decision-fields/decision-fields-control'; +import { decisionFormRenderer } from '../decision-form/decision-form-control'; +import { OUTPUT_HINTS, decisionActionsRenderer } from './decision-actions-control'; + +vi.mock('@workflowbuilder/sdk', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, Icon: ({ name }: { name: string }) => }; +}); + +registerCustomRenderers([decisionFormRenderer, decisionFieldsRenderer, decisionActionsRenderer]); +registerFunctionDecorator('trackFutureChange', { callback: trackFutureChangeDecorator, name: 'undoRedo' }); + +const HUMAN = 'human-1'; +const REJECTED = getHandleId({ handleType: 'source', innerId: 'rejected' }); + +const [approveAction] = defaultDecisionRequest.actions; +const withoutReject = { ...defaultDecisionRequest, actions: [approveAction] }; + +function human(decisionRequest: unknown): WorkflowBuilderNode { + return { + id: HUMAN, + type: humanDecisionNodeType, + position: { x: 350, y: 0 }, + data: { + segments: [], + properties: { label: 'Review Refund', description: '', decisionRequest }, + type: humanDecisionNodeType, + icon: 'UserCheck', + }, + }; +} + +function edgeFrom(sourceHandle: string): WorkflowBuilderEdge { + return { + id: `edge-${sourceHandle}`, + source: HUMAN, + sourceHandle, + target: 'next-1', + targetHandle: 'target', + type: 'labelEdge', + data: {}, + }; +} + +function Host() { + const node = useStore((state) => state.nodes.find((candidate) => candidate.id === HUMAN)); + return node ? : null; +} + +function RunLock() { + useRunLocksCanvas(); + return null; +} + +const storedRequest = () => + useStore.getState().nodes.find((node) => node.id === HUMAN)?.data.properties['decisionRequest'] as + | typeof defaultDecisionRequest + | undefined; + +// JsonForms debounces onChange by 10 ms. +const settle = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 40)); + }); + +describe('the decider actions control in the real properties panel', () => { + let container: HTMLDivElement; + let root: ReturnType; + let trackedChanges = 0; + let unsubscribe: () => void; + + beforeEach(() => { + useStore.setState(useStore.getInitialState(), true); + resetExecution(); + useUndoRedoStore.setState({ past: [], future: [], snapshotsWatchers: {} }); + trackedChanges = 0; + unsubscribe = useChangesTrackerStore.subscribe(() => { + trackedChanges += 1; + }); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + unsubscribe(); + act(() => root.unmount()); + container.remove(); + useStore.setState(useStore.getInitialState(), true); + resetExecution(); + }); + + async function renderPanel(nodes: WorkflowBuilderNode[], edges: WorkflowBuilderEdge[] = []) { + useStore.setState({ + nodes, + edges, + selectedNodesIds: [HUMAN], + selectedEdgesIds: [], + data: [humanDecisionPaletteItem as never], + }); + act(() => root.render()); + await settle(); + } + + const row = (action: string) => container.querySelector(`[data-decider-action="${action}"]`); + const toggle = (action: string) => row(action)?.querySelector('[role="switch"]') ?? null; + const isOn = (action: string) => toggle(action)?.getAttribute('aria-checked') === 'true'; + const checkbox = (action: string) => row(action)?.querySelector('input[type="checkbox"]'); + const nameOf = (action: string) => { + const id = toggle(action)?.getAttribute('aria-labelledby') ?? ''; + return container.querySelector(`[id="${id}"]`)?.textContent; + }; + const descriptionOf = (action: string) => { + const id = toggle(action)?.getAttribute('aria-describedby'); + return id ? container.querySelector(`[id="${id}"]`)?.textContent : undefined; + }; + const hints = () => + [...container.querySelectorAll('[data-decider-actions] [data-hint]')].map((hint) => ({ + variant: hint.dataset['hint'], + text: hint.textContent, + })); + const sectionHeader = () => + [...container.querySelectorAll('[aria-expanded]')].find((element) => element.textContent === 'Decider actions'); + + // The switch forwards a click to a hidden checkbox; jsdom does not run that forwarding, so the test clicks it. + async function flip(action: string) { + const target = checkbox(action); + if (!target) throw new Error(`no switch for ${action}`); + act(() => { + target.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await settle(); + } + + it('sits under the fields the decider sees', async () => { + await renderPanel([human(defaultDecisionRequest)]); + + const headers = [...container.querySelectorAll('[aria-expanded]')].map((element) => element.textContent); + expect(headers.indexOf('Decider actions')).toBe(headers.indexOf('Fields the decider sees') + 1); + }); + + it('on a node fresh from the palette: Approve always on, Reject on, a reason required, no path from Rejected', async () => { + await renderPanel([human(defaultDecisionRequest)]); + + expect(row('approve')?.textContent).toBe('ApproveAlways on'); + expect(toggle('approve')).toBeNull(); + expect(isOn('reject')).toBe(true); + expect(row('reject')?.textContent).not.toContain('single output'); + expect(descriptionOf('reject')).toBeUndefined(); + expect(isOn('reasonRequired')).toBe(true); + expect(row('reasonRequired')?.textContent).not.toContain('optional'); + expect(descriptionOf('reasonRequired')).toBeUndefined(); + expect(hints()).toEqual([OUTPUT_HINTS.unwired]); + expect(container.querySelector('[data-decider-actions] [data-hint]')?.getAttribute('role')).toBe('status'); + }); + + it('names each switch after its row', async () => { + await renderPanel([human(defaultDecisionRequest)]); + + expect(nameOf('reject')).toBe('Reject'); + expect(nameOf('reasonRequired')).toBe('Reason required'); + }); + + it('with an edge from the Rejected port, says both outputs carry equal weight', async () => { + await renderPanel([human(defaultDecisionRequest)], [edgeFrom(REJECTED)]); + + expect(hints()).toEqual([OUTPUT_HINTS.wired]); + }); + + it('an edge from the Approved port alone leaves Rejected without a path', async () => { + await renderPanel([human(defaultDecisionRequest)], [edgeFrom(approveAction.port)]); + + expect(hints()).toEqual([OUTPUT_HINTS.unwired]); + }); + + it('turning Reject off is one undo step: the request keeps Approve alone and the rest as it was', async () => { + await renderPanel([human(defaultDecisionRequest)]); + + await flip('reject'); + + expect(trackedChanges).toBe(1); + expect(storedRequest()?.actions).toEqual([approveAction]); + expect(storedRequest()?.schema).toBe(defaultDecisionRequest.schema); + expect(isOn('reject')).toBe(false); + expect(row('reject')?.textContent).toContain('single output'); + expect(descriptionOf('reject')).toBe('single output'); + expect(row('reasonRequired')).toBeNull(); + expect(hints()).toEqual([OUTPUT_HINTS.single]); + + act(() => undo()); + await settle(); + + expect(storedRequest()?.actions).toEqual(defaultDecisionRequest.actions); + expect(isOn('reject')).toBe(true); + expect(useUndoRedoStore.getState().past).toEqual([]); + }); + + it('turning Reject on is one undo step and adds it back on the fixed Rejected port, a reason required', async () => { + await renderPanel([human(withoutReject)]); + + await flip('reject'); + + expect(trackedChanges).toBe(1); + expect(storedRequest()?.actions).toEqual([approveAction, defaultRejectAction]); + expect(storedRequest()?.actions[1]).toMatchObject({ port: REJECTED, reasonRequired: true }); + expect(isOn('reasonRequired')).toBe(true); + expect(hints()).toEqual([OUTPUT_HINTS.unwired]); + }); + + it('Reason required off is one undo step, stores it and notes the reason is optional; on again requires it', async () => { + await renderPanel([human(defaultDecisionRequest)]); + + await flip('reasonRequired'); + + expect(trackedChanges).toBe(1); + expect(storedRequest()?.actions[1]).toEqual({ ...defaultRejectAction, reasonRequired: false }); + expect(isOn('reasonRequired')).toBe(false); + expect(row('reasonRequired')?.textContent).toContain('optional'); + expect(descriptionOf('reasonRequired')).toBe('optional'); + + await flip('reasonRequired'); + + expect(trackedChanges).toBe(2); + expect(storedRequest()?.actions[1]).toEqual(defaultRejectAction); + }); + + it.each([ + ['Reject off', 'reject', defaultDecisionRequest], + ['Reject on', 'reject', withoutReject], + ['Reason required off', 'reasonRequired', defaultDecisionRequest], + ] as const)('%s stores a request the backend takes', async (_name, action, request) => { + await renderPanel([human(request)]); + + await flip(action); + + const parsed = decisionRequestSchema.safeParse(storedRequest()); + expect(parsed.success, parsed.success ? '' : JSON.stringify(parsed.error.issues)).toBe(true); + }); + + it('on the "Refund Review" template, Rejected has a path, and turning Reject off keeps the request valid', async () => { + const { nodes, edges } = refundReviewFlow.value.diagram; + await renderPanel(nodes, edges); + + expect(hints()).toEqual([OUTPUT_HINTS.wired]); + + await flip('reject'); + + expect(decisionRequestSchema.safeParse(storedRequest()).success).toBe(true); + expect(hints()).toEqual([OUTPUT_HINTS.single]); + }); + + it.todo('turning Reject off removes the Rejected edge in the same undo step'); + it.todo('turning Reject on again brings the Rejected port back without its old edge'); + + it('locks the switches while the canvas is in the app bar read-only mode', async () => { + await renderPanel([human(defaultDecisionRequest)]); + + act(() => useStore.getState().setToggleReadOnlyMode(true)); + expect(toggle('reject')?.hasAttribute('data-disabled')).toBe(true); + expect(toggle('reasonRequired')?.hasAttribute('data-disabled')).toBe(true); + expect(checkbox('reject')?.disabled).toBe(true); + + act(() => useStore.getState().setToggleReadOnlyMode(false)); + expect(toggle('reject')?.hasAttribute('data-disabled')).toBe(false); + expect(checkbox('reject')?.disabled).toBe(false); + }); + + it('steps aside from Run until Reset, section header included, even with the canvas lock lifted', async () => { + await renderPanel([human(defaultDecisionRequest)]); + act(() => + root.render( + <> + + + , + ), + ); + expect(sectionHeader()).toBeDefined(); + + act(() => setExecutionStarted('exec-1', '/api/executions/exec-1/stream')); + expect(sectionHeader()).toBeUndefined(); + + act(() => useStore.getState().setToggleReadOnlyMode(false)); + act(() => applyEvent(event({ type: 'node_waiting', nodeId: HUMAN }))); + expect(sectionHeader()).toBeUndefined(); + + act(() => applyEvent(event({ type: 'execution_completed' }))); + expect(sectionHeader()).toBeUndefined(); + + act(() => resetExecution()); + expect(sectionHeader()).toBeDefined(); + expect(isOn('reject')).toBe(true); + }); + + it('renders nothing for a request without a resume action', async () => { + await renderPanel([human({ ...defaultDecisionRequest, actions: [defaultRejectAction] })]); + + expect(sectionHeader()).toBeUndefined(); + }); +}); diff --git a/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.tsx b/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.tsx new file mode 100644 index 000000000..b8ffc5160 --- /dev/null +++ b/apps/ai-studio/src/components/human-decision/decision-actions/decision-actions-control.tsx @@ -0,0 +1,125 @@ +import { + rankWith, + uiTypeIs, + useSingleSelectedElement, + useStore, + withJsonFormsControlProps, +} from '@workflowbuilder/sdk'; +import type { ControlProps, JsonFormsRendererExtension } from '@workflowbuilder/sdk'; +import { Accordion, Switch } from '@workflowbuilder/ui'; +import { useId } from 'react'; + +import styles from './decision-actions-control.module.css'; + +import { useIsRunShown } from '../../../hooks/use-is-run-shown'; +import { defaultRejectAction } from '../../../nodes/human-decision/default-properties-data'; +import { rejectPortOf, withReasonRequired, withReject } from '../../../utils/human-decision/decision-actions'; +import { authoredActionsOf, readDecisionRequest } from '../../../utils/human-decision/decision-request'; +import { Hint, type HintVariant } from '../hint/hint'; + +type OutputHint = 'single' | 'unwired' | 'wired'; + +export const OUTPUT_HINTS = { + single: { + variant: 'info', + text: 'Reject is off, so the gate has a single output: Approved - the decider can only approve.', + }, + unwired: { + variant: 'info', + text: '"Rejected" has no path yet. That is not a validation error - a run that reaches it simply ends as rejected. Draw an edge only if rejection has its own business path.', + }, + wired: { + variant: 'info', + text: 'Both outputs carry equal weight. An unwired "Rejected" output is not a validation error - a run that reaches it ends as rejected.', + }, +} satisfies Record; + +function DecisionActionsControl({ data, handleChange, path, enabled, label }: ControlProps) { + const nodeId = useSingleSelectedElement()?.node?.id; + const request = readDecisionRequest(data); + const actions = authoredActionsOf(data); + const rejectPort = rejectPortOf(actions); + const rejectWired = useStore( + (state) => + rejectPort !== undefined && + state.edges.some((edge) => edge.source === nodeId && edge.sourceHandle === rejectPort), + ); + const isRunShown = useIsRunShown(); + const rejectLabelId = useId(); + const rejectNoteId = useId(); + const reasonLabelId = useId(); + const reasonNoteId = useId(); + + if (request === undefined || isRunShown) { + return null; + } + + const reject = request.actions.reject; + const toggleReject = (on: boolean) => + // Turning Reject off leaves the edge from its port (the SDK drops edges only under a `sourceHandle` key, + // an action keeps it under `port`), and turning it on again brings that edge back + // (follow-up: decision-reject-edge-cleanup). + handleChange(path, { ...data, actions: withReject(actions, on, defaultRejectAction) }); + const toggleReason = (required: boolean) => + handleChange(path, { ...data, actions: withReasonRequired(actions, required) }); + + let outputHint: OutputHint = 'single'; + if (reject !== undefined) { + outputHint = rejectWired ? 'wired' : 'unwired'; + } + + return ( + +
+
+ Approve + Always on +
+
+ + Reject + + {reject === undefined && ( + + single output + + )} + +
+ {reject !== undefined && ( +
+ + Reason required + + {!reject.reasonRequired && ( + + optional + + )} + +
+ )} + + {OUTPUT_HINTS[outputHint].text} + +
+
+ ); +} + +export const decisionActionsRenderer: JsonFormsRendererExtension = { + tester: rankWith(5, uiTypeIs('DecisionActions')), + renderer: withJsonFormsControlProps(DecisionActionsControl), +}; diff --git a/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.module.css b/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.module.css index 73a5894cf..38c70f02d 100644 --- a/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.module.css +++ b/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.module.css @@ -5,13 +5,3 @@ /* The accordion's grid track grows to the widest label; this keeps it at the panel's width. */ contain: inline-size; } - -.hint { - composes: wb-text-body-s from global; - - margin: 0; - padding: 0.75rem; - border-radius: var(--wb-ds-radius-75); - background: var(--wb-ds-ui-bg-inset-subtle); - color: var(--wb-ds-ui-text-muted-default); -} diff --git a/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.test.tsx b/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.test.tsx index 72b0ec109..41ef3d6d2 100644 --- a/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.test.tsx +++ b/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.test.tsx @@ -27,8 +27,9 @@ import { useExecutionStore, } from '../../../stores/use-execution-store'; import { FIELD_MODES } from '../../../utils/human-decision/decision-fields'; +import { decisionActionsRenderer } from '../decision-actions/decision-actions-control'; import { decisionFormRenderer } from '../decision-form/decision-form-control'; -import { decisionFieldsRenderer } from './decision-fields-control'; +import { SOURCE_HINTS, decisionFieldsRenderer } from './decision-fields-control'; vi.mock('@workflowbuilder/sdk', async (importOriginal) => { const actual = await importOriginal(); @@ -64,7 +65,7 @@ declare global { } globalThis.IS_REACT_ACT_ENVIRONMENT = true; -registerCustomRenderers([decisionFormRenderer, decisionFieldsRenderer]); +registerCustomRenderers([decisionFormRenderer, decisionFieldsRenderer, decisionActionsRenderer]); const HUMAN = 'human-1'; @@ -309,24 +310,36 @@ describe('the decision fields control in the real properties panel', () => { schema: { type: 'object', properties: { replyDraft: { type: 'string', title: 'Reply draft' } } }, }; const rowLabels = () => rows().map((row) => row.querySelector('span')?.textContent); + const hints = () => + [...container.querySelectorAll('[data-decision-fields] [data-hint]')].map((hint) => ({ + variant: hint.dataset['hint'], + text: hint.textContent, + })); - it('with nothing connected, says to connect a block and keeps listing the stored fields', async () => { + it('with nothing connected, a neutral hint says nothing leads in, and keeps listing the stored fields', async () => { await renderPanel([agent('draft-1', refundOutput), human(stored)], []); - expect(container.textContent).toContain('Connect a block before this one'); - expect(container.textContent).not.toContain('declares no output fields'); + expect(hints()).toEqual([SOURCE_HINTS.unconnected]); + expect(container.querySelector('[data-decision-fields] [data-hint]')?.hasAttribute('role')).toBe(false); expect(rowLabels()).toEqual(['Reply draft (not in the source)']); }); - it('with two predecessors and no declared source, says several blocks lead in and lists the stored fields', async () => { + it('with nothing connected, Run does not start, as the hint says: the backend refuses the snapshot', async () => { + await renderPanel([agent('draft-1', refundOutput), human(stored)], []); + + const { nodes, edges } = useStore.getState(); + const parsed = workflowSnapshotSchema.safeParse(structuredClone({ nodes, edges })); + expect(parsed.success).toBe(false); + expect(JSON.stringify(parsed.error?.issues)).toContain('source_missing'); + }); + + it('with two predecessors and no declared source, a neutral hint says several blocks lead in', async () => { await renderPanel( [agent('draft-1', refundOutput), agent('draft-2', refundOutput), human(stored)], [edge('draft-1'), edge('draft-2')], ); - expect(container.textContent).toContain('Several blocks lead into this one'); - expect(container.textContent).not.toContain('Connect a block before this one'); - expect(container.textContent).not.toContain('declares no output fields'); + expect(hints()).toEqual([SOURCE_HINTS.ambiguous]); expect(rowLabels()).toEqual(['Reply draft (not in the source)']); }); @@ -334,8 +347,7 @@ describe('the decision fields control in the real properties panel', () => { const ghost = { ...refundReviewRequest, proposalSourceNodeId: 'ghost' }; await renderPanel([agent('draft-1', refundOutput), human(ghost)], [edge('draft-1')]); - expect(container.textContent).not.toContain('declares no output fields'); - expect(container.textContent).not.toContain('Connect a block before this one'); + expect(hints()).toEqual([]); expect(rowLabels()).toEqual([ 'Refund amount (not in the source)', 'Order date (not in the source)', @@ -347,8 +359,7 @@ describe('the decision fields control in the real properties panel', () => { const ghost = { ...refundReviewRequest, proposalSourceNodeId: 'ghost' }; await renderPanel([agent('draft-1', refundOutput), human(ghost)], []); - expect(container.textContent).toContain('Connect a block before this one'); - expect(container.textContent).not.toContain('declares no output fields'); + expect(hints()).toEqual([SOURCE_HINTS.unconnected]); }); describe('on the "Refund Review" template', () => { @@ -389,7 +400,7 @@ describe('the decision fields control in the real properties panel', () => { expect(rowLabels()).toEqual(['Refund amount', 'Order date', 'Reply draft', 'Internal reasoning']); expect(selects().map((select) => select.value)).toEqual(['required', 'readOnly', 'editable', 'hidden']); - expect(container.textContent).not.toContain('declares no output fields'); + expect(hints()).toEqual([]); }); it('as shipped, the backend refuses an edit to the read-only and the hidden field and takes the rest', async () => { @@ -459,11 +470,10 @@ describe('the decision fields control in the real properties panel', () => { node.id === 'draft-1' ? { ...node, data: { ...node.data, properties: change(node.data.properties) } } : node, ); - it('with the draft on Plain text, says it declares no fields and keeps listing the stored ones', async () => { + it('with the draft on Plain text, a warning says it declares no fields, and the stored ones stay listed', async () => { await renderTemplate(withDraftProperties((properties) => ({ ...properties, outputSchema: undefined }))); - expect(container.textContent).toContain('declares no output fields'); - expect(container.textContent).not.toContain('Connect a block before this one'); + expect(hints()).toEqual([SOURCE_HINTS.noFields]); expect(rowLabels()).toEqual([ 'Refund amount (not in the source)', 'Order date (not in the source)', @@ -476,7 +486,7 @@ describe('the decision fields control in the real properties panel', () => { await renderTemplate(withDraftProperties((properties) => ({ ...properties, outputSchema: amountOnly }))); - expect(container.textContent).not.toContain('declares no output fields'); + expect(hints()).toEqual([]); expect(rowLabels()).toEqual([ 'Refund amount', 'Order date (not in the source)', diff --git a/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.tsx b/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.tsx index b9c619bf2..4f9dce253 100644 --- a/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.tsx +++ b/apps/ai-studio/src/components/human-decision/decision-fields/decision-fields-control.tsx @@ -10,8 +10,8 @@ import { Accordion } from '@workflowbuilder/ui'; import styles from './decision-fields-control.module.css'; +import { useIsRunShown } from '../../../hooks/use-is-run-shown'; import { proposalSourceIdOf } from '../../../hooks/use-node-decision'; -import { useExecutionStore } from '../../../stores/use-execution-store'; import { type FieldMode, type SourceHint, @@ -21,14 +21,25 @@ import { withFieldMode, } from '../../../utils/human-decision/decision-fields'; import { readDecisionRequest } from '../../../utils/human-decision/decision-request'; +import { Hint, type HintVariant } from '../hint/hint'; import { FieldModeRow } from './field-mode-row'; -const HINTS = { - unconnected: 'Connect a block before this one — its output fields will appear here (e.g. the AI step).', - ambiguous: 'Several blocks lead into this one — keep one connection before it so its output fields appear here.', - noFields: - 'The block before this one declares no output fields the form can show (text, number, yes/no) — for an AI step, pick a structured Response format.', -} satisfies Record; +// The backend refuses a Human task with no predecessor, so `unconnected` may say Run won't start; +// nothing refuses an empty form yet, so `noFields` promises nothing about Run. +export const SOURCE_HINTS = { + unconnected: { + variant: 'neutral', + text: "Nothing leads into this block yet. Connect a block before it - its output fields will appear here (e.g. the AI step). Run won't start until this block has an incoming connection.", + }, + ambiguous: { + variant: 'neutral', + text: 'Several blocks lead into this one - keep one connection before it so its output fields appear here.', + }, + noFields: { + variant: 'warning', + text: 'The block before this one declares no output fields the form can show (text, number, yes/no) - for an AI step, pick a structured Response format.', + }, +} satisfies Record; function DecisionFieldsControl({ data, handleChange, path, enabled, label }: ControlProps) { const nodeId = useSingleSelectedElement()?.node?.id; @@ -45,8 +56,7 @@ function DecisionFieldsControl({ data, handleChange, path, enabled, label }: Con ? undefined : state.nodes.find((node) => node.id === sourceId)?.data.properties['outputSchema'], ); - // From Run until Reset the sidebar belongs to the run, even if the app bar lifts the canvas lock. - const isRunShown = useExecutionStore((state) => state.executionId !== undefined); + const isRunShown = useIsRunShown(); if (request === undefined || isRunShown) { return null; @@ -54,14 +64,14 @@ function DecisionFieldsControl({ data, handleChange, path, enabled, label }: Con const { schema } = request; const rows = fieldRows(outputSchema, schema); - const hint = sourceHintOf(sourceId, predecessors.length, rows); + const sourceHint = sourceHintOf(sourceId, predecessors.length, rows); const pick = (key: string, mode: FieldMode) => handleChange(path, { ...data, schema: withFieldMode(schema, rows, key, mode) }); return ( -
- {hint &&

{HINTS[hint]}

} +
+ {sourceHint && {SOURCE_HINTS[sourceHint].text}} {rows.map((row) => ( { diff --git a/apps/ai-studio/src/components/human-decision/decision-form/decision-form-panel.test.tsx b/apps/ai-studio/src/components/human-decision/decision-form/decision-form-panel.test.tsx index c08c7dc1f..e73b9b1ca 100644 --- a/apps/ai-studio/src/components/human-decision/decision-form/decision-form-panel.test.tsx +++ b/apps/ai-studio/src/components/human-decision/decision-form/decision-form-panel.test.tsx @@ -13,6 +13,7 @@ import { plugin } from '../../../plugin'; import { executionEvent as event } from '../../../stores/execution-event.fixture'; import { applyEvent, resetExecution, setExecutionStarted } from '../../../stores/use-execution-store'; import { reviewRequest } from '../../../utils/human-decision/review-request.fixture'; +import { decisionActionsRenderer } from '../decision-actions/decision-actions-control'; import { decisionFieldsRenderer } from '../decision-fields/decision-fields-control'; import { decisionFormRenderer } from './decision-form-control'; @@ -24,7 +25,7 @@ vi.mock('@workflowbuilder/sdk', async (importOriginal) => { vi.mock('../../../adapters/submit-decision', () => ({ submitDecision: vi.fn() })); const submit = vi.mocked(submitDecision); -registerCustomRenderers([decisionFormRenderer, decisionFieldsRenderer]); +registerCustomRenderers([decisionFormRenderer, decisionFieldsRenderer, decisionActionsRenderer]); plugin(); function agent(id: string): WorkflowBuilderNode { diff --git a/apps/ai-studio/src/components/human-decision/hint/hint.module.css b/apps/ai-studio/src/components/human-decision/hint/hint.module.css new file mode 100644 index 000000000..d5b3884ac --- /dev/null +++ b/apps/ai-studio/src/components/human-decision/hint/hint.module.css @@ -0,0 +1,35 @@ +.hint { + composes: wb-text-body-s from global; + + display: flex; + gap: var(--wb-ds-space-100); + align-items: flex-start; + margin: 0; + border-radius: var(--wb-ds-radius-75); + color: var(--wb-ds-ui-text-muted-default); +} + +.icon { + flex: none; + margin-top: var(--wb-ds-space-25); +} + +.hint--neutral { + padding: var(--wb-ds-space-150); + background: var(--wb-ds-ui-bg-inset-subtle); +} + +.hint--info .icon { + color: var(--wb-ds-ui-icon-subtle-default); +} + +.hint--warning { + padding: var(--wb-ds-space-150); + /* missing token: a semantic --wb-ds-ui-bg-warning-* */ + background: var(--wb-ds-components-snackbar-bg-warning); + color: var(--wb-ds-ui-text-warning-default); +} + +.hint--warning .icon { + color: var(--wb-ds-ui-icon-warning-default); +} diff --git a/apps/ai-studio/src/components/human-decision/hint/hint.test.tsx b/apps/ai-studio/src/components/human-decision/hint/hint.test.tsx new file mode 100644 index 000000000..ea7081ca0 --- /dev/null +++ b/apps/ai-studio/src/components/human-decision/hint/hint.test.tsx @@ -0,0 +1,57 @@ +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Hint, type HintVariant } from './hint'; + +vi.mock('@workflowbuilder/sdk', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, Icon: ({ name }: { name: string }) => }; +}); + +describe('Hint', () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + const render = (variant: HintVariant, live?: boolean) => + act(() => + root.render( + + Some guidance + , + ), + ); + const hint = () => container.querySelector('[data-hint]'); + const icon = () => container.querySelector('[data-icon]')?.dataset['icon']; + + it.each([ + ['neutral', undefined], + ['info', 'Info'], + ['warning', 'Warning'], + ] as const)('the %s variant shows the text with the %s icon', (variant, expectedIcon) => { + render(variant); + + expect(hint()?.dataset['hint']).toBe(variant); + expect(hint()?.textContent).toBe('Some guidance'); + expect(icon()).toBe(expectedIcon); + }); + + it('is a status region only when live', () => { + render('info'); + expect(hint()?.hasAttribute('role')).toBe(false); + + render('info', true); + expect(hint()?.getAttribute('role')).toBe('status'); + }); +}); diff --git a/apps/ai-studio/src/components/human-decision/hint/hint.tsx b/apps/ai-studio/src/components/human-decision/hint/hint.tsx new file mode 100644 index 000000000..9c23555a6 --- /dev/null +++ b/apps/ai-studio/src/components/human-decision/hint/hint.tsx @@ -0,0 +1,27 @@ +import { Icon } from '@workflowbuilder/sdk'; +import clsx from 'clsx'; +import type { ReactNode } from 'react'; + +import styles from './hint.module.css'; + +export type HintVariant = 'neutral' | 'info' | 'warning'; + +const ICONS = { neutral: undefined, info: 'Info', warning: 'Warning' } as const; + +// `live` reads out every new text: a switch's, and, as the panel stays mounted, a new selection's or edge's too. +type Props = { variant: HintVariant; live?: boolean; children: ReactNode }; + +/** A note in a properties section: neutral for guidance, info for how the node behaves, warning for a gap. */ +export function Hint({ variant, live = false, children }: Props) { + const icon = ICONS[variant]; + return ( +

+ {icon && } + {children} +

+ ); +} diff --git a/apps/ai-studio/src/hooks/use-is-run-shown.ts b/apps/ai-studio/src/hooks/use-is-run-shown.ts new file mode 100644 index 000000000..1b97946a8 --- /dev/null +++ b/apps/ai-studio/src/hooks/use-is-run-shown.ts @@ -0,0 +1,6 @@ +import { useExecutionStore } from '../stores/use-execution-store'; + +/** From Run until Reset the sidebar belongs to the run, even if the app bar lifts the canvas lock. */ +export function useIsRunShown() { + return useExecutionStore((state) => state.executionId !== undefined); +} diff --git a/apps/ai-studio/src/nodes/human-decision/default-properties-data.ts b/apps/ai-studio/src/nodes/human-decision/default-properties-data.ts index 3fd7398d2..e23d1a637 100644 --- a/apps/ai-studio/src/nodes/human-decision/default-properties-data.ts +++ b/apps/ai-studio/src/nodes/human-decision/default-properties-data.ts @@ -1,10 +1,22 @@ import { getHandleId } from '@workflowbuilder/sdk'; import type { NodeDataProperties } from '@workflowbuilder/sdk'; -import type { DecisionRequest } from '@workflow-builder/types/workflow-execution/decision-request'; +import type { + DecisionRequest, + RejectDecisionAction, +} from '@workflow-builder/types/workflow-execution/decision-request'; import type { HumanDecisionSchema } from './schema'; +/** Also what the Reject switch adds back, on the same port, so the node's "Rejected" handle keeps its id. */ +export const defaultRejectAction = { + name: 'reject', + label: 'Reject', + effect: 'reject', + port: getHandleId({ handleType: 'source', innerId: 'rejected' }), + reasonRequired: true, +} satisfies RejectDecisionAction; + export const defaultDecisionRequest = { version: 1, actions: [ @@ -14,13 +26,7 @@ export const defaultDecisionRequest = { effect: 'resume', port: getHandleId({ handleType: 'source', innerId: 'approved' }), }, - { - name: 'reject', - label: 'Reject', - effect: 'reject', - port: getHandleId({ handleType: 'source', innerId: 'rejected' }), - reasonRequired: true, - }, + defaultRejectAction, ], schema: { type: 'object', properties: {} }, } satisfies DecisionRequest; diff --git a/apps/ai-studio/src/nodes/human-decision/uischema.test.tsx b/apps/ai-studio/src/nodes/human-decision/uischema.test.tsx new file mode 100644 index 000000000..5685abce7 --- /dev/null +++ b/apps/ai-studio/src/nodes/human-decision/uischema.test.tsx @@ -0,0 +1,124 @@ +import { useChangesTrackerStore, useStore } from '@workflowbuilder/sdk'; +import type { WorkflowBuilderEdge, WorkflowBuilderNode } from '@workflowbuilder/sdk'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { humanDecisionNodeType, humanDecisionPaletteItem } from '.'; +// SDK internals by path: the public API mounts these only inside a whole . +import { registerCustomRenderers } from '../../../../../packages/sdk/src/features/json-form/extension-registry'; +import { NodeProperties } from '../../../../../packages/sdk/src/features/properties-bar/components/node-properties/node-properties'; +import { decisionActionsRenderer } from '../../components/human-decision/decision-actions/decision-actions-control'; +import { decisionFieldsRenderer } from '../../components/human-decision/decision-fields/decision-fields-control'; +import { decisionFormRenderer } from '../../components/human-decision/decision-form/decision-form-control'; +import { resetExecution } from '../../stores/use-execution-store'; +import { defaultPropertiesData } from './default-properties-data'; + +vi.mock('@workflowbuilder/sdk', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, Icon: ({ name }: { name: string }) => }; +}); + +registerCustomRenderers([decisionFormRenderer, decisionFieldsRenderer, decisionActionsRenderer]); + +const HUMAN = 'human-1'; + +const human: WorkflowBuilderNode = { + id: HUMAN, + type: humanDecisionNodeType, + position: { x: 350, y: 0 }, + data: { + segments: [], + properties: { ...defaultPropertiesData, label: 'Review Refund', description: 'Checks the refund' }, + type: humanDecisionNodeType, + icon: 'UserCheck', + }, +}; + +const edges: WorkflowBuilderEdge[] = []; + +function Host() { + const node = useStore((state) => state.nodes.find((candidate) => candidate.id === HUMAN)); + return node ? : null; +} + +const storedProperties = () => useStore.getState().nodes.find((node) => node.id === HUMAN)?.data.properties; + +// JsonForms debounces onChange by 10 ms. +const settle = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 40)); + }); + +// The editor's text controls keep the typing locally and hand the value over on blur. +function commit(element: HTMLInputElement, text: string) { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(element, text); + act(() => { + element.dispatchEvent(new Event('input', { bubbles: true })); + }); + act(() => { + element.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + }); +} + +describe('the Human task properties panel', () => { + let container: HTMLDivElement; + let root: ReturnType; + let trackedChanges = 0; + let unsubscribe: () => void; + + beforeEach(async () => { + useStore.setState(useStore.getInitialState(), true); + resetExecution(); + trackedChanges = 0; + unsubscribe = useChangesTrackerStore.subscribe(() => { + trackedChanges += 1; + }); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + useStore.setState({ + nodes: [human], + edges, + selectedNodesIds: [HUMAN], + selectedEdgesIds: [], + data: [humanDecisionPaletteItem as never], + }); + act(() => root.render()); + await settle(); + }); + + afterEach(() => { + unsubscribe(); + act(() => root.unmount()); + container.remove(); + useStore.setState(useStore.getInitialState(), true); + resetExecution(); + }); + + const sectionHeaders = () => [...container.querySelectorAll('[aria-expanded]')].map((element) => element.textContent); + const input = (placeholder: string) => { + const found = container.querySelector(`input[placeholder="${placeholder}"]`); + if (!found) throw new Error(`no field with the placeholder ${placeholder}`); + return found; + }; + + it('opens with General information, then the fields the decider sees, then the decider actions', () => { + expect(sectionHeaders()).toEqual(['General information', 'Fields the decider sees', 'Decider actions']); + }); + + it("groups the node's title and description, and no Status field", () => { + expect(input('Node Title...').value).toBe('Review Refund'); + expect(input('Type your description here...').value).toBe('Checks the refund'); + expect(container.textContent).not.toContain('Status'); + }); + + it('a description is stored on the node as one undo step, leaving the request alone', async () => { + commit(input('Type your description here...'), 'A person approves the refund'); + await settle(); + + expect(storedProperties()?.['description']).toBe('A person approves the refund'); + expect(storedProperties()?.['decisionRequest']).toBe(defaultPropertiesData.decisionRequest); + expect(trackedChanges).toBe(1); + }); +}); diff --git a/apps/ai-studio/src/nodes/human-decision/uischema.ts b/apps/ai-studio/src/nodes/human-decision/uischema.ts index 877b3a190..fa3f31259 100644 --- a/apps/ai-studio/src/nodes/human-decision/uischema.ts +++ b/apps/ai-studio/src/nodes/human-decision/uischema.ts @@ -5,15 +5,28 @@ import type { HumanDecisionSchema } from './schema'; const scope = getScope; -// Authoring the rest of the request lands later (follow-up: decision-request-properties-ui). +// Deadline, rerun and action labels are not authorable yet (follow-up: decision-request-properties-ui). export const uischema: UISchema = { type: 'VerticalLayout', elements: [ + // Not the SDK's `generalInformation`: it adds a Status field and shows only on a node with a `type`. { - type: 'Text', - scope: scope('properties.label'), - label: 'Title', - placeholder: 'Node Title...', + type: 'Accordion', + label: 'General information', + elements: [ + { + type: 'Text', + scope: scope('properties.label'), + label: 'Title', + placeholder: 'Node Title...', + }, + { + type: 'Text', + scope: scope('properties.description'), + label: 'Description', + placeholder: 'Type your description here...', + }, + ], }, // Custom elements: the `UISchema` union is closed (follow-up: uischema-custom-element-typing). { @@ -21,6 +34,11 @@ export const uischema: UISchema = { scope: scope('properties.decisionRequest'), label: 'Fields the decider sees', } as unknown as UISchema, + { + type: 'DecisionActions', + scope: scope('properties.decisionRequest'), + label: 'Decider actions', + } as unknown as UISchema, // The run-time decision form. { type: 'DecisionForm', scope: scope('properties.decisionRequest') } as unknown as UISchema, ], diff --git a/apps/ai-studio/src/utils/human-decision/decision-actions.test.ts b/apps/ai-studio/src/utils/human-decision/decision-actions.test.ts index d067ceb54..af1fedabb 100644 --- a/apps/ai-studio/src/utils/human-decision/decision-actions.test.ts +++ b/apps/ai-studio/src/utils/human-decision/decision-actions.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { offeredActions } from './decision-actions'; +import { offeredActions, rejectPortOf, withReasonRequired, withReject } from './decision-actions'; import { reviewRequest } from './review-request.fixture'; const [approve, reject] = reviewRequest.actions; @@ -37,8 +37,56 @@ describe('offeredActions', () => { expect(offeredActions([{ ...approve, label: ' ' }])?.resume.label).toBe('approve'); }); + it('offers no reject when the reject has no port to route on', () => { + const portless = { name: 'reject', label: 'Reject', effect: 'reject', reasonRequired: false }; + + expect(offeredActions([approve, portless])?.reject).toBeUndefined(); + expect(offeredActions([approve, { ...reject, port: ' ' }])?.reject).toBeUndefined(); + }); + it('offers nothing without a usable resume action, whatever else the request carries', () => { expect(offeredActions([reject])).toBeUndefined(); expect(offeredActions([null, { name: '', effect: 'resume' }])).toBeUndefined(); }); }); + +describe('the reject switches', () => { + const rerun = { name: 'redraft', label: 'Ask again', effect: 'rerun-source' }; + const added = { + name: 'reject', + label: 'Reject', + effect: 'reject', + port: 'source:inner:rejected', + reasonRequired: true, + }; + + it('reads the port the reject routes on, and none without a reject', () => { + expect(rejectPortOf(reviewRequest.actions)).toBe('source:inner:rejected'); + expect(rejectPortOf([approve])).toBeUndefined(); + expect(rejectPortOf([approve, { ...reject, port: '' }])).toBeUndefined(); + }); + + it('turned off, drops the reject and keeps every other action in its place', () => { + expect(withReject([rerun, reject, approve], false, added)).toEqual([rerun, approve]); + }); + + it('turned on, appends the given reject', () => { + expect(withReject([approve], true, added)).toEqual([approve, added]); + }); + + it('turned on over a stored reject listed first, drops it and appends the given one', () => { + expect(withReject([reject, approve], true, added)).toEqual([approve, added]); + }); + + it('turned on over a stored reject the decider is not offered, drops it and appends the given one', () => { + const unnamed = { effect: 'reject', port: 'source:inner:rejected', reasonRequired: false }; + + expect(offeredActions([approve, unnamed])?.reject).toBeUndefined(); + expect(withReject([approve, unnamed], true, added)).toEqual([approve, added]); + }); + + it('sets reasonRequired on the reject alone, and leaves a request without one as it was', () => { + expect(withReasonRequired(reviewRequest.actions, true)).toEqual([approve, { ...reject, reasonRequired: true }]); + expect(withReasonRequired([approve], true)).toEqual([approve]); + }); +}); diff --git a/apps/ai-studio/src/utils/human-decision/decision-actions.ts b/apps/ai-studio/src/utils/human-decision/decision-actions.ts index c8f283cb5..55310ac54 100644 --- a/apps/ai-studio/src/utils/human-decision/decision-actions.ts +++ b/apps/ai-studio/src/utils/human-decision/decision-actions.ts @@ -22,10 +22,33 @@ function rejectOfferOf(entry: Record | undefined): RejectOffer return offer === undefined ? undefined : { ...offer, reasonRequired: entry?.['reasonRequired'] === true }; } +function isReject(entry: unknown): entry is Record { + return isPlainObject(entry) && entry['effect'] === 'reject'; +} + +// A reject without a port has no handle on the canvas and Publish refuses it, so it counts as off. +function offeredReject(actions: readonly unknown[]): RejectOffer | undefined { + const entry = actions.find(isReject); + return hasText(entry?.['port']) ? rejectOfferOf(entry) : undefined; +} + // Authored node data: only the array is proven. A `rerun-source` action is left out: the endpoint answers 501 for it. export function offeredActions(actions: readonly unknown[]): OfferedActions | undefined { - const entries = actions.filter(isPlainObject); - const withEffect = (effect: string) => entries.find((entry) => entry['effect'] === effect); - const resume = offerOf(withEffect('resume')); - return resume === undefined ? undefined : { resume, reject: rejectOfferOf(withEffect('reject')) }; + const resume = offerOf(actions.filter(isPlainObject).find((entry) => entry['effect'] === 'resume')); + return resume === undefined ? undefined : { resume, reject: offeredReject(actions) }; +} + +export function rejectPortOf(actions: readonly unknown[]): string | undefined { + const port = actions.find(isReject)?.['port']; + return hasText(port) ? port : undefined; +} + +/** Drops every stored reject and, turned on, appends `rejectAction`. The switch turns it on only while it shows off. */ +export function withReject(actions: readonly unknown[], on: boolean, rejectAction: unknown): unknown[] { + const withoutReject = actions.filter((entry) => !isReject(entry)); + return on ? [...withoutReject, rejectAction] : withoutReject; +} + +export function withReasonRequired(actions: readonly unknown[], reasonRequired: boolean): unknown[] { + return actions.map((entry) => (isReject(entry) ? { ...entry, reasonRequired } : entry)); } diff --git a/apps/ai-studio/src/utils/human-decision/decision-request.ts b/apps/ai-studio/src/utils/human-decision/decision-request.ts index d7bdcb442..cda3a2361 100644 --- a/apps/ai-studio/src/utils/human-decision/decision-request.ts +++ b/apps/ai-studio/src/utils/human-decision/decision-request.ts @@ -7,14 +7,18 @@ import { type OfferedActions, offeredActions } from './decision-actions'; /** What the form takes from the node's authored request. */ type DecisionFormRequest = { schema: JsonSchema; actions: OfferedActions; proposalSourceNodeId: string | undefined }; +export function authoredActionsOf(data: unknown): readonly unknown[] { + return isPlainObject(data) && Array.isArray(data['actions']) ? data['actions'] : []; +} + // Publish refuses a request without a resume action or a form schema, so a parked node has both. // The request's own `uiSchema` is not used yet (follow-up: decision-form-authored-ui-schema). export function readDecisionRequest(data: unknown): DecisionFormRequest | undefined { if (!isPlainObject(data)) { return undefined; } - const { actions, schema, proposalSourceNodeId } = data; - const offered = Array.isArray(actions) ? offeredActions(actions) : undefined; + const { schema, proposalSourceNodeId } = data; + const offered = offeredActions(authoredActionsOf(data)); if (offered === undefined || !isFormSchema(schema)) { return undefined; }