diff --git a/docs/adoption/integration.md b/docs/adoption/integration.md
index c04602f..29e8832 100644
--- a/docs/adoption/integration.md
+++ b/docs/adoption/integration.md
@@ -70,6 +70,16 @@ const capabilityContract = registry.toContract();
`capabilityContract.pack` is model-facing. `capabilityContract.validationCapabilities`
and `capabilityContract.initialState` are runtime-facing.
+Data resources can expose a host-owned empty state when "no results" is a
+merchant-facing condition. Add `stateKeys.empty` and, when array length is not
+the right definition, `isEmpty(data)`. The generated surface should render
+`$alias.empty`; it should not infer "no results" from missing pre-load data.
+
+Actions can opt into a tiny lifecycle with `controlled: true`. Summon then
+pushes pending, done, and error state around the host handler so generated UI can
+disable the trigger, show host errors, and render success only after the host
+actually finishes. Existing actions remain uncontrolled unless they opt in.
+
Approval actions are still host tools. The difference is that the host can
prepare the exact operation before asking for a decision. The generated surface
gets only small status state such as pending, approved, denied, failed, and a
diff --git a/docs/adoption/security.md b/docs/adoption/security.md
index 79eb37b..6944e53 100644
--- a/docs/adoption/security.md
+++ b/docs/adoption/security.md
@@ -96,6 +96,9 @@ requires a compatible host registry for the same reason.
- Use `defineWorkerAction` / `defineWorkerResource` for host-owned background
work and `defineApprovalAction` for operations that require a host approval
adapter before the handler runs.
+- Use controlled action state for merchant-facing pending, success, and error
+ UI. Generated surfaces should render those keys; they should not invent local
+ completion or failure state for host actions.
- Treat approval as a workflow owned by the host, not a generated modal. For
approval actions, the host may `prepare` the exact operation into an
`ApprovalRequest`; the user approves or denies that request in host UI; the
diff --git a/examples/surface-gallery/src/capabilities.ts b/examples/surface-gallery/src/capabilities.ts
index 61e59d0..e977576 100644
--- a/examples/surface-gallery/src/capabilities.ts
+++ b/examples/surface-gallery/src/capabilities.ts
@@ -76,10 +76,10 @@ function galleryCapabilityDefinitions(opts: GalleryCapabilityOptions): Capabilit
argsSchema: searchArgsSchema,
resultSchema: searchResultSchema,
defaultData: [],
- stateKeys: { loading: 'searching', data: 'results', error: 'searchError' },
+ stateKeys: { loading: 'searching', data: 'results', error: 'searchError', empty: 'noResults' },
triggers: ['submit', 'mount'],
stateShape:
- '{searching: boolean, query: string, results: Array<{title: string, snippet: string, source: string}> | null, searchError: string | null}',
+ '{searching: boolean, query: string, results: Array<{title: string, snippet: string, source: string}> | null, searchError: string | null, noResults: boolean}',
patterns: [
{
name: 'Search resource',
@@ -90,6 +90,7 @@ function galleryCapabilityDefinitions(opts: GalleryCapabilityOptions): Capabilit
Searching...
+ No matching results.
@@ -127,10 +128,13 @@ function galleryCapabilityDefinitions(opts: GalleryCapabilityOptions): Capabilit
'Save the option the user chose. Args must include an option label. Use for generated comparison, picker, or review surfaces.',
argsSchema: chooseArgsSchema,
stateShape: '{lastChoice: string, chosenOptions: string[]}',
+ controlled: true,
patterns: [
{
name: 'Save a choice',
- code: `Save this option
+ code: `Save this option
+Saving...
+
Saved:
`,
},
],
diff --git a/examples/surface-gallery/tests/gallery-smoke.spec.ts b/examples/surface-gallery/tests/gallery-smoke.spec.ts
index 153cfec..6ddfe04 100644
--- a/examples/surface-gallery/tests/gallery-smoke.spec.ts
+++ b/examples/surface-gallery/tests/gallery-smoke.spec.ts
@@ -201,7 +201,10 @@ test('mocked generation renders and generated host tool requests update host sta
html: `
Pick a launch path
- Save Balanced path
+ Save Balanced path
+ Saving...
+
+ Saved.
Saved
`,
},
@@ -248,10 +251,110 @@ test('mocked generation renders and generated host tool requests update host sta
const frame = page.frameLocator('#sandbox');
await frame.locator('button').click();
+ await expect(frame.getByTestId('saved')).toBeVisible();
await expect(page.locator('#state-preview')).toContainText('Balanced path');
+ await expect(page.locator('#state-preview')).toContainText('chooseDone');
await expect(page.locator('#event-log')).toContainText('host tool choose');
});
+test('host search resource renders host-owned empty state', async ({ page }) => {
+ let captured: any = null;
+ await page.route('**/api/model-providers', async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify(modelProviderPayload()),
+ });
+ });
+ await page.route('**/api/ghost-roots', async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: '[]',
+ });
+ });
+ await page.route('**/api/mock-search', async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ results: [] }),
+ });
+ });
+ await page.route('**/api/generate', async (route) => {
+ captured = route.request().postDataJSON();
+ await route.fulfill({
+ status: 200,
+ contentType: 'text/plain',
+ body: streamBody([
+ { op: 'meta', path: '/surface-policy', value: captured.surfacePolicy },
+ {
+ op: 'meta',
+ path: '/surface-plan',
+ value: {
+ purpose: 'explore',
+ runtime: 'declarative',
+ data: 'host-resource',
+ authority: 'read',
+ persistence: 'replayable',
+ },
+ },
+ { op: 'set', path: '/screen', value: { sections: ['main'] } },
+ {
+ op: 'add',
+ path: '/section/main',
+ html: `
+
+ Recipe search
+
+ Searching...
+
+ No recipes found.
+
+ `,
+ },
+ {
+ op: 'meta',
+ path: '/stream-graph-summary',
+ value: {
+ health: {
+ complete: true,
+ missingDeclared: [],
+ blockedCount: 0,
+ skippedCount: 0,
+ repairedCount: 0,
+ },
+ sections: [],
+ },
+ },
+ ]),
+ });
+ });
+
+ await page.goto('/');
+ await page.locator('[data-preset-id="host-resource-search"]').click();
+ await page.locator('#run').click();
+ await expect(page.locator('#status')).toContainText('done');
+
+ expect(captured.surfacePolicy).toEqual({
+ tier: 'declarative',
+ purpose: 'explore',
+ grants: ['search'],
+ });
+
+ const frame = page.frameLocator('#sandbox');
+ await expect(frame.getByTestId('empty')).toBeHidden();
+ await frame.locator('form').evaluate((form) => {
+ form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
+ });
+ await expect(frame.getByTestId('empty')).toBeVisible();
+ await expect(page.locator('#state-preview')).toContainText('noResults');
+});
+
test('approval publish uses host-owned approval card for approve and deny decisions', async ({ page }) => {
let captured: any = null;
await page.route('**/api/model-providers', async (route) => {
diff --git a/packages/engine/src/contracts.ts b/packages/engine/src/contracts.ts
index 23ee8f5..6a92772 100644
--- a/packages/engine/src/contracts.ts
+++ b/packages/engine/src/contracts.ts
@@ -196,6 +196,12 @@ export function hintsForContractIssue(issue: ContractIssue): string[] {
return ['Add visible UI bound to the data resource error state, for example `data-summon-show="$alias.error" data-summon-bind="$alias.error"`.'];
case 'resource-data-not-rendered':
return ['Wrap result UI in `data-summon-show="$alias.data"` and bind or foreach under the data resource alias.'];
+ case 'resource-empty-not-rendered':
+ return ['Add visible no-results UI bound to the data resource empty state, for example `data-summon-show="$alias.empty"`.'];
+ case 'action-pending-not-rendered':
+ return ['Disable the triggering control with `data-summon-attr-disabled=""` or show a pending message.'];
+ case 'action-error-not-rendered':
+ return ['Add visible host error UI with `data-summon-show="" data-summon-bind=""`.'];
case 'unsafe-attr-binding':
case 'bad-attr-binding-placement':
return ['Use only safe data-summon-attr-* bindings on supported elements.'];
diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts
index 973592b..e75da68 100644
--- a/packages/engine/src/index.ts
+++ b/packages/engine/src/index.ts
@@ -98,11 +98,13 @@ export {
hasCompleteResourceStateKeys,
} from './capability-contract.js';
export type {
+ ActionStateKeys,
CapabilityBindingSpec,
CapabilityKind,
CapabilityStateKeys,
CapabilityTrigger,
CapabilityTriggerSpec,
+ ResourceStateKeys,
} from './capability-contract.js';
export {
TOKEN_CONTRACT,
diff --git a/packages/engine/src/runtime-validator/binding-rules.ts b/packages/engine/src/runtime-validator/binding-rules.ts
index 90accf3..28b7c12 100644
--- a/packages/engine/src/runtime-validator/binding-rules.ts
+++ b/packages/engine/src/runtime-validator/binding-rules.ts
@@ -32,6 +32,7 @@ export function scanIntentBindings(
context: ValidationContext,
issues: ContractIssue[],
): void {
+ const actionUsages = actionUsageMap(capabilityMap);
for (const element of elements) {
for (const trigger of ['click', 'submit', 'mount'] as const) {
const intent = element.attrs.get(`data-summon-on-${trigger}`)?.trim();
@@ -50,8 +51,12 @@ export function scanIntentBindings(
);
}
validateSurfaceCapability(capability, context, issues);
+ const usage = actionUsages.get(capability.name);
+ if (usage) usage.hasTrigger = true;
}
+ recordActionStateUsage(element.attrs, actionUsages);
}
+ warnForActionStateQuality(actionUsages, issues);
}
export function scanResourceAndAttributeBindings(
@@ -303,6 +308,11 @@ function referencesResourceSlot(
return value.trim() === path || value.trim().startsWith(`${path}.`);
}
+function referencesStateKey(value: string, key: string): boolean {
+ const trimmed = value.trim();
+ return trimmed === key || trimmed.startsWith(`${key}.`);
+}
+
function warnForResourceQuality(
resourceUsages: ResourceUsage[],
issues: ContractIssue[],
@@ -334,6 +344,85 @@ function warnForResourceQuality(
),
);
}
+ if (usage.hasEmptyState && !usage.hasEmptyBinding) {
+ issues.push(
+ warn(
+ 'resource-empty-not-rendered',
+ `Data resource "${usage.name}" has no visible empty binding under ${aliasPath}.empty`,
+ ),
+ );
+ }
+ }
+}
+
+interface ActionUsage {
+ name: string;
+ pending: string;
+ error: string;
+ hasTrigger: boolean;
+ hasPendingBinding: boolean;
+ hasErrorBinding: boolean;
+}
+
+function actionUsageMap(capabilityMap: Map): Map {
+ const out = new Map();
+ for (const capability of capabilityMap.values()) {
+ if (capability.kind !== 'action' || !capability.actionStateKeys) continue;
+ out.set(capability.name, {
+ name: capability.name,
+ pending: capability.actionStateKeys.pending,
+ error: capability.actionStateKeys.error,
+ hasTrigger: false,
+ hasPendingBinding: false,
+ hasErrorBinding: false,
+ });
+ }
+ return out;
+}
+
+function recordActionStateUsage(
+ attrs: Map,
+ actionUsages: Map,
+): void {
+ if (actionUsages.size === 0) return;
+ for (const [attr, value] of attrs) {
+ if (!isBindingAttribute(attr)) continue;
+ for (const usage of actionUsages.values()) {
+ if (
+ (attr === 'data-summon-attr-disabled' || isVisibleStateBinding(attr)) &&
+ referencesStateKey(value, usage.pending)
+ ) {
+ usage.hasPendingBinding = true;
+ }
+ if (isVisibleStateBinding(attr) && referencesStateKey(value, usage.error)) {
+ usage.hasErrorBinding = true;
+ }
+ }
+ }
+}
+
+function warnForActionStateQuality(
+ actionUsages: Map,
+ issues: ContractIssue[],
+): void {
+ for (const usage of actionUsages.values()) {
+ if (!usage.hasTrigger) continue;
+ if (!usage.hasPendingBinding) {
+ issues.push(
+ warn(
+ 'action-pending-not-rendered',
+ `Controlled action "${usage.name}" has no disabled or visible pending binding for ${usage.pending}`,
+ ),
+ );
+ }
+ if (!usage.hasErrorBinding) {
+ issues.push(
+ warn(
+ 'action-error-not-rendered',
+ `Controlled action "${usage.name}" has no visible error binding for ${usage.error}`,
+ ),
+ );
+ }
}
}
diff --git a/packages/engine/src/runtime-validator/capabilities.ts b/packages/engine/src/runtime-validator/capabilities.ts
index 1bae35e..968a0f4 100644
--- a/packages/engine/src/runtime-validator/capabilities.ts
+++ b/packages/engine/src/runtime-validator/capabilities.ts
@@ -20,6 +20,7 @@ export function buildCapabilityMap(context: ValidationContext): Map {
stateShape: '{searching: boolean, results: any[] | null, searchError: string | null}',
kind: 'resource',
triggers: ['submit', 'mount'],
- stateKeys: { loading: 'searching', data: 'results', error: 'searchError' },
+ stateKeys: { loading: 'searching', data: 'results', error: 'searchError', empty: 'noResults' },
resultSchema: '{title: string}[]',
defaultDataShape: '[]',
},
+ {
+ name: 'save',
+ description: 'Save a choice.',
+ argsSchema: '{choice: string}',
+ stateShape: '{savedChoice: string | null}',
+ kind: 'action',
+ triggers: ['click'],
+ actionStateKeys: { pending: 'savePending', done: 'saveDone', error: 'saveError' },
+ },
],
patterns: [
{
@@ -87,10 +115,14 @@ test('capabilities block renders generated protocol docs', () => {
assert.match(text, /Declarative-only interactivity/);
assert.match(text, /data-summon-resource=""/);
assert.match(text, /\$alias\.loading/);
+ assert.match(text, /\$alias\.empty/);
assert.match(text, /Default data: `\[\]`/);
assert.match(text, /Never hallucinate fetched rows/);
+ assert.match(text, /Render "no results" from `\$alias\.empty`/);
assert.match(text, /data-summon-show="\$alias\.data"/);
- assert.match(text, /State keys: loading=searching, data=results, error=searchError/);
+ assert.match(text, /State keys: loading=searching, data=results, error=searchError, empty=noResults/);
+ assert.match(text, /Action state: pending=savePending, done=saveDone, error=saveError/);
+ assert.match(text, /Controlled actions expose host-owned pending\/done\/error keys/);
assert.doesNotMatch(text, /data-summon-on-click="counter"/);
assert.doesNotMatch(text, /data-summon-on-submit="submit"/);
assert.doesNotMatch(text, /data-summon-on-click="log"/);
diff --git a/packages/engine/test/runtime-validator-bindings.test.ts b/packages/engine/test/runtime-validator-bindings.test.ts
index 0aac607..88a511e 100644
--- a/packages/engine/test/runtime-validator-bindings.test.ts
+++ b/packages/engine/test/runtime-validator-bindings.test.ts
@@ -90,6 +90,106 @@ test('warns when data resource UI omits lifecycle bindings without blocking', ()
]);
});
+test('warns when data resource UI omits declared empty state binding', () => {
+ const issues = validateHtmlFragment(
+ ``,
+ {
+ mode: 'interactive',
+ capabilities: [
+ {
+ name: 'search',
+ kind: 'resource',
+ triggers: ['submit'],
+ stateKeys: { loading: 'searching', data: 'results', error: 'searchError', empty: 'noResults' },
+ },
+ ],
+ },
+ );
+
+ assert.equal(issues.some((issue) => issue.severity === 'block'), false);
+ assert.deepEqual(codes(issues), ['resource-empty-not-rendered']);
+});
+
+test('accepts declared empty state binding for data resources', () => {
+ const issues = validateHtmlFragment(
+ `
+
+
Searching...
+
+
No results
+
+
`,
+ {
+ mode: 'interactive',
+ capabilities: [
+ {
+ name: 'search',
+ kind: 'resource',
+ triggers: ['submit'],
+ stateKeys: { loading: 'searching', data: 'results', error: 'searchError', empty: 'noResults' },
+ },
+ ],
+ },
+ );
+
+ assert.deepEqual(issues, []);
+});
+
+test('warns when controlled action UI omits pending or error state bindings', () => {
+ const issues = validateHtmlFragment(
+ 'Save ',
+ {
+ mode: 'interactive',
+ capabilities: [
+ {
+ name: 'save',
+ kind: 'action',
+ triggers: ['click'],
+ actionStateKeys: { pending: 'savePending', done: 'saveDone', error: 'saveError' },
+ },
+ ],
+ },
+ );
+
+ assert.equal(issues.some((issue) => issue.severity === 'block'), false);
+ assert.deepEqual(codes(issues), [
+ 'action-error-not-rendered',
+ 'action-pending-not-rendered',
+ ]);
+});
+
+test('accepts pending and error bindings for controlled actions', () => {
+ const issues = validateHtmlFragment(
+ `Save
+ Saving...
+
`,
+ {
+ mode: 'interactive',
+ capabilities: [
+ {
+ name: 'save',
+ kind: 'action',
+ triggers: ['click'],
+ actionStateKeys: { pending: 'savePending', done: 'saveDone', error: 'saveError' },
+ },
+ ],
+ },
+ );
+
+ assert.deepEqual(issues, []);
+});
+
test('rejects invalid resource declarations and unsafe attr bindings', () => {
const issues = validateHtmlFragment(
`Bad
diff --git a/packages/host/src/capability-registry.ts b/packages/host/src/capability-registry.ts
index d3099c7..d96001f 100644
--- a/packages/host/src/capability-registry.ts
+++ b/packages/host/src/capability-registry.ts
@@ -1,4 +1,5 @@
import type {
+ ActionStateKeys,
CapabilityKind,
CapabilityPack,
CapabilityPattern,
@@ -6,12 +7,15 @@ import type {
CapabilityTrigger,
CompiledCapabilityContract,
IntentSpec,
+ ResourceStateKeys,
CapabilitySurface,
} from '@summon-internal/engine';
import { compileCapabilityContract } from '@summon-internal/engine';
import type { ZodType, ZodTypeAny } from 'zod';
import { defineIntent, type IntentEntry, type IntentHandler } from './policy-engine.js';
+export type { ActionStateKeys, ResourceStateKeys } from '@summon-internal/engine';
+
export type StateShapeDescriptor = string | Record;
export interface CapabilityDefinition {
@@ -24,6 +28,7 @@ export interface CapabilityDefinition {
kind?: CapabilityKind;
triggers?: CapabilityTrigger[];
stateKeys?: CapabilityStateKeys;
+ actionStateKeys?: ActionStateKeys;
resultSchema?: string;
defaultDataShape?: string;
defaultData?: unknown;
@@ -41,6 +46,7 @@ export interface ActionDefinition {
triggers?: CapabilityTrigger[];
patterns?: CapabilityPattern[];
surface?: CapabilitySurface;
+ controlled?: boolean | { stateKeys?: Partial };
handler: IntentHandler;
}
@@ -53,13 +59,14 @@ export interface DataResourceDefinition {
resultSchemaText?: string;
defaultData?: Out | null;
stateShape?: StateShapeDescriptor;
- stateKeys: Required>;
+ stateKeys: ResourceStateKeys;
triggers: CapabilityTrigger[];
patterns?: CapabilityPattern[];
concurrency?: 'latest' | 'drop';
surface?: CapabilitySurface;
onStart?: (input: In) => Record;
onError?: (message: string) => void;
+ isEmpty?: (data: Out) => boolean;
fetch: (input: In, signal: AbortSignal) => Promise;
}
@@ -129,10 +136,18 @@ export function defineCapability(
}
export function defineAction(definition: ActionDefinition): CapabilityDefinition {
+ const stateKeys = actionStateKeys(definition.name, definition.controlled);
return defineCapability({
...definition,
kind: 'action',
triggers: definition.triggers ?? ['click', 'submit'],
+ ...(stateKeys
+ ? {
+ actionStateKeys: stateKeys,
+ stateShape: `${formatStateShape(definition.stateShape)} & {${stateKeys.pending}: boolean, ${stateKeys.done}: boolean, ${stateKeys.error}: string | null}`,
+ handler: createControlledActionHandler(definition, stateKeys),
+ }
+ : {}),
});
}
@@ -154,6 +169,7 @@ export function defineApprovalAction(
const approvedHandler = definition.handler;
return defineAction({
...definition,
+ controlled: undefined,
surface: {
...definition.surface,
authority: 'approval-gated',
@@ -274,6 +290,7 @@ class StaticCapabilityRegistry implements CapabilityRegistry {
triggers: definition.triggers ?? ['click', 'submit'],
};
if (definition.stateKeys) intent.stateKeys = definition.stateKeys;
+ if (definition.actionStateKeys) intent.actionStateKeys = definition.actionStateKeys;
if (definition.surface) intent.surface = definition.surface;
if (definition.resultSchema) intent.resultSchema = definition.resultSchema;
if (definition.defaultDataShape) intent.defaultDataShape = definition.defaultDataShape;
@@ -332,6 +349,7 @@ function createDataResourceHandler(
[stateKeys.loading]: true,
[stateKeys.data]: defaultData,
[stateKeys.error]: null,
+ ...resourceEmptyPatch(stateKeys, false),
});
try {
@@ -346,6 +364,7 @@ function createDataResourceHandler(
[stateKeys.loading]: false,
[stateKeys.data]: defaultData,
[stateKeys.error]: message,
+ ...resourceEmptyPatch(stateKeys, false),
});
return;
}
@@ -354,6 +373,7 @@ function createDataResourceHandler(
[stateKeys.loading]: false,
[stateKeys.data]: parsed.data,
[stateKeys.error]: null,
+ ...resourceEmptyPatch(stateKeys, resourceIsEmpty(definition, parsed.data)),
});
} catch (err) {
if (controller.signal.aborted) return;
@@ -363,6 +383,7 @@ function createDataResourceHandler(
[stateKeys.loading]: false,
[stateKeys.data]: defaultData,
[stateKeys.error]: message,
+ ...resourceEmptyPatch(stateKeys, false),
});
} finally {
if (inflight === controller) inflight = null;
@@ -370,6 +391,64 @@ function createDataResourceHandler(
};
}
+function actionStateKeys(
+ name: string,
+ controlled: ActionDefinition['controlled'],
+): ActionStateKeys | null {
+ if (!controlled) return null;
+ const partial = typeof controlled === 'object' ? controlled.stateKeys : undefined;
+ return {
+ pending: partial?.pending ?? `${name}Pending`,
+ done: partial?.done ?? `${name}Done`,
+ error: partial?.error ?? `${name}Error`,
+ };
+}
+
+function createControlledActionHandler(
+ definition: ActionDefinition,
+ stateKeys: ActionStateKeys,
+): IntentHandler {
+ const handler = definition.handler;
+ return async (ctx) => {
+ ctx.push({
+ [stateKeys.pending]: true,
+ [stateKeys.done]: false,
+ [stateKeys.error]: null,
+ });
+ try {
+ await handler(ctx);
+ ctx.push({
+ [stateKeys.pending]: false,
+ [stateKeys.done]: true,
+ [stateKeys.error]: null,
+ });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ ctx.push({
+ [stateKeys.pending]: false,
+ [stateKeys.done]: false,
+ [stateKeys.error]: message,
+ });
+ throw err;
+ }
+ };
+}
+
+function resourceEmptyPatch(
+ stateKeys: ResourceStateKeys,
+ empty: boolean,
+): Record {
+ return stateKeys.empty ? { [stateKeys.empty]: empty } : {};
+}
+
+function resourceIsEmpty(
+ definition: DataResourceDefinition,
+ data: Out,
+): boolean {
+ if (definition.isEmpty) return definition.isEmpty(data);
+ return Array.isArray(data) && data.length === 0;
+}
+
function validateDefaultData(
definition: DataResourceDefinition,
): void {
@@ -400,12 +479,13 @@ function formatDefaultDataShape(value: unknown): string {
}
function deriveResourceStateShape(
- keys: Required>,
+ keys: ResourceStateKeys,
resultSchema: ZodTypeAny,
resultSchemaText?: string,
): string {
const result = resultSchemaText ?? formatZodSchema(resultSchema);
- return `{${keys.loading}: boolean, ${keys.data}: ${result} | null, ${keys.error}: string | null}`;
+ const empty = keys.empty ? `, ${keys.empty}: boolean` : '';
+ return `{${keys.loading}: boolean, ${keys.data}: ${result} | null, ${keys.error}: string | null${empty}}`;
}
function assertUniqueCapabilityNames(definitions: CapabilityDefinition[]): void {
diff --git a/packages/host/src/index.ts b/packages/host/src/index.ts
index 8d6df04..687223f 100644
--- a/packages/host/src/index.ts
+++ b/packages/host/src/index.ts
@@ -19,6 +19,7 @@ export {
} from './capability-registry.js';
export type {
ActionDefinition,
+ ActionStateKeys,
ApprovalActionDefinition,
ApprovalDecision,
ApprovalPrepared,
@@ -27,6 +28,7 @@ export type {
CapabilityDefinition,
CapabilityRegistry,
DataResourceDefinition,
+ ResourceStateKeys,
StateShapeDescriptor,
} from './capability-registry.js';
export {
diff --git a/packages/host/src/policy.ts b/packages/host/src/policy.ts
index 00b9995..41a93e4 100644
--- a/packages/host/src/policy.ts
+++ b/packages/host/src/policy.ts
@@ -17,6 +17,7 @@ export {
} from './capability-registry.js';
export type {
ActionDefinition,
+ ActionStateKeys,
ApprovalActionDefinition,
ApprovalDecision,
ApprovalPrepared,
@@ -25,5 +26,6 @@ export type {
CapabilityDefinition,
CapabilityRegistry,
DataResourceDefinition,
+ ResourceStateKeys,
StateShapeDescriptor,
} from './capability-registry.js';
diff --git a/packages/host/test/capability-registry.test.ts b/packages/host/test/capability-registry.test.ts
index f4eab5a..bf6e784 100644
--- a/packages/host/test/capability-registry.test.ts
+++ b/packages/host/test/capability-registry.test.ts
@@ -270,6 +270,104 @@ test('registry-generated handlers reject invalid args before execution', async (
assert.ok(errors[0] instanceof IntentArgsError);
});
+test('controlled actions push pending, done, and error lifecycle state', async () => {
+ const states: Record[] = [];
+ const registry = createCapabilityRegistry([
+ defineAction({
+ name: 'save',
+ description: 'Save a selection.',
+ argsSchema: z.object({ label: z.string() }),
+ stateShape: '{savedLabel: string | null}',
+ controlled: true,
+ handler: async ({ args, push }) => {
+ push({ savedLabel: args.label });
+ },
+ }),
+ ]);
+
+ const contract = registry.toContract();
+ assert.deepEqual(contract.pack.intents[0]?.actionStateKeys, {
+ pending: 'savePending',
+ done: 'saveDone',
+ error: 'saveError',
+ });
+ assert.deepEqual(contract.initialState, {
+ savePending: false,
+ saveDone: false,
+ saveError: null,
+ });
+
+ const policy = new PolicyEngine({
+ handlers: registry.toPolicyHandlers(),
+ onStateChange: (state) => states.push(state),
+ });
+
+ await policy.dispatch('save', { label: 'Balanced path' });
+
+ assert.deepEqual(states[0], {
+ savePending: true,
+ saveDone: false,
+ saveError: null,
+ });
+ assert.equal(states[1]?.savedLabel, 'Balanced path');
+ assert.equal(states.at(-1)?.savePending, false);
+ assert.equal(states.at(-1)?.saveDone, true);
+ assert.equal(states.at(-1)?.saveError, null);
+});
+
+test('controlled actions support custom state keys and rethrow failures to diagnostics', async () => {
+ const states: Record[] = [];
+ const errors: Error[] = [];
+ const registry = createCapabilityRegistry([
+ defineAction({
+ name: 'save',
+ description: 'Save a selection.',
+ argsSchema: z.object({ label: z.string() }),
+ stateShape: '{}',
+ controlled: {
+ stateKeys: {
+ pending: 'saving',
+ done: 'saved',
+ error: 'saveFailure',
+ },
+ },
+ handler: () => {
+ throw new Error('write failed');
+ },
+ }),
+ ]);
+
+ const policy = new PolicyEngine({
+ handlers: registry.toPolicyHandlers(),
+ onStateChange: (state) => states.push(state),
+ onHandlerError: (_intent, error) => errors.push(error),
+ });
+
+ await policy.dispatch('save', { label: 'Balanced path' });
+
+ assert.equal(states[0]?.saving, true);
+ assert.equal(states.at(-1)?.saving, false);
+ assert.equal(states.at(-1)?.saved, false);
+ assert.equal(states.at(-1)?.saveFailure, 'write failed');
+ assert.equal(errors[0]?.message, 'write failed');
+});
+
+test('default actions do not receive controlled lifecycle state', () => {
+ const registry = createCapabilityRegistry([
+ defineAction({
+ name: 'save',
+ description: 'Save a selection.',
+ argsSchema: z.object({ label: z.string() }),
+ stateShape: '{}',
+ handler: () => {},
+ }),
+ ]);
+
+ const contract = registry.toContract();
+ assert.equal(contract.pack.intents[0]?.actionStateKeys, undefined);
+ assert.deepEqual(contract.initialState, {});
+});
+
test('data resource handlers push loading, data, and error state', async () => {
const registry = createCapabilityRegistry([
defineDataResource({
@@ -311,6 +409,123 @@ test('data resource handlers push loading, data, and error state', async () => {
]);
});
+test('data resource handlers expose optional empty state after successful empty results', async () => {
+ let nextResult: { title: string }[] = [];
+ const registry = createCapabilityRegistry([
+ defineDataResource({
+ name: 'lookup',
+ description: 'Fetch lookup results.',
+ argsSchema: z.object({ query: z.string() }),
+ resultSchema: z.array(z.object({ title: z.string() })),
+ defaultData: [],
+ stateKeys: {
+ loading: 'lookupLoading',
+ data: 'lookupResults',
+ error: 'lookupError',
+ empty: 'lookupEmpty',
+ },
+ triggers: ['submit'],
+ fetch: async () => nextResult,
+ }),
+ ]);
+
+ assert.deepEqual(registry.toContract().initialState, {
+ lookupLoading: false,
+ lookupResults: [],
+ lookupError: null,
+ lookupEmpty: false,
+ });
+
+ const states: Record[] = [];
+ const policy = new PolicyEngine({
+ handlers: registry.toPolicyHandlers(),
+ onStateChange: (state) => states.push(state),
+ });
+
+ await policy.dispatch('lookup', { query: 'none' });
+ assert.equal(states[0]?.lookupEmpty, false);
+ assert.equal(states.at(-1)?.lookupEmpty, true);
+
+ nextResult = [{ title: 'Found' }];
+ await policy.dispatch('lookup', { query: 'found' });
+ assert.equal(states.at(-2)?.lookupEmpty, false);
+ assert.equal(states.at(-1)?.lookupEmpty, false);
+});
+
+test('data resource empty state resets false on invalid and failed host results', async () => {
+ let mode: 'invalid' | 'throw' = 'invalid';
+ const registry = createCapabilityRegistry([
+ defineDataResource({
+ name: 'lookup',
+ description: 'Fetch lookup results.',
+ argsSchema: z.object({ query: z.string() }),
+ resultSchema: z.array(z.object({ title: z.string() })),
+ defaultData: [],
+ stateKeys: {
+ loading: 'lookupLoading',
+ data: 'lookupResults',
+ error: 'lookupError',
+ empty: 'lookupEmpty',
+ },
+ triggers: ['submit'],
+ fetch: async () => {
+ if (mode === 'throw') throw new Error('network down');
+ return [{ title: 42 }] as any;
+ },
+ }),
+ ]);
+
+ let latestState: Record = {};
+ const policy = new PolicyEngine({
+ handlers: registry.toPolicyHandlers(),
+ onStateChange: (state) => {
+ latestState = state;
+ },
+ });
+
+ await policy.dispatch('lookup', { query: 'bad' });
+ assert.equal(latestState.lookupEmpty, false);
+ assert.equal(latestState.lookupError, 'Resource "lookup" returned invalid data');
+
+ mode = 'throw';
+ await policy.dispatch('lookup', { query: 'bad' });
+ assert.equal(latestState.lookupEmpty, false);
+ assert.equal(latestState.lookupError, 'network down');
+});
+
+test('data resource empty state supports custom isEmpty logic', async () => {
+ const registry = createCapabilityRegistry([
+ defineDataResource({
+ name: 'lookup',
+ description: 'Fetch lookup result.',
+ argsSchema: z.object({ query: z.string() }),
+ resultSchema: z.object({ items: z.array(z.string()), total: z.number() }),
+ defaultData: { items: [], total: 0 },
+ stateKeys: {
+ loading: 'lookupLoading',
+ data: 'lookupResult',
+ error: 'lookupError',
+ empty: 'lookupEmpty',
+ },
+ triggers: ['submit'],
+ isEmpty: (result) => result.total === 0,
+ fetch: async () => ({ items: ['cached suggestion'], total: 0 }),
+ }),
+ ]);
+
+ let latestState: Record = {};
+ const policy = new PolicyEngine({
+ handlers: registry.toPolicyHandlers(),
+ onStateChange: (state) => {
+ latestState = state;
+ },
+ });
+
+ await policy.dispatch('lookup', { query: 'none' });
+ assert.deepEqual(latestState.lookupResult, { items: ['cached suggestion'], total: 0 });
+ assert.equal(latestState.lookupEmpty, true);
+});
+
test('data resource result validation converts invalid host data into error state', async () => {
const errors: string[] = [];
const registry = createCapabilityRegistry([
diff --git a/packages/sandbox-runtime/src/bootstrap.js b/packages/sandbox-runtime/src/bootstrap.js
index 1eac438..41c8100 100644
--- a/packages/sandbox-runtime/src/bootstrap.js
+++ b/packages/sandbox-runtime/src/bootstrap.js
@@ -31,8 +31,16 @@
const loading = keys.loading;
const data = keys.data;
const error = keys.error;
+ const empty = keys.empty;
if (typeof loading !== 'string' || typeof data !== 'string' || typeof error !== 'string') continue;
- out[name] = Object.freeze({ stateKeys: Object.freeze({ loading, data, error }) });
+ out[name] = Object.freeze({
+ stateKeys: Object.freeze({
+ loading,
+ data,
+ error,
+ ...(typeof empty === 'string' ? { empty } : {}),
+ }),
+ });
}
return Object.freeze(out);
}
@@ -174,7 +182,8 @@
const loading = walkPath(currentState, keys.loading);
const data = walkPath(currentState, keys.data);
const error = walkPath(currentState, keys.error);
- if (!rest) return { loading, data, error };
+ const empty = keys.empty ? walkPath(currentState, keys.empty) : undefined;
+ if (!rest) return { loading, data, error, empty };
const dot = rest.indexOf('.');
const head = dot === -1 ? rest : rest.slice(0, dot);
const tail = dot === -1 ? '' : rest.slice(dot + 1);
@@ -182,6 +191,7 @@
if (head === 'loading') base = loading;
else if (head === 'data') base = data;
else if (head === 'error') base = error;
+ else if (head === 'empty') base = empty;
else return undefined;
return tail ? walkPath(base, tail) : base;
}
diff --git a/packages/summon/src/index.ts b/packages/summon/src/index.ts
index 3386f2a..aaeaa22 100644
--- a/packages/summon/src/index.ts
+++ b/packages/summon/src/index.ts
@@ -25,6 +25,7 @@ export {
export type {
ActionDefinition,
+ ActionStateKeys,
ApprovalActionDefinition,
ApprovalDecision,
ApprovalPrepared,
@@ -43,6 +44,7 @@ export type {
IntentEntry,
IntentHandler,
PolicyEngineOptions,
+ ResourceStateKeys,
StateShapeDescriptor,
TypedIntentEntry,
} from '@summon-internal/host';