Skip to content
Merged
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
10 changes: 10 additions & 0 deletions docs/adoption/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/adoption/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions examples/surface-gallery/src/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -90,6 +90,7 @@ function galleryCapabilityDefinitions(opts: GalleryCapabilityOptions): Capabilit
</form>
<p data-summon-show="$s.loading">Searching...</p>
<p data-summon-show="$s.error" data-summon-bind="$s.error"></p>
<p data-summon-show="$s.empty">No matching results.</p>
<ul data-summon-show="$s.data" data-summon-foreach="$s.data" data-summon-as="result">
<template>
<li>
Expand Down Expand Up @@ -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: `<button data-summon-on-click="choose" data-summon-args='{"option":"Balanced path"}'>Save this option</button>
code: `<button data-summon-on-click="choose" data-summon-args='{"option":"Balanced path"}' data-summon-attr-disabled="choosePending">Save this option</button>
<p data-summon-show="choosePending">Saving...</p>
<p data-summon-show="chooseError" data-summon-bind="chooseError"></p>
<p data-summon-show="lastChoice">Saved: <span data-summon-bind="lastChoice"></span></p>`,
},
],
Expand Down
105 changes: 104 additions & 1 deletion examples/surface-gallery/tests/gallery-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,10 @@ test('mocked generation renders and generated host tool requests update host sta
html: `
<article style="padding:24px;font-family:system-ui;">
<h1>Pick a launch path</h1>
<button data-summon-on-click="choose" data-summon-args='{"option":"Balanced path"}'>Save Balanced path</button>
<button data-summon-on-click="choose" data-summon-args='{"option":"Balanced path"}' data-summon-attr-disabled="choosePending">Save Balanced path</button>
<p data-testid="saving" data-summon-show="choosePending">Saving...</p>
<p data-testid="save-error" data-summon-show="chooseError" data-summon-bind="chooseError"></p>
<p data-testid="saved" data-summon-show="chooseDone">Saved.</p>
<p data-summon-show="lastChoice">Saved <span data-summon-bind="lastChoice"></span></p>
</article>`,
},
Expand Down Expand Up @@ -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: `
<section data-summon-resource="search" data-summon-resource-as="s" style="padding:24px;font-family:system-ui;">
<h1>Recipe search</h1>
<form data-summon-resource-trigger="submit">
<input name="query" value="zzzzzz">
<button data-summon-attr-disabled="$s.loading">Search</button>
</form>
<p data-testid="loading" data-summon-show="$s.loading">Searching...</p>
<p data-testid="error" data-summon-show="$s.error" data-summon-bind="$s.error"></p>
<p data-testid="empty" data-summon-show="$s.empty">No recipes found.</p>
<ul data-testid="results" data-summon-show="$s.data" data-summon-foreach="$s.data" data-summon-as="result">
<template><li data-summon-bind="$result.title"></li></template>
</ul>
</section>`,
},
{
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) => {
Expand Down
6 changes: 6 additions & 0 deletions packages/engine/src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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="<pendingKey>"` or show a pending message.'];
case 'action-error-not-rendered':
return ['Add visible host error UI with `data-summon-show="<errorKey>" data-summon-bind="<errorKey>"`.'];
case 'unsafe-attr-binding':
case 'bad-attr-binding-placement':
return ['Use only safe data-summon-attr-* bindings on supported elements.'];
Expand Down
2 changes: 2 additions & 0 deletions packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
89 changes: 89 additions & 0 deletions packages/engine/src/runtime-validator/binding-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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(
Expand Down Expand Up @@ -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[],
Expand Down Expand Up @@ -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<string, RuntimeCapability>): Map<string, ActionUsage> {
const out = new Map<string, ActionUsage>();
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<string, string>,
actionUsages: Map<string, ActionUsage>,
): 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<string, ActionUsage>,
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}`,
),
);
}
}
}

Expand Down
1 change: 1 addition & 0 deletions packages/engine/src/runtime-validator/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export function buildCapabilityMap(context: ValidationContext): Map<string, Runt
kind: capability.kind ?? 'action',
triggers: new Set(triggers),
stateKeys: capability.stateKeys,
actionStateKeys: capability.actionStateKeys,
surface: capability.surface,
});
}
Expand Down
Loading
Loading