@happyvertical/smrt-svelte 0.43.10 → 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.
Files changed (90) hide show
  1. package/AGENTS.md +38 -2
  2. package/dist/Provider.svelte +11 -0
  3. package/dist/Provider.svelte.d.ts.map +1 -1
  4. package/dist/components/forms/Form.svelte +29 -13
  5. package/dist/components/forms/Form.svelte.d.ts +5 -0
  6. package/dist/components/forms/Form.svelte.d.ts.map +1 -1
  7. package/dist/components/forms/__tests__/Form.webmcp.test.js +115 -6
  8. package/dist/components/settings/SettingsCatalog.svelte +9 -0
  9. package/dist/components/settings/SettingsCatalog.svelte.d.ts +9 -0
  10. package/dist/components/settings/SettingsCatalog.svelte.d.ts.map +1 -1
  11. package/dist/components/workspace/admin-shell/ActivityBadge.svelte +2 -0
  12. package/dist/components/workspace/admin-shell/ActivityBadge.svelte.d.ts +2 -0
  13. package/dist/components/workspace/admin-shell/ActivityBadge.svelte.d.ts.map +1 -1
  14. package/dist/components/workspace/admin-shell/ActivityItem.svelte +1 -0
  15. package/dist/components/workspace/admin-shell/ActivityItem.svelte.d.ts +1 -0
  16. package/dist/components/workspace/admin-shell/ActivityItem.svelte.d.ts.map +1 -1
  17. package/dist/components/workspace/admin-shell/ActivityList.svelte +3 -0
  18. package/dist/components/workspace/admin-shell/ActivityList.svelte.d.ts +3 -0
  19. package/dist/components/workspace/admin-shell/ActivityList.svelte.d.ts.map +1 -1
  20. package/dist/components/workspace/admin-shell/ActivityToasts.svelte +1 -0
  21. package/dist/components/workspace/admin-shell/ActivityToasts.svelte.d.ts +1 -0
  22. package/dist/components/workspace/admin-shell/ActivityToasts.svelte.d.ts.map +1 -1
  23. package/dist/components/workspace/admin-shell/AdminShell.svelte +15 -0
  24. package/dist/components/workspace/admin-shell/AdminShell.svelte.d.ts +15 -0
  25. package/dist/components/workspace/admin-shell/AdminShell.svelte.d.ts.map +1 -1
  26. package/dist/components/workspace/admin-shell/AppScopePanel.svelte +7 -0
  27. package/dist/components/workspace/admin-shell/AppScopePanel.svelte.d.ts +7 -0
  28. package/dist/components/workspace/admin-shell/AppScopePanel.svelte.d.ts.map +1 -1
  29. package/dist/components/workspace/admin-shell/HotkeyInput.svelte +4 -0
  30. package/dist/components/workspace/admin-shell/HotkeyInput.svelte.d.ts +4 -0
  31. package/dist/components/workspace/admin-shell/HotkeyInput.svelte.d.ts.map +1 -1
  32. package/dist/components/workspace/admin-shell/ShellCorner.svelte +1 -0
  33. package/dist/components/workspace/admin-shell/ShellCorner.svelte.d.ts +1 -0
  34. package/dist/components/workspace/admin-shell/ShellCorner.svelte.d.ts.map +1 -1
  35. package/dist/components/workspace/admin-shell/ShortcutsOverlay.svelte +1 -0
  36. package/dist/components/workspace/admin-shell/ShortcutsOverlay.svelte.d.ts +1 -0
  37. package/dist/components/workspace/admin-shell/ShortcutsOverlay.svelte.d.ts.map +1 -1
  38. package/dist/components/workspace/admin-shell/SystemScopePanel.svelte +2 -0
  39. package/dist/components/workspace/admin-shell/SystemScopePanel.svelte.d.ts +2 -0
  40. package/dist/components/workspace/admin-shell/SystemScopePanel.svelte.d.ts.map +1 -1
  41. package/dist/components/workspace/admin-shell/SystemStatusChips.svelte +1 -0
  42. package/dist/components/workspace/admin-shell/SystemStatusChips.svelte.d.ts +1 -0
  43. package/dist/components/workspace/admin-shell/SystemStatusChips.svelte.d.ts.map +1 -1
  44. package/dist/components/workspace/admin-shell/TenantNav.svelte +5 -0
  45. package/dist/components/workspace/admin-shell/TenantNav.svelte.d.ts +5 -0
  46. package/dist/components/workspace/admin-shell/TenantNav.svelte.d.ts.map +1 -1
  47. package/dist/components/workspace/admin-shell/WorkspaceAccountMenu.svelte +11 -0
  48. package/dist/components/workspace/admin-shell/WorkspaceAccountMenu.svelte.d.ts +11 -0
  49. package/dist/components/workspace/admin-shell/WorkspaceAccountMenu.svelte.d.ts.map +1 -1
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +3 -0
  53. package/dist/web/__tests__/view-intent-provider.fixture.svelte +28 -0
  54. package/dist/web/__tests__/view-intent-provider.fixture.svelte.d.ts +13 -0
  55. package/dist/web/__tests__/view-intent-provider.fixture.svelte.d.ts.map +1 -0
  56. package/dist/web/__tests__/view-intent.fixture.svelte +33 -0
  57. package/dist/web/__tests__/view-intent.fixture.svelte.d.ts +15 -0
  58. package/dist/web/__tests__/view-intent.fixture.svelte.d.ts.map +1 -0
  59. package/dist/web/__tests__/view-intent.intents.d.ts +14 -0
  60. package/dist/web/__tests__/view-intent.intents.d.ts.map +1 -0
  61. package/dist/web/__tests__/view-intent.intents.js +38 -0
  62. package/dist/web/__tests__/view-intent.svelte.test.js +201 -0
  63. package/dist/web/__tests__/webmcp-composed.child.fixture.svelte +11 -0
  64. package/dist/web/__tests__/webmcp-composed.child.fixture.svelte.d.ts.map +1 -1
  65. package/dist/web/__tests__/webmcp-composed.integration.svelte.test.js +4 -0
  66. package/dist/web/__tests__/webmcp-effects-reactivity.child.fixture.svelte +16 -0
  67. package/dist/web/__tests__/webmcp-effects-reactivity.child.fixture.svelte.d.ts +19 -0
  68. package/dist/web/__tests__/webmcp-effects-reactivity.child.fixture.svelte.d.ts.map +1 -0
  69. package/dist/web/__tests__/webmcp-effects-reactivity.fixture.svelte +11 -0
  70. package/dist/web/__tests__/webmcp-effects-reactivity.fixture.svelte.d.ts +8 -0
  71. package/dist/web/__tests__/webmcp-effects-reactivity.fixture.svelte.d.ts.map +1 -0
  72. package/dist/web/__tests__/webmcp-harness-reactive-samename.svelte +20 -0
  73. package/dist/web/__tests__/webmcp-harness-reactive-samename.svelte.d.ts +7 -0
  74. package/dist/web/__tests__/webmcp-harness-reactive-samename.svelte.d.ts.map +1 -0
  75. package/dist/web/__tests__/webmcp-harness-unannotated.svelte +14 -0
  76. package/dist/web/__tests__/webmcp-harness-unannotated.svelte.d.ts +19 -0
  77. package/dist/web/__tests__/webmcp-harness-unannotated.svelte.d.ts.map +1 -0
  78. package/dist/web/__tests__/webmcp-harness.svelte +4 -0
  79. package/dist/web/__tests__/webmcp-harness.svelte.d.ts.map +1 -1
  80. package/dist/web/__tests__/webmcp.test.js +136 -9
  81. package/dist/web/view-intent.svelte.d.ts +64 -0
  82. package/dist/web/view-intent.svelte.d.ts.map +1 -0
  83. package/dist/web/view-intent.svelte.js +83 -0
  84. package/dist/web/webmcp-bespoke-context.d.ts +14 -0
  85. package/dist/web/webmcp-bespoke-context.d.ts.map +1 -0
  86. package/dist/web/webmcp-bespoke-context.js +8 -0
  87. package/dist/web/webmcp.svelte.d.ts +29 -13
  88. package/dist/web/webmcp.svelte.d.ts.map +1 -1
  89. package/dist/web/webmcp.svelte.js +92 -12
  90. 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;AAyDvE,QAAA,MAAM,cAAc,sDAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,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;AAkB/C,QAAA,MAAM,aAAa,sDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,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
- await tick();
22
- expect(registered).toHaveLength(1);
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 tick();
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 tick();
84
+ await vi.waitFor(() => expect(signal).toBeDefined());
46
85
  expect(signal?.aborted).toBe(false);
47
86
  rejectRegistration?.(new Error('host collision'));
48
- await Promise.resolve();
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"}