diff --git a/apps/api/src/app/config/workflows/workflow-builder.json b/apps/api/src/app/config/workflows/workflow-builder.json
index b377846..86423a2 100644
--- a/apps/api/src/app/config/workflows/workflow-builder.json
+++ b/apps/api/src/app/config/workflows/workflow-builder.json
@@ -147,6 +147,19 @@
},
"ai-handlers-view-render": {
"steps": [
+ {
+ "route": ["client", "database"],
+ "transform": {
+ "clientMessageType": "update-state",
+ "actions": [
+ {
+ "actionType": "defaults",
+ "path": "$state.tests",
+ "value": "~{ $merge($each(message.document.state.draftConfig.handlers, function($v, $k){ $v.steps[route='ai' or ($type(route)='array' and 'ai' in route)] ? {($k): []} : {} })) }"
+ }
+ ]
+ }
+ },
{
"route": "client",
"transform": {
@@ -154,14 +167,34 @@
"viewHandler": "aiHandlersView",
"layoutConfig": [
{
- "componentType": "logTreePanel",
- "props": {
- "treeData": {
- "$map": "$message.document.state.draftConfig.handlers",
- "$where": "~{ item.steps[route='ai' or ($type(route)='array' and 'ai' in route)] }",
- "$using": { "id": "$key", "name": "$key", "rawData": "$item", "children": [] }
- }
- }
+ "componentType": "aiObserverPanel",
+ "props": { "tests": "@state.tests" },
+ "emits": { "addTest": "add-test" }
+ }
+ ]
+ }
+ }
+ ]
+ },
+ "add-test": {
+ "steps": [
+ {
+ "route": "database-query",
+ "query": { "name": "get-handler-test-skeleton", "responseType": "handler-test-skeleton-ready" }
+ }
+ ]
+ },
+ "handler-test-skeleton-ready": {
+ "steps": [
+ {
+ "route": ["client", "database"],
+ "transform": {
+ "clientMessageType": "update-state",
+ "actions": [
+ {
+ "actionType": "append",
+ "path": "~{ '$state.tests.' & message.handlerName }",
+ "value": "$message.skeleton"
}
]
}
diff --git a/apps/api/src/app/websocket/DatabasePersistor.ts b/apps/api/src/app/websocket/DatabasePersistor.ts
index 85b9fc2..3f29f7f 100644
--- a/apps/api/src/app/websocket/DatabasePersistor.ts
+++ b/apps/api/src/app/websocket/DatabasePersistor.ts
@@ -134,6 +134,14 @@ export function createDatabasePersistor(deps: DatabasePersistorDeps) {
);
break;
}
+ case 'defaults': {
+ const fieldRef = `$${mongoPath}`;
+ await db.collection('artifacts').updateOne(
+ { _id: artifactId },
+ [{ $set: { [mongoPath]: { $mergeObjects: [value, { $ifNull: [fieldRef, {}] }] } } }] as any
+ );
+ break;
+ }
}
}
diff --git a/apps/api/src/app/websocket/QueryExecutor.ts b/apps/api/src/app/websocket/QueryExecutor.ts
index 1c775db..4164c5b 100644
--- a/apps/api/src/app/websocket/QueryExecutor.ts
+++ b/apps/api/src/app/websocket/QueryExecutor.ts
@@ -223,6 +223,47 @@ export function createQueryExecutor(deps: QueryExecutorDeps) {
type: phase === 'building-config' ? 'run-config-ai-step' : 'run-requirements-ai-step',
};
}
+ if (queryName === 'get-handler-test-skeleton') {
+ const channel = context.message['channel'] as string | undefined;
+ const handlerName = context.message['handlerName'] as string | undefined;
+ const artifactId = channel ? await getArtifactIdForChannel(channel) : null;
+ const doc = artifactId ? await db.collection('artifacts').findOne({ _id: artifactId }, { projection: { state: 1 } }) : null;
+ const state = (doc?.['state'] as Record | undefined) ?? {};
+ const draftConfig = state['draftConfig'] as
+ | { handlers?: Record[] }> }
+ | null
+ | undefined;
+ const handler = handlerName ? draftConfig?.handlers?.[handlerName] : undefined;
+
+ const skeleton: Record = { createdAt: new Date().toISOString() };
+ const scanTargets = (handler?.steps ?? []).map((s) => ({
+ transform: s['transform'],
+ condition: s['condition'],
+ query: s['query'],
+ }));
+ const text = JSON.stringify(scanTargets);
+ const pattern = /\$?(state|message)\.([a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*)/g;
+ const seen = new Set();
+ let m: RegExpExecArray | null;
+ while ((m = pattern.exec(text))) {
+ const [, root, subPath] = m;
+ const key = `${root}.${subPath}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ skeleton[root] = skeleton[root] ?? {};
+ let curr = skeleton[root] as Record;
+ const parts = subPath.split('.');
+ parts.forEach((p, i) => {
+ if (i === parts.length - 1) curr[p] = '';
+ else {
+ curr[p] = curr[p] ?? {};
+ curr = curr[p] as Record;
+ }
+ });
+ }
+
+ return { skeleton, handlerName };
+ }
if (queryName === 'publish-workflow-config') {
const channel = context.message['channel'] as string | undefined;
const artifactId = channel ? await getArtifactIdForChannel(channel) : null;
diff --git a/apps/web/src/app/registry/layoutRegistry.ts b/apps/web/src/app/registry/layoutRegistry.ts
index 38dc613..10c75fb 100644
--- a/apps/web/src/app/registry/layoutRegistry.ts
+++ b/apps/web/src/app/registry/layoutRegistry.ts
@@ -79,6 +79,9 @@ const registry: Partial> = {
namedView: lazy(() =>
import('@/components/layout/NamedView').then((m) => ({ default: m.NamedView as ComponentType }))
),
+ aiObserverPanel: lazy(() =>
+ import('@/components/layout/AiObserverPanel').then((m) => ({ default: m.AiObserverPanel as ComponentType }))
+ ),
};
export function getLayoutComponent(componentType: string): LazyLayoutComponent | null {
diff --git a/apps/web/src/app/services/documentModelStore.ts b/apps/web/src/app/services/documentModelStore.ts
index 278d56f..4f65c47 100644
--- a/apps/web/src/app/services/documentModelStore.ts
+++ b/apps/web/src/app/services/documentModelStore.ts
@@ -151,6 +151,10 @@ function applyAction(next: Record, action: ActionItem): Record<
const existing = (getAtPath(next, resolvedPath) as unknown[]) ?? [];
return setAtPath(next, resolvedPath, existing.slice(action.start, action.end));
}
+ case 'defaults': {
+ const existing = (getAtPath(next, resolvedPath) as Record) ?? {};
+ return setAtPath(next, resolvedPath, { ...(value as Record), ...existing });
+ }
default:
return next;
}
diff --git a/apps/web/src/app/styles/ai-observer-panel.css b/apps/web/src/app/styles/ai-observer-panel.css
new file mode 100644
index 0000000..18c7436
--- /dev/null
+++ b/apps/web/src/app/styles/ai-observer-panel.css
@@ -0,0 +1,56 @@
+/* AiObserverPanel */
+.ai-observer-panel-empty {
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1.5rem;
+ color: hsl(var(--muted-foreground));
+ font-size: 0.875rem;
+ font-style: italic;
+}
+.ai-observer-panel-right {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+.ai-observer-panel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid hsl(var(--border));
+}
+.ai-observer-panel-title {
+ font-size: 0.875rem;
+ font-weight: 600;
+}
+
+/* JsonEditor */
+.json-editor {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+.json-editor__field {
+ flex: 1;
+ width: 100%;
+ box-sizing: border-box;
+ font: inherit;
+ font-family: monospace;
+ font-size: 0.8125rem;
+ padding: 1rem;
+ border: none;
+ resize: none;
+ outline: none;
+ background-color: hsl(var(--background));
+ color: hsl(var(--foreground));
+}
+.json-editor__error {
+ padding: 0.5rem 1rem;
+ margin: 0;
+ font-size: 0.8125rem;
+ color: hsl(var(--destructive));
+ border-top: 1px solid hsl(var(--border));
+}
diff --git a/apps/web/src/app/styles/global.css b/apps/web/src/app/styles/global.css
index a862fdd..1d912fa 100644
--- a/apps/web/src/app/styles/global.css
+++ b/apps/web/src/app/styles/global.css
@@ -13,6 +13,7 @@
@import './progress-bar.css';
@import './dialog.css';
@import './writing-area.css';
+@import './ai-observer-panel.css';
:root {
--background: 0 0% 100%;
diff --git a/apps/web/src/components/layout/AiObserverPanel.tsx b/apps/web/src/components/layout/AiObserverPanel.tsx
new file mode 100644
index 0000000..3bd7412
--- /dev/null
+++ b/apps/web/src/components/layout/AiObserverPanel.tsx
@@ -0,0 +1,101 @@
+import { useMemo, useRef, useState, useEffect } from 'react';
+import { TreeView, type TreeDataItem } from '../ui/tree-view';
+import { TwoColumnPanel } from './TwoColumnPanel';
+import { JsonEditor } from './JsonEditor';
+import { Button } from '@/components/ui/button';
+
+interface TestEntry {
+ createdAt: string;
+ [key: string]: unknown;
+}
+
+interface NodeInfo {
+ handlerName: string;
+ test?: TestEntry;
+}
+
+interface Props {
+ tests?: Record;
+ onAddTest?: (payload: { handlerName: string }) => void;
+ [key: string]: unknown;
+}
+
+function buildTree(tests: Record): { items: TreeDataItem[]; nodeInfo: Map } {
+ const nodeInfo = new Map();
+ const items: TreeDataItem[] = Object.entries(tests).map(([handlerName, handlerTests]) => {
+ nodeInfo.set(handlerName, { handlerName });
+ const children: TreeDataItem[] = (handlerTests ?? []).map((test, i) => {
+ const id = test.createdAt;
+ nodeInfo.set(id, { handlerName, test });
+ return { id, name: `Test ${i + 1}` };
+ });
+ return { id: handlerName, name: handlerName, children: children.length > 0 ? children : undefined };
+ });
+ return { items, nodeInfo };
+}
+
+export function AiObserverPanel({ tests, onAddTest }: Props) {
+ const testsObj = (tests ?? {}) as Record;
+ const { items, nodeInfo } = useMemo(() => buildTree(testsObj), [testsObj]);
+
+ const [selectedId, setSelectedId] = useState(undefined);
+ const pendingAddRef = useRef(null);
+ const prevCountsRef = useRef>({});
+
+ useEffect(() => {
+ const pendingHandler = pendingAddRef.current;
+ if (pendingHandler) {
+ const currentCount = (testsObj[pendingHandler] ?? []).length;
+ const prevCount = prevCountsRef.current[pendingHandler] ?? 0;
+ if (currentCount > prevCount) {
+ const newest = testsObj[pendingHandler][currentCount - 1];
+ setSelectedId(newest.createdAt);
+ pendingAddRef.current = null;
+ }
+ }
+ for (const [handlerName, handlerTests] of Object.entries(testsObj)) {
+ prevCountsRef.current[handlerName] = (handlerTests ?? []).length;
+ }
+ }, [testsObj]);
+
+ const handleSelectChange = (item: TreeDataItem | undefined) => {
+ setSelectedId(item?.id);
+ };
+
+ const selected = selectedId ? nodeInfo.get(selectedId) : undefined;
+
+ const handleAddTest = () => {
+ if (!selected) return;
+ pendingAddRef.current = selected.handlerName;
+ onAddTest?.({ handlerName: selected.handlerName });
+ };
+
+ return (
+ No AI-routed handlers yet.
+ ) : (
+
+ )
+ }
+ right={
+ selected ? (
+
+
+ {selected.handlerName}
+ Add Test
+
+ {selected.test ? (
+
+ ) : (
+
Select a test to edit its input, or click Add Test to create one.
+ )}
+
+ ) : (
+ Select a handler or test to view details.
+ )
+ }
+ />
+ );
+}
diff --git a/apps/web/src/components/layout/JsonEditor.tsx b/apps/web/src/components/layout/JsonEditor.tsx
new file mode 100644
index 0000000..c6dbdb2
--- /dev/null
+++ b/apps/web/src/components/layout/JsonEditor.tsx
@@ -0,0 +1,40 @@
+import { useState, useEffect, ChangeEvent } from 'react';
+
+interface Props {
+ value: unknown;
+ onChange?: (parsed: unknown, raw: string) => void;
+}
+
+export function JsonEditor({ value, onChange }: Props) {
+ const [text, setText] = useState(() => JSON.stringify(value, null, 2));
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ setText(JSON.stringify(value, null, 2));
+ setError(null);
+ }, [value]);
+
+ function handleChange(e: ChangeEvent) {
+ const next = e.target.value;
+ setText(next);
+ try {
+ const parsed = JSON.parse(next);
+ setError(null);
+ onChange?.(parsed, next);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Invalid JSON');
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/docs/workflow-reference/registered-component-types.md b/docs/workflow-reference/registered-component-types.md
index 56c3239..68f8c41 100644
--- a/docs/workflow-reference/registered-component-types.md
+++ b/docs/workflow-reference/registered-component-types.md
@@ -96,6 +96,36 @@ Props: `treeData` — array of tree nodes as returned by `get-log-tree`.
---
+### `aiObserverPanel`
+
+A tree/detail panel for authoring per-handler tests against a workflow config's
+`ai`-routed handlers. Left side lists the keys of a `tests` object (one per handler,
+each holding an array of test objects); clicking a handler or test node selects it
+locally (no server round trip). Selecting any node shows that handler's name and an
+"Add Test" button on the right; clicking it emits `addTest` so the workflow can
+scaffold a new test object (seeded from the `state.*`/`message.*` paths the handler
+references) and append it — the newly created test is then auto-selected once it
+appears in `tests`. A test node's data renders in an editable JSON editor (local edits
+only for now — persisting them is separate follow-up work).
+
+```json
+{
+ "componentType": "aiObserverPanel",
+ "props": { "tests": "@state.tests" },
+ "emits": { "addTest": "add-test" }
+}
+```
+
+Props:
+| Prop | Type | Description |
+|---|---|---|
+| `tests` | `Record` | Object keyed by handler name; each value is that handler's array of test objects (`{ createdAt: string, ...state/message skeleton }`). |
+
+Emits `addTest` with payload `{ handlerName: string }` when the "Add Test" button is
+clicked for the currently selected handler/test.
+
+---
+
### `smartTab` and `smartTabs`
A tabbed workspace. `smartTabs` is the container; `smartTab` is each individual tab. Must be used together.
diff --git a/libs/shared-types/src/message.types.ts b/libs/shared-types/src/message.types.ts
index 22c0bb5..b533325 100644
--- a/libs/shared-types/src/message.types.ts
+++ b/libs/shared-types/src/message.types.ts
@@ -105,7 +105,7 @@ export interface DisplayJsonMessage extends Message {
}
export interface ActionItem {
- actionType: 'update' | 'merge' | 'append' | 'prepend' | 'upsert' | 'remove' | 'update-in' | 'slice';
+ actionType: 'update' | 'merge' | 'append' | 'prepend' | 'upsert' | 'remove' | 'update-in' | 'slice' | 'defaults';
path: string;
value: unknown;
keys?: string[];