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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/ai-studio/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<id>.response }}` fails the run as `template_unresolved`, while the editor still suggests `response` for every AI Agent.
Expand Down
5 changes: 4 additions & 1 deletion apps/ai-studio/src/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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 <WorkflowBuilder.Root>.
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<typeof import('@workflowbuilder/sdk')>();
return { ...actual, Icon: ({ name }: { name: string }) => <i data-icon={name} /> };
});

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 ? <NodeProperties node={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<typeof createRoot>;
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(<Host />));
await settle();
}

const row = (action: string) => container.querySelector<HTMLElement>(`[data-decider-action="${action}"]`);
const toggle = (action: string) => row(action)?.querySelector<HTMLElement>('[role="switch"]') ?? null;
const isOn = (action: string) => toggle(action)?.getAttribute('aria-checked') === 'true';
const checkbox = (action: string) => row(action)?.querySelector<HTMLInputElement>('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<HTMLElement>('[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(
<>
<RunLock />
<Host />
</>,
),
);
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();
});
});
Loading
Loading