@happyvertical/smrt-svelte 0.43.9 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +38 -2
- package/dist/Provider.svelte +11 -0
- package/dist/Provider.svelte.d.ts.map +1 -1
- package/dist/components/forms/Form.svelte +29 -13
- package/dist/components/forms/Form.svelte.d.ts +5 -0
- package/dist/components/forms/Form.svelte.d.ts.map +1 -1
- package/dist/components/forms/__tests__/Form.webmcp.test.js +115 -6
- package/dist/components/settings/SettingsCatalog.svelte +9 -0
- package/dist/components/settings/SettingsCatalog.svelte.d.ts +9 -0
- package/dist/components/settings/SettingsCatalog.svelte.d.ts.map +1 -1
- package/dist/components/workspace/admin-shell/ActivityBadge.svelte +2 -0
- package/dist/components/workspace/admin-shell/ActivityBadge.svelte.d.ts +2 -0
- package/dist/components/workspace/admin-shell/ActivityBadge.svelte.d.ts.map +1 -1
- package/dist/components/workspace/admin-shell/ActivityItem.svelte +1 -0
- package/dist/components/workspace/admin-shell/ActivityItem.svelte.d.ts +1 -0
- package/dist/components/workspace/admin-shell/ActivityItem.svelte.d.ts.map +1 -1
- package/dist/components/workspace/admin-shell/ActivityList.svelte +3 -0
- package/dist/components/workspace/admin-shell/ActivityList.svelte.d.ts +3 -0
- package/dist/components/workspace/admin-shell/ActivityList.svelte.d.ts.map +1 -1
- package/dist/components/workspace/admin-shell/ActivityToasts.svelte +1 -0
- package/dist/components/workspace/admin-shell/ActivityToasts.svelte.d.ts +1 -0
- package/dist/components/workspace/admin-shell/ActivityToasts.svelte.d.ts.map +1 -1
- package/dist/components/workspace/admin-shell/AdminShell.svelte +15 -0
- package/dist/components/workspace/admin-shell/AdminShell.svelte.d.ts +15 -0
- package/dist/components/workspace/admin-shell/AdminShell.svelte.d.ts.map +1 -1
- package/dist/components/workspace/admin-shell/AppScopePanel.svelte +7 -0
- package/dist/components/workspace/admin-shell/AppScopePanel.svelte.d.ts +7 -0
- package/dist/components/workspace/admin-shell/AppScopePanel.svelte.d.ts.map +1 -1
- package/dist/components/workspace/admin-shell/HotkeyInput.svelte +4 -0
- package/dist/components/workspace/admin-shell/HotkeyInput.svelte.d.ts +4 -0
- package/dist/components/workspace/admin-shell/HotkeyInput.svelte.d.ts.map +1 -1
- package/dist/components/workspace/admin-shell/ShellCorner.svelte +1 -0
- package/dist/components/workspace/admin-shell/ShellCorner.svelte.d.ts +1 -0
- package/dist/components/workspace/admin-shell/ShellCorner.svelte.d.ts.map +1 -1
- package/dist/components/workspace/admin-shell/ShortcutsOverlay.svelte +1 -0
- package/dist/components/workspace/admin-shell/ShortcutsOverlay.svelte.d.ts +1 -0
- package/dist/components/workspace/admin-shell/ShortcutsOverlay.svelte.d.ts.map +1 -1
- package/dist/components/workspace/admin-shell/SystemScopePanel.svelte +2 -0
- package/dist/components/workspace/admin-shell/SystemScopePanel.svelte.d.ts +2 -0
- package/dist/components/workspace/admin-shell/SystemScopePanel.svelte.d.ts.map +1 -1
- package/dist/components/workspace/admin-shell/SystemStatusChips.svelte +1 -0
- package/dist/components/workspace/admin-shell/SystemStatusChips.svelte.d.ts +1 -0
- package/dist/components/workspace/admin-shell/SystemStatusChips.svelte.d.ts.map +1 -1
- package/dist/components/workspace/admin-shell/TenantNav.svelte +5 -0
- package/dist/components/workspace/admin-shell/TenantNav.svelte.d.ts +5 -0
- package/dist/components/workspace/admin-shell/TenantNav.svelte.d.ts.map +1 -1
- package/dist/components/workspace/admin-shell/WorkspaceAccountMenu.svelte +11 -0
- package/dist/components/workspace/admin-shell/WorkspaceAccountMenu.svelte.d.ts +11 -0
- package/dist/components/workspace/admin-shell/WorkspaceAccountMenu.svelte.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/web/__tests__/view-intent-provider.fixture.svelte +28 -0
- package/dist/web/__tests__/view-intent-provider.fixture.svelte.d.ts +13 -0
- package/dist/web/__tests__/view-intent-provider.fixture.svelte.d.ts.map +1 -0
- package/dist/web/__tests__/view-intent.fixture.svelte +33 -0
- package/dist/web/__tests__/view-intent.fixture.svelte.d.ts +15 -0
- package/dist/web/__tests__/view-intent.fixture.svelte.d.ts.map +1 -0
- package/dist/web/__tests__/view-intent.intents.d.ts +14 -0
- package/dist/web/__tests__/view-intent.intents.d.ts.map +1 -0
- package/dist/web/__tests__/view-intent.intents.js +38 -0
- package/dist/web/__tests__/view-intent.svelte.test.js +201 -0
- package/dist/web/__tests__/webmcp-composed.child.fixture.svelte +11 -0
- package/dist/web/__tests__/webmcp-composed.child.fixture.svelte.d.ts.map +1 -1
- package/dist/web/__tests__/webmcp-composed.integration.svelte.test.js +4 -0
- package/dist/web/__tests__/webmcp-effects-reactivity.child.fixture.svelte +16 -0
- package/dist/web/__tests__/webmcp-effects-reactivity.child.fixture.svelte.d.ts +19 -0
- package/dist/web/__tests__/webmcp-effects-reactivity.child.fixture.svelte.d.ts.map +1 -0
- package/dist/web/__tests__/webmcp-effects-reactivity.fixture.svelte +11 -0
- package/dist/web/__tests__/webmcp-effects-reactivity.fixture.svelte.d.ts +8 -0
- package/dist/web/__tests__/webmcp-effects-reactivity.fixture.svelte.d.ts.map +1 -0
- package/dist/web/__tests__/webmcp-harness-reactive-samename.svelte +20 -0
- package/dist/web/__tests__/webmcp-harness-reactive-samename.svelte.d.ts +7 -0
- package/dist/web/__tests__/webmcp-harness-reactive-samename.svelte.d.ts.map +1 -0
- package/dist/web/__tests__/webmcp-harness-unannotated.svelte +14 -0
- package/dist/web/__tests__/webmcp-harness-unannotated.svelte.d.ts +19 -0
- package/dist/web/__tests__/webmcp-harness-unannotated.svelte.d.ts.map +1 -0
- package/dist/web/__tests__/webmcp-harness.svelte +4 -0
- package/dist/web/__tests__/webmcp-harness.svelte.d.ts.map +1 -1
- package/dist/web/__tests__/webmcp.test.js +136 -9
- package/dist/web/view-intent.svelte.d.ts +64 -0
- package/dist/web/view-intent.svelte.d.ts.map +1 -0
- package/dist/web/view-intent.svelte.js +83 -0
- package/dist/web/webmcp-bespoke-context.d.ts +14 -0
- package/dist/web/webmcp-bespoke-context.d.ts.map +1 -0
- package/dist/web/webmcp-bespoke-context.js +8 -0
- package/dist/web/webmcp.svelte.d.ts +29 -13
- package/dist/web/webmcp.svelte.d.ts.map +1 -1
- package/dist/web/webmcp.svelte.js +92 -12
- package/package.json +8 -7
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Svelte binding for declarative view intents (#2588).
|
|
3
|
+
*
|
|
4
|
+
* The contract, registry, and compilation are covered in
|
|
5
|
+
* `@happyvertical/smrt-web` (`src/intents.test.ts`). What is proven HERE is
|
|
6
|
+
* the binding: that a declared intent reaches WebMCP through the shared
|
|
7
|
+
* `useWebMcpTool` lifecycle and the Provider's exposure policy, and that
|
|
8
|
+
* executing it moves only mounted registry state.
|
|
9
|
+
*/
|
|
10
|
+
import { createDataSurfaceRegistry } from '@happyvertical/smrt-ui/data';
|
|
11
|
+
import { createControlInteractionRegistry } from '@happyvertical/smrt-ui/forms';
|
|
12
|
+
import { render } from '@testing-library/svelte';
|
|
13
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
14
|
+
const registerBespokeSpy = vi.hoisted(() => vi.fn());
|
|
15
|
+
vi.mock('@happyvertical/smrt-web', async (importOriginal) => {
|
|
16
|
+
const actual = await importOriginal();
|
|
17
|
+
registerBespokeSpy.mockImplementation(actual.registerWebMcpBespokeTool);
|
|
18
|
+
return { ...actual, registerWebMcpBespokeTool: registerBespokeSpy };
|
|
19
|
+
});
|
|
20
|
+
import Fixture from './view-intent.fixture.svelte';
|
|
21
|
+
import { clearNotesIntent, nextPageIntent, revealNotesIntent, stageNotesIntent, } from './view-intent.intents.js';
|
|
22
|
+
import ProviderFixture from './view-intent-provider.fixture.svelte';
|
|
23
|
+
function installModelContext() {
|
|
24
|
+
const registered = [];
|
|
25
|
+
document.modelContext = {
|
|
26
|
+
async registerTool(tool, options) {
|
|
27
|
+
registered.push({ ...tool, signal: options?.signal });
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
return registered;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A Provider that enables the UI adapter also registers the six fixed
|
|
34
|
+
* `smrt_ui_*` tools into the same array, so every Provider-mounted assertion
|
|
35
|
+
* selects by NAME rather than by index or length — the pattern
|
|
36
|
+
* `webmcp-composed.integration.svelte.test.ts` already established.
|
|
37
|
+
*/
|
|
38
|
+
function toolNamed(registered, name) {
|
|
39
|
+
return registered.find((tool) => tool.name === name);
|
|
40
|
+
}
|
|
41
|
+
function mountedControlRegistry() {
|
|
42
|
+
const registry = createControlInteractionRegistry();
|
|
43
|
+
let value = 'before';
|
|
44
|
+
registry.register({
|
|
45
|
+
identity: { formId: 'order-form', controlId: 'notes' },
|
|
46
|
+
metadata: { kind: 'text', label: 'Notes', readable: true, writable: true },
|
|
47
|
+
getValue: () => value,
|
|
48
|
+
setValue: (next) => {
|
|
49
|
+
value = String(next);
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
return registry;
|
|
53
|
+
}
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
document.modelContext = undefined;
|
|
56
|
+
registerBespokeSpy.mockClear();
|
|
57
|
+
vi.restoreAllMocks();
|
|
58
|
+
});
|
|
59
|
+
describe('useViewIntent', () => {
|
|
60
|
+
it('registers a declared intent on mount and deregisters on unmount', async () => {
|
|
61
|
+
const registered = installModelContext();
|
|
62
|
+
const view = render(Fixture, {
|
|
63
|
+
props: {
|
|
64
|
+
intent: revealNotesIntent,
|
|
65
|
+
identity: { formId: 'order-form', controlId: 'notes' },
|
|
66
|
+
controlRegistry: mountedControlRegistry(),
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
await vi.waitFor(() => expect(registered).toHaveLength(1));
|
|
70
|
+
expect(registered[0].name).toBe('fixture_reveal_notes');
|
|
71
|
+
expect(registered[0].annotations).toMatchObject({
|
|
72
|
+
readOnlyHint: true,
|
|
73
|
+
destructiveHint: false,
|
|
74
|
+
idempotentHint: true,
|
|
75
|
+
openWorldHint: false,
|
|
76
|
+
untrustedContentHint: true,
|
|
77
|
+
});
|
|
78
|
+
expect(registered[0].signal?.aborted).toBe(false);
|
|
79
|
+
view.unmount();
|
|
80
|
+
expect(registered[0].signal?.aborted).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
it('routes through registerWebMcpBespokeTool, never document.modelContext directly', async () => {
|
|
83
|
+
installModelContext();
|
|
84
|
+
render(Fixture, {
|
|
85
|
+
props: {
|
|
86
|
+
intent: revealNotesIntent,
|
|
87
|
+
identity: { formId: 'order-form', controlId: 'notes' },
|
|
88
|
+
controlRegistry: mountedControlRegistry(),
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
await vi.waitFor(() => expect(registerBespokeSpy).toHaveBeenCalledTimes(1));
|
|
92
|
+
expect(registerBespokeSpy.mock.calls[0][0]).toMatchObject({
|
|
93
|
+
name: 'fixture_reveal_notes',
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
it('resolves the mounted registries from the nearest Provider', async () => {
|
|
97
|
+
const registered = installModelContext();
|
|
98
|
+
const registry = mountedControlRegistry();
|
|
99
|
+
render(ProviderFixture, {
|
|
100
|
+
props: {
|
|
101
|
+
intent: revealNotesIntent,
|
|
102
|
+
identity: { formId: 'order-form', controlId: 'notes' },
|
|
103
|
+
providerRegistry: registry,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
await vi.waitFor(() => expect(toolNamed(registered, 'fixture_reveal_notes')).toBeDefined());
|
|
107
|
+
});
|
|
108
|
+
it("applies the Provider's effects policy to an intent", async () => {
|
|
109
|
+
const registered = installModelContext();
|
|
110
|
+
// `clearNotesIntent` declares no capability, so it classifies
|
|
111
|
+
// fail-closed as destructive and a read-only Provider excludes it.
|
|
112
|
+
render(ProviderFixture, {
|
|
113
|
+
props: {
|
|
114
|
+
intent: clearNotesIntent,
|
|
115
|
+
identity: { formId: 'order-form', controlId: 'notes' },
|
|
116
|
+
providerRegistry: mountedControlRegistry(),
|
|
117
|
+
effects: ['read'],
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
await vi.waitFor(() => expect(registerBespokeSpy).toHaveBeenCalledTimes(1));
|
|
121
|
+
expect(registerBespokeSpy.mock.calls[0][1]).toEqual({ effects: ['read'] });
|
|
122
|
+
// The six fixed `smrt_ui_*` tools register alongside it; only the intent
|
|
123
|
+
// must be absent.
|
|
124
|
+
expect(toolNamed(registered, 'fixture_clear_notes')).toBeUndefined();
|
|
125
|
+
});
|
|
126
|
+
it('executes by dispatching one agent-sourced registry command', async () => {
|
|
127
|
+
const registered = installModelContext();
|
|
128
|
+
const registry = mountedControlRegistry();
|
|
129
|
+
const executeSpy = vi.spyOn(registry, 'execute');
|
|
130
|
+
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
131
|
+
render(ProviderFixture, {
|
|
132
|
+
props: {
|
|
133
|
+
intent: stageNotesIntent,
|
|
134
|
+
identity: { formId: 'order-form', controlId: 'notes' },
|
|
135
|
+
providerRegistry: registry,
|
|
136
|
+
effects: ['read', 'write'],
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
await vi.waitFor(() => expect(toolNamed(registered, 'fixture_stage_notes')).toBeDefined());
|
|
140
|
+
const tool = toolNamed(registered, 'fixture_stage_notes');
|
|
141
|
+
if (!tool)
|
|
142
|
+
throw new Error('intent tool was not registered');
|
|
143
|
+
await tool.execute({ value: 'after' });
|
|
144
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
145
|
+
const staged = executeSpy.mock.calls.find(([command]) => command.action === 'stage');
|
|
146
|
+
if (!staged)
|
|
147
|
+
throw new Error('intent did not dispatch a stage command');
|
|
148
|
+
expect(staged[0]).toEqual({
|
|
149
|
+
action: 'stage',
|
|
150
|
+
identity: { formId: 'order-form', controlId: 'notes' },
|
|
151
|
+
value: 'after',
|
|
152
|
+
});
|
|
153
|
+
// Agent-staged values stay proposals: the command is dispatched as
|
|
154
|
+
// `source: 'agent'`, so StagedControlReview and the local-gesture
|
|
155
|
+
// requirement apply unchanged.
|
|
156
|
+
expect(staged[1]).toEqual({ source: 'agent' });
|
|
157
|
+
});
|
|
158
|
+
it('binds a data-surface intent to a mounted surface identity', async () => {
|
|
159
|
+
const registered = installModelContext();
|
|
160
|
+
const registry = createDataSurfaceRegistry();
|
|
161
|
+
const executeSpy = vi.spyOn(registry, 'execute');
|
|
162
|
+
render(Fixture, {
|
|
163
|
+
props: {
|
|
164
|
+
intent: nextPageIntent,
|
|
165
|
+
identity: { surfaceId: 'orders-table', kind: 'table' },
|
|
166
|
+
dataSurfaceRegistry: registry,
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
await vi.waitFor(() => expect(registered).toHaveLength(1));
|
|
170
|
+
expect(registered[0].name).toBe('fixture_next_page');
|
|
171
|
+
// Nothing is mounted under that identity, so the intent reports
|
|
172
|
+
// not_found rather than inventing a command.
|
|
173
|
+
expect(JSON.parse(String(await registered[0].execute({})))).toEqual({
|
|
174
|
+
ok: false,
|
|
175
|
+
reason: 'not_found',
|
|
176
|
+
});
|
|
177
|
+
expect(executeSpy).not.toHaveBeenCalled();
|
|
178
|
+
});
|
|
179
|
+
it('is a no-op with no Provider and no explicit registry', async () => {
|
|
180
|
+
const registered = installModelContext();
|
|
181
|
+
render(Fixture, {
|
|
182
|
+
props: {
|
|
183
|
+
intent: revealNotesIntent,
|
|
184
|
+
identity: { formId: 'order-form', controlId: 'notes' },
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
188
|
+
expect(registered).toHaveLength(0);
|
|
189
|
+
expect(registerBespokeSpy).not.toHaveBeenCalled();
|
|
190
|
+
});
|
|
191
|
+
it('rejects an identity that does not match the declared target registry', () => {
|
|
192
|
+
installModelContext();
|
|
193
|
+
expect(() => render(Fixture, {
|
|
194
|
+
props: {
|
|
195
|
+
intent: revealNotesIntent,
|
|
196
|
+
identity: { surfaceId: 'orders-table', kind: 'table' },
|
|
197
|
+
controlRegistry: mountedControlRegistry(),
|
|
198
|
+
},
|
|
199
|
+
})).toThrow(/needs a \{ formId, controlId \} identity/);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -20,6 +20,17 @@ useWebMcpTool(() => ({
|
|
|
20
20
|
execute: () => JSON.stringify({ ok: true, source: 'bespoke-component' }),
|
|
21
21
|
}));
|
|
22
22
|
|
|
23
|
+
// No `annotations`: fail-closed classifies this destructive (#2586), so it
|
|
24
|
+
// must be excluded under the Provider's default read-only exposure policy
|
|
25
|
+
// and must never reach document.modelContext.registerTool.
|
|
26
|
+
useWebMcpTool(() => ({
|
|
27
|
+
name: 'fixture_component_unannotated',
|
|
28
|
+
description: 'A bespoke tool with no declared effect.',
|
|
29
|
+
inputSchema: { type: 'object', properties: {} },
|
|
30
|
+
execute: () =>
|
|
31
|
+
JSON.stringify({ ok: true, source: 'bespoke-component-unannotated' }),
|
|
32
|
+
}));
|
|
33
|
+
|
|
23
34
|
const tableDescriptor: DataSurfaceDescriptor = {
|
|
24
35
|
version: 1,
|
|
25
36
|
identity: { surfaceId: 'fixture-items', kind: 'table' },
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"webmcp-composed.child.fixture.svelte.d.ts","sourceRoot":"","sources":["../../../src/web/__tests__/webmcp-composed.child.fixture.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,6BAA6B,CAAC;AAStE,KAAK,gBAAgB,GAAI;IAAE,mBAAmB,EAAE,mBAAmB,CAAA;CAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"webmcp-composed.child.fixture.svelte.d.ts","sourceRoot":"","sources":["../../../src/web/__tests__/webmcp-composed.child.fixture.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,6BAA6B,CAAC;AAStE,KAAK,gBAAgB,GAAI;IAAE,mBAAmB,EAAE,mBAAmB,CAAA;CAAE,CAAC;AAoEvE,QAAA,MAAM,cAAc,sDAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
|
|
@@ -204,6 +204,10 @@ describe('composed Provider WebMCP surface (#2523)', () => {
|
|
|
204
204
|
ok: true,
|
|
205
205
|
source: 'bespoke-component',
|
|
206
206
|
});
|
|
207
|
+
// #2586: a bespoke tool with no declared effect classifies destructive
|
|
208
|
+
// and is excluded under the Provider's default read-only policy — it
|
|
209
|
+
// must never reach document.modelContext.registerTool.
|
|
210
|
+
expect(registered.some((tool) => tool.name === 'fixture_component_unannotated')).toBe(false);
|
|
207
211
|
await view.rerender({
|
|
208
212
|
dataSurfaceRegistry,
|
|
209
213
|
generatedDefinitions,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { useWebMcpTool } from '../webmcp.svelte.js';
|
|
3
|
+
|
|
4
|
+
// Declared destructive so it registers only under a Provider policy that
|
|
5
|
+
// explicitly allows `destructive` — used to prove a later Provider policy
|
|
6
|
+
// change is observed by `useWebMcpTool`'s effect (#2586 F2).
|
|
7
|
+
useWebMcpTool(() => ({
|
|
8
|
+
name: 'reactivity_destructive_tool',
|
|
9
|
+
description: 'Destructive bespoke tool for Provider effects reactivity',
|
|
10
|
+
inputSchema: { type: 'object', properties: {} },
|
|
11
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
12
|
+
execute: () => 'destroyed',
|
|
13
|
+
}));
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<span>reactivity-destructive-tool</span>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
|
|
2
|
+
new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
|
|
3
|
+
$$bindings?: Bindings;
|
|
4
|
+
} & Exports;
|
|
5
|
+
(internal: unknown, props: {
|
|
6
|
+
$$events?: Events;
|
|
7
|
+
$$slots?: Slots;
|
|
8
|
+
}): Exports & {
|
|
9
|
+
$set?: any;
|
|
10
|
+
$on?: any;
|
|
11
|
+
};
|
|
12
|
+
z_$$bindings?: Bindings;
|
|
13
|
+
}
|
|
14
|
+
declare const WebmcpEffectsReactivity: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
|
|
15
|
+
[evt: string]: CustomEvent<any>;
|
|
16
|
+
}, {}, {}, string>;
|
|
17
|
+
type WebmcpEffectsReactivity = InstanceType<typeof WebmcpEffectsReactivity>;
|
|
18
|
+
export default WebmcpEffectsReactivity;
|
|
19
|
+
//# sourceMappingURL=webmcp-effects-reactivity.child.fixture.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webmcp-effects-reactivity.child.fixture.svelte.d.ts","sourceRoot":"","sources":["../../../src/web/__tests__/webmcp-effects-reactivity.child.fixture.svelte.ts"],"names":[],"mappings":"AAwBA,UAAU,kCAAkC,CAAC,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,OAAO,GAAG,EAAE,EAAE,QAAQ,GAAG,MAAM;IACpM,KAAK,OAAO,EAAE,OAAO,QAAQ,EAAE,2BAA2B,CAAC,KAAK,CAAC,GAAG,OAAO,QAAQ,EAAE,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,GAAG;QAAE,UAAU,CAAC,EAAE,QAAQ,CAAA;KAAE,GAAG,OAAO,CAAC;IACjK,CAAC,QAAQ,EAAE,OAAO,EAAE,KAAK,EAAE;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,KAAK,CAAA;KAAC,GAAG,OAAO,GAAG;QAAE,IAAI,CAAC,EAAE,GAAG,CAAC;QAAC,GAAG,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC;IACtG,YAAY,CAAC,EAAE,QAAQ,CAAC;CAC3B;AAKD,QAAA,MAAM,uBAAuB;;kBAA+E,CAAC;AAC3F,KAAK,uBAAuB,GAAG,YAAY,CAAC,OAAO,uBAAuB,CAAC,CAAC;AAC9E,eAAe,uBAAuB,CAAC"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { WebMcpToolEffect } from '@happyvertical/smrt-web';
|
|
3
|
+
import Provider from '../../Provider.svelte';
|
|
4
|
+
import ReactivityChild from './webmcp-effects-reactivity.child.fixture.svelte';
|
|
5
|
+
|
|
6
|
+
let { effects }: { effects: readonly WebMcpToolEffect[] } = $props();
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<Provider webmcp={{ definitions: [], effects }}>
|
|
10
|
+
<ReactivityChild />
|
|
11
|
+
</Provider>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { WebMcpToolEffect } from '@happyvertical/smrt-web';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
effects: readonly WebMcpToolEffect[];
|
|
4
|
+
};
|
|
5
|
+
declare const WebmcpEffectsReactivity: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
6
|
+
type WebmcpEffectsReactivity = ReturnType<typeof WebmcpEffectsReactivity>;
|
|
7
|
+
export default WebmcpEffectsReactivity;
|
|
8
|
+
//# sourceMappingURL=webmcp-effects-reactivity.fixture.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webmcp-effects-reactivity.fixture.svelte.d.ts","sourceRoot":"","sources":["../../../src/web/__tests__/webmcp-effects-reactivity.fixture.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAI/D,KAAK,gBAAgB,GAAI;IAAE,OAAO,EAAE,SAAS,gBAAgB,EAAE,CAAA;CAAE,CAAC;AAenE,QAAA,MAAM,uBAAuB,sDAAwC,CAAC;AACtE,KAAK,uBAAuB,GAAG,UAAU,CAAC,OAAO,uBAAuB,CAAC,CAAC;AAC1E,eAAe,uBAAuB,CAAC"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { useWebMcpTool } from '../webmcp.svelte.js';
|
|
3
|
+
|
|
4
|
+
// Same registered NAME on every render; only `description` changes, so each
|
|
5
|
+
// prop change still replaces the spec object and reruns useWebMcpTool's
|
|
6
|
+
// effect (#2586 F2's reactive `bespokeContext.effects` read applies to any
|
|
7
|
+
// reactive spec, not only one driven by Provider policy) while exercising
|
|
8
|
+
// same-name re-registration serialization.
|
|
9
|
+
let { version = 1 }: { version?: number } = $props();
|
|
10
|
+
|
|
11
|
+
useWebMcpTool(() => ({
|
|
12
|
+
name: 'harness_reactive_tool',
|
|
13
|
+
description: `Reactive tool v${version}`,
|
|
14
|
+
inputSchema: { type: 'object' },
|
|
15
|
+
annotations: { readOnlyHint: true },
|
|
16
|
+
execute: () => String(version),
|
|
17
|
+
}));
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<span>{version}</span>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
type $$ComponentProps = {
|
|
2
|
+
version?: number;
|
|
3
|
+
};
|
|
4
|
+
declare const WebmcpHarnessReactiveSamename: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
5
|
+
type WebmcpHarnessReactiveSamename = ReturnType<typeof WebmcpHarnessReactiveSamename>;
|
|
6
|
+
export default WebmcpHarnessReactiveSamename;
|
|
7
|
+
//# sourceMappingURL=webmcp-harness-reactive-samename.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webmcp-harness-reactive-samename.svelte.d.ts","sourceRoot":"","sources":["../../../src/web/__tests__/webmcp-harness-reactive-samename.svelte.ts"],"names":[],"mappings":"AAKC,KAAK,gBAAgB,GAAI;IAAE,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAwB/C,QAAA,MAAM,6BAA6B,sDAAwC,CAAC;AAC5E,KAAK,6BAA6B,GAAG,UAAU,CAAC,OAAO,6BAA6B,CAAC,CAAC;AACtF,eAAe,6BAA6B,CAAC"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { useWebMcpTool } from '../webmcp.svelte.js';
|
|
3
|
+
|
|
4
|
+
// No `annotations`: fail-closed classifies this destructive, so it must be
|
|
5
|
+
// excluded under the registrar's default read-only exposure policy (#2586).
|
|
6
|
+
useWebMcpTool(() => ({
|
|
7
|
+
name: 'harness_tool_unannotated',
|
|
8
|
+
description: 'A lifecycle test tool with no declared effect',
|
|
9
|
+
inputSchema: { type: 'object' },
|
|
10
|
+
execute: () => 'unannotated',
|
|
11
|
+
}));
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<span>unannotated</span>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
|
|
2
|
+
new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
|
|
3
|
+
$$bindings?: Bindings;
|
|
4
|
+
} & Exports;
|
|
5
|
+
(internal: unknown, props: {
|
|
6
|
+
$$events?: Events;
|
|
7
|
+
$$slots?: Slots;
|
|
8
|
+
}): Exports & {
|
|
9
|
+
$set?: any;
|
|
10
|
+
$on?: any;
|
|
11
|
+
};
|
|
12
|
+
z_$$bindings?: Bindings;
|
|
13
|
+
}
|
|
14
|
+
declare const WebmcpHarnessUnannotated: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
|
|
15
|
+
[evt: string]: CustomEvent<any>;
|
|
16
|
+
}, {}, {}, string>;
|
|
17
|
+
type WebmcpHarnessUnannotated = InstanceType<typeof WebmcpHarnessUnannotated>;
|
|
18
|
+
export default WebmcpHarnessUnannotated;
|
|
19
|
+
//# sourceMappingURL=webmcp-harness-unannotated.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webmcp-harness-unannotated.svelte.d.ts","sourceRoot":"","sources":["../../../src/web/__tests__/webmcp-harness-unannotated.svelte.ts"],"names":[],"mappings":"AAsBA,UAAU,kCAAkC,CAAC,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,OAAO,GAAG,EAAE,EAAE,QAAQ,GAAG,MAAM;IACpM,KAAK,OAAO,EAAE,OAAO,QAAQ,EAAE,2BAA2B,CAAC,KAAK,CAAC,GAAG,OAAO,QAAQ,EAAE,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,GAAG;QAAE,UAAU,CAAC,EAAE,QAAQ,CAAA;KAAE,GAAG,OAAO,CAAC;IACjK,CAAC,QAAQ,EAAE,OAAO,EAAE,KAAK,EAAE;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,KAAK,CAAA;KAAC,GAAG,OAAO,GAAG;QAAE,IAAI,CAAC,EAAE,GAAG,CAAC;QAAC,GAAG,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC;IACtG,YAAY,CAAC,EAAE,QAAQ,CAAC;CAC3B;AAKD,QAAA,MAAM,wBAAwB;;kBAA+E,CAAC;AAC5F,KAAK,wBAAwB,GAAG,YAAY,CAAC,OAAO,wBAAwB,CAAC,CAAC;AAChF,eAAe,wBAAwB,CAAC"}
|
|
@@ -7,6 +7,10 @@ useWebMcpTool(() => ({
|
|
|
7
7
|
name: `harness_tool_${version}`,
|
|
8
8
|
description: 'A lifecycle test tool',
|
|
9
9
|
inputSchema: { type: 'object' },
|
|
10
|
+
// Declared read-only so this lifecycle fixture registers under the
|
|
11
|
+
// registrar's default read-only exposure policy (#2586). An undeclared
|
|
12
|
+
// bespoke tool is excluded by default — see the "unannotated" test below.
|
|
13
|
+
annotations: { readOnlyHint: true },
|
|
10
14
|
execute: () => String(version),
|
|
11
15
|
}));
|
|
12
16
|
</script>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"webmcp-harness.svelte.d.ts","sourceRoot":"","sources":["../../../src/web/__tests__/webmcp-harness.svelte.ts"],"names":[],"mappings":"AAKC,KAAK,gBAAgB,GAAI;IAAE,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"webmcp-harness.svelte.d.ts","sourceRoot":"","sources":["../../../src/web/__tests__/webmcp-harness.svelte.ts"],"names":[],"mappings":"AAKC,KAAK,gBAAgB,GAAI;IAAE,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAsB/C,QAAA,MAAM,aAAa,sDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
|
|
@@ -1,7 +1,45 @@
|
|
|
1
1
|
import { render } from '@testing-library/svelte';
|
|
2
2
|
import { tick } from 'svelte';
|
|
3
|
-
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
// Wrap the registrar in a spy so a test can observe *which* policy each
|
|
5
|
+
// `useWebMcpTool` effect run handed it, including runs the policy excludes
|
|
6
|
+
// before they ever reach document.modelContext.registerTool (#2599).
|
|
7
|
+
const registerBespokeSpy = vi.hoisted(() => vi.fn());
|
|
8
|
+
vi.mock('@happyvertical/smrt-web', async (importOriginal) => {
|
|
9
|
+
const actual = await importOriginal();
|
|
10
|
+
registerBespokeSpy.mockImplementation(actual.registerWebMcpBespokeTool);
|
|
11
|
+
return { ...actual, registerWebMcpBespokeTool: registerBespokeSpy };
|
|
12
|
+
});
|
|
13
|
+
import EffectsReactivity from './webmcp-effects-reactivity.fixture.svelte';
|
|
4
14
|
import Harness from './webmcp-harness.svelte';
|
|
15
|
+
import HarnessReactiveSameName from './webmcp-harness-reactive-samename.svelte';
|
|
16
|
+
import HarnessUnannotated from './webmcp-harness-unannotated.svelte';
|
|
17
|
+
/**
|
|
18
|
+
* Mimics a host that rejects a duplicate tool name while it still considers
|
|
19
|
+
* an earlier registration of that name live, and only forgets that name one
|
|
20
|
+
* microtask after the earlier registration's abort signal fires — modeling
|
|
21
|
+
* a host that does not process deregistration perfectly synchronously with
|
|
22
|
+
* `AbortController.abort()`.
|
|
23
|
+
*/
|
|
24
|
+
function installCollisionAwareModelContext() {
|
|
25
|
+
const registered = [];
|
|
26
|
+
const liveNames = new Set();
|
|
27
|
+
document.modelContext = {
|
|
28
|
+
async registerTool(tool, options) {
|
|
29
|
+
if (liveNames.has(tool.name)) {
|
|
30
|
+
throw new Error(`host collision: ${tool.name}`);
|
|
31
|
+
}
|
|
32
|
+
liveNames.add(tool.name);
|
|
33
|
+
registered.push({ name: tool.name, signal: options?.signal });
|
|
34
|
+
options?.signal?.addEventListener('abort', () => {
|
|
35
|
+
void Promise.resolve().then(() => {
|
|
36
|
+
liveNames.delete(tool.name);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
return { registered, liveNames };
|
|
42
|
+
}
|
|
5
43
|
function installModelContext() {
|
|
6
44
|
const registered = [];
|
|
7
45
|
document.modelContext = {
|
|
@@ -13,18 +51,19 @@ function installModelContext() {
|
|
|
13
51
|
}
|
|
14
52
|
afterEach(() => {
|
|
15
53
|
delete document.modelContext;
|
|
54
|
+
registerBespokeSpy.mockClear();
|
|
16
55
|
});
|
|
17
56
|
describe('useWebMcpTool', () => {
|
|
18
57
|
it('registers on mount, aborts on spec changes, and aborts on unmount', async () => {
|
|
19
58
|
const registered = installModelContext();
|
|
20
59
|
const view = render(Harness, { props: { version: 1 } });
|
|
21
|
-
|
|
22
|
-
|
|
60
|
+
// Registration now routes through a dynamically imported registrar
|
|
61
|
+
// (#2586), so it lands one or more microtasks after mount.
|
|
62
|
+
await vi.waitFor(() => expect(registered).toHaveLength(1));
|
|
23
63
|
expect(registered[0].name).toBe('harness_tool_1');
|
|
24
64
|
expect(registered[0].signal?.aborted).toBe(false);
|
|
25
65
|
await view.rerender({ version: 2 });
|
|
26
|
-
await
|
|
27
|
-
expect(registered).toHaveLength(2);
|
|
66
|
+
await vi.waitFor(() => expect(registered).toHaveLength(2));
|
|
28
67
|
expect(registered[0].signal?.aborted).toBe(true);
|
|
29
68
|
expect(registered[1].name).toBe('harness_tool_2');
|
|
30
69
|
view.unmount();
|
|
@@ -42,12 +81,10 @@ describe('useWebMcpTool', () => {
|
|
|
42
81
|
},
|
|
43
82
|
};
|
|
44
83
|
const view = render(Harness);
|
|
45
|
-
await
|
|
84
|
+
await vi.waitFor(() => expect(signal).toBeDefined());
|
|
46
85
|
expect(signal?.aborted).toBe(false);
|
|
47
86
|
rejectRegistration?.(new Error('host collision'));
|
|
48
|
-
await
|
|
49
|
-
await Promise.resolve();
|
|
50
|
-
expect(signal?.aborted).toBe(true);
|
|
87
|
+
await vi.waitFor(() => expect(signal?.aborted).toBe(true));
|
|
51
88
|
view.unmount();
|
|
52
89
|
});
|
|
53
90
|
it('is a no-op when WebMCP is unavailable', async () => {
|
|
@@ -56,4 +93,94 @@ describe('useWebMcpTool', () => {
|
|
|
56
93
|
expect(view.container).toHaveTextContent('1');
|
|
57
94
|
view.unmount();
|
|
58
95
|
});
|
|
96
|
+
it('excludes an undeclared tool under the default read-only exposure policy', async () => {
|
|
97
|
+
const registered = installModelContext();
|
|
98
|
+
const view = render(HarnessUnannotated);
|
|
99
|
+
// No Provider ancestor: the hook falls back to the registrar's default
|
|
100
|
+
// read-only policy. An undeclared spec classifies destructive (#2586)
|
|
101
|
+
// and must never reach document.modelContext.registerTool.
|
|
102
|
+
await tick();
|
|
103
|
+
await tick();
|
|
104
|
+
await tick();
|
|
105
|
+
expect(registered).toHaveLength(0);
|
|
106
|
+
view.unmount();
|
|
107
|
+
expect(registered).toHaveLength(0);
|
|
108
|
+
});
|
|
109
|
+
it('disposes a registered destructive bespoke tool when a Provider policy tightens from destructive to read (#2586 F2)', async () => {
|
|
110
|
+
const registered = installModelContext();
|
|
111
|
+
const view = render(EffectsReactivity, {
|
|
112
|
+
props: { effects: ['destructive'] },
|
|
113
|
+
});
|
|
114
|
+
await vi.waitFor(() => expect(registered.map((tool) => tool.name)).toContain('reactivity_destructive_tool'));
|
|
115
|
+
expect(registered).toHaveLength(1);
|
|
116
|
+
const first = registered[0];
|
|
117
|
+
expect(first.signal?.aborted).toBe(false);
|
|
118
|
+
// `bespokeContext.effects` is read synchronously at the top of the
|
|
119
|
+
// `$effect` body (the F2 fix), so this Provider policy change is
|
|
120
|
+
// tracked as a dependency and re-runs the effect: the previously
|
|
121
|
+
// registered destructive tool is torn down, and re-registration under
|
|
122
|
+
// the new read-only policy is excluded before it ever reaches
|
|
123
|
+
// document.modelContext.registerTool again.
|
|
124
|
+
await view.rerender({ effects: ['read'] });
|
|
125
|
+
await vi.waitFor(() => expect(first.signal?.aborted).toBe(true));
|
|
126
|
+
expect(registered).toHaveLength(1);
|
|
127
|
+
view.unmount();
|
|
128
|
+
});
|
|
129
|
+
it('serializes same-name re-registration across rapid reactive reruns without a host collision', async () => {
|
|
130
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
131
|
+
const { registered, liveNames } = installCollisionAwareModelContext();
|
|
132
|
+
const view = render(HarnessReactiveSameName, { props: { version: 1 } });
|
|
133
|
+
await vi.waitFor(() => expect(registered.length).toBeGreaterThan(0));
|
|
134
|
+
// Three reactive reruns in quick succession, all under the SAME tool
|
|
135
|
+
// name. Without serializing against the previous run's registration
|
|
136
|
+
// settling, a new run's registration attempt can reach the host before
|
|
137
|
+
// it has processed the previous run's abort, producing a host
|
|
138
|
+
// collision — this must never happen.
|
|
139
|
+
await view.rerender({ version: 2 });
|
|
140
|
+
await view.rerender({ version: 3 });
|
|
141
|
+
await view.rerender({ version: 4 });
|
|
142
|
+
await vi.waitFor(() => {
|
|
143
|
+
const live = registered.filter((tool) => !tool.signal?.aborted);
|
|
144
|
+
expect(live).toHaveLength(1);
|
|
145
|
+
expect(live[0]?.name).toBe('harness_reactive_tool');
|
|
146
|
+
});
|
|
147
|
+
expect(liveNames.has('harness_reactive_tool')).toBe(true);
|
|
148
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
149
|
+
view.unmount();
|
|
150
|
+
warnSpy.mockRestore();
|
|
151
|
+
});
|
|
152
|
+
it('registers a previously-excluded bespoke tool when a Provider policy loosens to permit it (#2599)', async () => {
|
|
153
|
+
const registered = installModelContext();
|
|
154
|
+
const view = render(EffectsReactivity, {
|
|
155
|
+
props: { effects: ['read'] },
|
|
156
|
+
});
|
|
157
|
+
// The bespoke tool's static factory declares `destructiveHint: true` and
|
|
158
|
+
// has no rune dependencies of its own, so under a read-only Provider
|
|
159
|
+
// policy the registrar classifies it destructive and excludes it before
|
|
160
|
+
// it ever reaches document.modelContext.registerTool. Wait for that
|
|
161
|
+
// first evaluation to actually happen under the read-only policy — the
|
|
162
|
+
// registrar is reached through a dynamic import, so without this the
|
|
163
|
+
// policy change below could race ahead of the first evaluation and the
|
|
164
|
+
// test would pass even if the effect never re-ran.
|
|
165
|
+
await vi.waitFor(() => expect(registerBespokeSpy).toHaveBeenCalledTimes(1));
|
|
166
|
+
expect(registerBespokeSpy.mock.calls[0][1]).toEqual({
|
|
167
|
+
effects: ['read'],
|
|
168
|
+
});
|
|
169
|
+
expect(registered).toHaveLength(0);
|
|
170
|
+
// `bespokeContext.effects` is read synchronously at the top of the
|
|
171
|
+
// `$effect` body, so this Provider policy change is tracked as a
|
|
172
|
+
// dependency and re-runs the effect even though the component's own
|
|
173
|
+
// factory never changed: the registrar is consulted again under the
|
|
174
|
+
// loosened policy and the tool newly registers.
|
|
175
|
+
await view.rerender({ effects: ['destructive'] });
|
|
176
|
+
await vi.waitFor(() => expect(registerBespokeSpy).toHaveBeenCalledTimes(2));
|
|
177
|
+
expect(registerBespokeSpy.mock.calls[1][1]).toEqual({
|
|
178
|
+
effects: ['destructive'],
|
|
179
|
+
});
|
|
180
|
+
await vi.waitFor(() => expect(registered.map((tool) => tool.name)).toContain('reactivity_destructive_tool'));
|
|
181
|
+
expect(registered).toHaveLength(1);
|
|
182
|
+
expect(registered[0].signal?.aborted).toBe(false);
|
|
183
|
+
view.unmount();
|
|
184
|
+
expect(registered[0].signal?.aborted).toBe(true);
|
|
185
|
+
});
|
|
59
186
|
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { DataSurfaceRegistry } from '@happyvertical/smrt-ui/data';
|
|
2
|
+
import type { ControlInteractionRegistry } from '@happyvertical/smrt-ui/forms';
|
|
3
|
+
import type { ViewIntent, ViewIntentControlIdentity, ViewIntentDataSurfaceIdentity, WebMcpToolEffect } from '@happyvertical/smrt-web';
|
|
4
|
+
/**
|
|
5
|
+
* The mounted registry identity an intent addresses. Both members carry the
|
|
6
|
+
* optional `subject`, so a rich form can bind an intent to the right record
|
|
7
|
+
* when several controls share a `formId`/`controlId`.
|
|
8
|
+
*/
|
|
9
|
+
export type ViewIntentIdentity = ViewIntentControlIdentity | ViewIntentDataSurfaceIdentity;
|
|
10
|
+
export interface UseViewIntentOptions {
|
|
11
|
+
/**
|
|
12
|
+
* The mounted identity this intent is bound to for the component's
|
|
13
|
+
* lifetime. Shape must match the intent's declared target registry:
|
|
14
|
+
* `{ formId, controlId, subject? }` for a control intent,
|
|
15
|
+
* `{ surfaceId, kind, subject? }` for a data-surface intent.
|
|
16
|
+
*/
|
|
17
|
+
identity: ViewIntentIdentity;
|
|
18
|
+
/**
|
|
19
|
+
* `effects` exposure-policy fallback, used only when no Provider ancestor
|
|
20
|
+
* supplies an explicit `webmcp.effects` policy — see
|
|
21
|
+
* {@link useWebMcpTool}. A Provider's explicit policy always wins.
|
|
22
|
+
*/
|
|
23
|
+
effects?: readonly WebMcpToolEffect[];
|
|
24
|
+
/** Override the Provider's control registry (tests, nested hosts). */
|
|
25
|
+
controlRegistry?: ControlInteractionRegistry;
|
|
26
|
+
/** Override the Provider's data-surface registry. */
|
|
27
|
+
dataSurfaceRegistry?: DataSurfaceRegistry;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Bind a declared view intent (#2588) to this component's mounted registry
|
|
31
|
+
* identity, for exactly this component's lifetime.
|
|
32
|
+
*
|
|
33
|
+
* Svelte is the FIRST binding, not the only one: the declaration contract,
|
|
34
|
+
* the registry, and the compilation to a browser tool all live in
|
|
35
|
+
* `@happyvertical/smrt-web` with no Svelte dependency. This file is the thin
|
|
36
|
+
* part — it resolves the mounted registries from the nearest Provider and
|
|
37
|
+
* hands the compiled spec to {@link useWebMcpTool}, which already owns the
|
|
38
|
+
* WebMCP lifecycle: the synchronous Provider policy read, the `options.effects`
|
|
39
|
+
* fallback, serialized same-name re-registration across effect reruns, and
|
|
40
|
+
* the lazy `@happyvertical/smrt-web` import.
|
|
41
|
+
*
|
|
42
|
+
* Registration therefore goes through `registerWebMcpBespokeTool`, so an
|
|
43
|
+
* intent is subject to the same fail-closed exposure policy as a generated
|
|
44
|
+
* model tool. Execution dispatches exactly one registry command with
|
|
45
|
+
* `source: 'agent'` — `StagedControlReview` stays on the path, agent-staged
|
|
46
|
+
* values remain proposals, and there is no path to REST.
|
|
47
|
+
*
|
|
48
|
+
* Declare the intent itself at module scope in a `.ts` sidecar
|
|
49
|
+
* (`Foo.intents.ts`) so #2591's scanner can emit it; import it here.
|
|
50
|
+
*
|
|
51
|
+
* With no Provider ancestor and no explicit registry override, this is a
|
|
52
|
+
* silent no-op — an intent with nothing mounted to dispatch to registers
|
|
53
|
+
* nothing, the same way every WebMCP entry point no-ops off-WebMCP. Because
|
|
54
|
+
* the registries are resolved INSIDE the tool factory, a Provider that
|
|
55
|
+
* enables `webmcp.ui` after this component initializes, or that swaps a
|
|
56
|
+
* registry, re-runs the effect and registers the intent against the current
|
|
57
|
+
* one rather than staying stuck on what was there at init.
|
|
58
|
+
*
|
|
59
|
+
* @throws if `identity` does not match the intent's declared target registry,
|
|
60
|
+
* or contradicts an identity the declaration pinned — both are author errors,
|
|
61
|
+
* not environment differences.
|
|
62
|
+
*/
|
|
63
|
+
export declare function useViewIntent(intent: ViewIntent, options: UseViewIntentOptions): void;
|
|
64
|
+
//# sourceMappingURL=view-intent.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"view-intent.svelte.d.ts","sourceRoot":"","sources":["../../src/web/view-intent.svelte.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,6BAA6B,CAAC;AACvE,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,8BAA8B,CAAC;AAC/E,OAAO,KAAK,EACV,UAAU,EAEV,yBAAyB,EACzB,6BAA6B,EAC7B,gBAAgB,EACjB,MAAM,yBAAyB,CAAC;AASjC;;;;GAIG;AACH,MAAM,MAAM,kBAAkB,GAC1B,yBAAyB,GACzB,6BAA6B,CAAC;AAElC,MAAM,WAAW,oBAAoB;IACnC;;;;;OAKG;IACH,QAAQ,EAAE,kBAAkB,CAAC;IAC7B;;;;OAIG;IACH,OAAO,CAAC,EAAE,SAAS,gBAAgB,EAAE,CAAC;IACtC,sEAAsE;IACtE,eAAe,CAAC,EAAE,0BAA0B,CAAC;IAC7C,qDAAqD;IACrD,mBAAmB,CAAC,EAAE,mBAAmB,CAAC;CAC3C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,UAAU,EAClB,OAAO,EAAE,oBAAoB,GAC5B,IAAI,CA8BN"}
|