@frockbot/plugin-shell 0.3.1 → 0.3.2

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 (33) hide show
  1. package/package.json +32 -29
  2. package/src/backend-applets.test.ts +581 -0
  3. package/src/backend-applets.ts +959 -0
  4. package/src/backend-authoring.test.ts +61 -19
  5. package/src/backend-authoring.ts +52 -26
  6. package/src/backend-composition.ts +64 -0
  7. package/src/backend-computer.test.ts +128 -0
  8. package/src/backend-computer.ts +81 -0
  9. package/src/backend-configuration.test.ts +8 -1
  10. package/src/backend-iframe-ui.test.ts +29 -12
  11. package/src/backend-isolate.ts +31 -5
  12. package/src/backend-package-catalog.test.ts +13 -8
  13. package/src/backend-package-catalog.ts +8 -6
  14. package/src/backend-recovery-integration.test.ts +23 -0
  15. package/src/backend.ts +508 -5
  16. package/src/client/AppletCanvas.vue +679 -0
  17. package/src/client/FrockBotApp.vue +169 -15
  18. package/src/client/PackageEntryTrigger.vue +77 -0
  19. package/src/client/PackageIframeHost.vue +148 -47
  20. package/src/client/PackageIframeSettings.vue +8 -6
  21. package/src/client/PackageSurfacePage.vue +39 -0
  22. package/src/client/applets-client.test.ts +204 -0
  23. package/src/client/applets-client.ts +139 -0
  24. package/src/client/applets-state.ts +64 -0
  25. package/src/client/index.test.ts +13 -7
  26. package/src/client/index.ts +308 -1
  27. package/src/client/package-iframe-entries.test.ts +122 -0
  28. package/src/client/package-iframe-entries.ts +112 -0
  29. package/src/client/package-iframe-host-message.test.ts +3 -3
  30. package/src/client/package-iframe-host-message.ts +3 -3
  31. package/src/client/styles.css +107 -1
  32. package/src/composition-views.ts +31 -6
  33. package/src/shared.ts +52 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-shell",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -14,6 +14,7 @@
14
14
  "./backend-contracts": "./src/backend-contracts.ts",
15
15
  "./backend-execution": "./src/backend-execution.ts",
16
16
  "./backend-image": "./src/backend-image.ts",
17
+ "./backend-applets": "./src/backend-applets.ts",
17
18
  "./backend-isolate": "./src/backend-isolate.ts",
18
19
  "./backend-memory": "./src/backend-memory.ts",
19
20
  "./backend-package-catalog": "./src/backend-package-catalog.ts",
@@ -46,39 +47,41 @@
46
47
  },
47
48
  "dependencies": {
48
49
  "@cordisjs/plugin-webui": "0.8.2",
49
- "@frockbot/agent-runtime": "0.3.1",
50
- "@frockbot/application-foundation": "0.3.1",
51
- "@frockbot/catalog-core": "0.3.1",
52
- "@frockbot/client-core": "0.3.1",
53
- "@frockbot/client-ui": "0.3.1",
54
- "@frockbot/computer-core": "0.3.1",
55
- "@frockbot/computer-host-protocol": "0.3.1",
56
- "@frockbot/configuration-core": "0.3.1",
57
- "@frockbot/connection-core": "0.3.1",
58
- "@frockbot/kernel-agent-loop": "0.3.1",
59
- "@frockbot/kernel-composition": "0.3.1",
60
- "@frockbot/kernel-contracts": "0.3.1",
61
- "@frockbot/kernel-do": "0.3.1",
62
- "@frockbot/machine-protocol": "0.3.1",
63
- "@frockbot/plugin-authoring": "0.3.1",
64
- "@frockbot/plugin-package-catalog": "0.3.1",
65
- "@frockbot/plugin-bot-template": "0.3.1",
66
- "@frockbot/plugin-flock": "0.3.1",
67
- "@frockbot/plugin-image": "0.3.1",
68
- "@frockbot/plugin-machine-messages": "0.3.1",
69
- "@frockbot/plugin-mcp": "0.3.1",
70
- "@frockbot/plugin-memory": "0.3.1",
71
- "@frockbot/plugin-routines": "0.3.1",
72
- "@frockbot/plugin-skills": "0.3.1",
73
- "@frockbot/plugin-subagents": "0.3.1",
74
- "@frockbot/plugin-user-machine": "0.3.1",
75
- "@frockbot/protocol": "0.3.1",
50
+ "@frockbot/agent-runtime": "0.3.2",
51
+ "@frockbot/application-foundation": "0.3.2",
52
+ "@frockbot/catalog-core": "0.3.2",
53
+ "@frockbot/client-core": "0.3.2",
54
+ "@frockbot/client-ui": "0.3.2",
55
+ "@frockbot/computer-core": "0.3.2",
56
+ "@frockbot/computer-host-protocol": "0.3.2",
57
+ "@frockbot/configuration-core": "0.3.2",
58
+ "@frockbot/connection-core": "0.3.2",
59
+ "@frockbot/kernel-agent-loop": "0.3.2",
60
+ "@frockbot/kernel-composition": "0.3.2",
61
+ "@frockbot/kernel-contracts": "0.3.2",
62
+ "@frockbot/kernel-do": "0.3.2",
63
+ "@frockbot/machine-protocol": "0.3.2",
64
+ "@frockbot/plugin-applets": "0.3.2",
65
+ "@frockbot/plugin-authoring": "0.3.2",
66
+ "@frockbot/plugin-bot-template": "0.3.2",
67
+ "@frockbot/plugin-computer": "0.3.2",
68
+ "@frockbot/plugin-flock": "0.3.2",
69
+ "@frockbot/plugin-image": "0.3.2",
70
+ "@frockbot/plugin-machine-messages": "0.3.2",
71
+ "@frockbot/plugin-mcp": "0.3.2",
72
+ "@frockbot/plugin-memory": "0.3.2",
73
+ "@frockbot/plugin-package-catalog": "0.3.2",
74
+ "@frockbot/plugin-routines": "0.3.2",
75
+ "@frockbot/plugin-skills": "0.3.2",
76
+ "@frockbot/plugin-subagents": "0.3.2",
77
+ "@frockbot/plugin-user-machine": "0.3.2",
78
+ "@frockbot/protocol": "0.3.2",
76
79
  "cordis": "4.0.0-rc.8",
77
80
  "vue": "3.5.41"
78
81
  },
79
82
  "devDependencies": {
80
83
  "@cloudflare/workers-types": "latest",
81
- "@frockbot/plugin-tools": "0.3.1",
84
+ "@frockbot/plugin-tools": "0.3.2",
82
85
  "@types/bun": "1.3.6",
83
86
  "@types/node": "26.2.0",
84
87
  "@vitejs/plugin-vue": "6.0.8",
@@ -0,0 +1,581 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ appletCompositionMembersV1,
4
+ appletDistPathV1,
5
+ appletMembersDifferV1,
6
+ createAppletCapabilityHostV1,
7
+ decodeAppletBuildManifestV1,
8
+ resolveAppletCompositionV1,
9
+ APPLET_DIRECTORY_REVISION_SEEN_KEY,
10
+ type AppletInstanceBindingV1,
11
+ type AppletUserDirectoryV1,
12
+ } from "./backend-applets.js";
13
+ import {
14
+ compositionArtifactSetHashV1,
15
+ decodeCompositionGenerationV1,
16
+ type CompositionGenerationV1,
17
+ } from "@frockbot/kernel-composition/generation";
18
+ import {
19
+ APPLET_FOCUSED_KEY,
20
+ type AppletGenerationV1,
21
+ } from "@frockbot/kernel-do";
22
+
23
+ const USER = "user-42";
24
+ const APPLET = `${USER}.${"a".repeat(32)}`;
25
+ const OTHER = `${USER}.${"b".repeat(32)}`;
26
+
27
+ function tool(name: string) {
28
+ return {
29
+ name,
30
+ description: `The ${name} tool`,
31
+ inputSchema: { type: "object" },
32
+ };
33
+ }
34
+
35
+ async function bootstrap(): Promise<CompositionGenerationV1> {
36
+ const members = [
37
+ {
38
+ packageId: "shell",
39
+ specifier: "@frockbot/plugin-shell",
40
+ version: "1.0.0",
41
+ manifestHash: "c".repeat(64),
42
+ provenance: {
43
+ kind: "first-party" as const,
44
+ packageId: "shell",
45
+ version: "1.0.0",
46
+ },
47
+ },
48
+ ];
49
+ const artifactSetHash = await compositionArtifactSetHashV1(members);
50
+ return decodeCompositionGenerationV1({
51
+ schemaVersion: 1,
52
+ generationId: `2026-09-03T00:00:00.000Z:${artifactSetHash.slice(0, 16)}`,
53
+ artifactSetHash,
54
+ createdAt: "2026-09-03T00:00:00.000Z",
55
+ origin: { kind: "bootstrap" },
56
+ members,
57
+ status: "active",
58
+ });
59
+ }
60
+
61
+ function memoryStorage() {
62
+ const values = new Map<string, unknown>();
63
+ return {
64
+ values,
65
+ get: <T>(key: string) => Promise.resolve(values.get(key) as T | undefined),
66
+ put: (entries: Record<string, unknown>) => {
67
+ for (const [key, value] of Object.entries(entries))
68
+ values.set(key, value);
69
+ return Promise.resolve();
70
+ },
71
+ };
72
+ }
73
+
74
+ describe("Applet Composition members", () => {
75
+ test("members are ordered by Applet id and carry their provenance", () => {
76
+ const members = appletCompositionMembersV1([
77
+ {
78
+ appletId: OTHER,
79
+ generationId: "g2",
80
+ tools: [tool("b_tool")],
81
+ provenance: { kind: "user" },
82
+ },
83
+ {
84
+ appletId: APPLET,
85
+ generationId: "g1",
86
+ tools: [tool("a_tool")],
87
+ provenance: {
88
+ kind: "bot",
89
+ botId: "bot-1",
90
+ sessionId: `${USER}:bot-1`,
91
+ turnId: "turn-1",
92
+ },
93
+ },
94
+ ]);
95
+ expect(members.map((member) => member.appletId)).toEqual([APPLET, OTHER]);
96
+ expect(members[0]).toMatchObject({ kind: "applet", generationId: "g1" });
97
+ expect(members[0]?.provenance).toMatchObject({
98
+ kind: "bot",
99
+ packageId: APPLET,
100
+ version: "g1",
101
+ botId: "bot-1",
102
+ });
103
+ expect(members[1]?.provenance).toMatchObject({ kind: "user" });
104
+ });
105
+
106
+ test("the artifact set hash moves with the Applet generation", async () => {
107
+ const members = [
108
+ {
109
+ packageId: "shell",
110
+ specifier: "@frockbot/plugin-shell",
111
+ version: "1.0.0",
112
+ manifestHash: "c".repeat(64),
113
+ provenance: {
114
+ kind: "first-party" as const,
115
+ packageId: "shell",
116
+ version: "1.0.0",
117
+ },
118
+ },
119
+ ];
120
+ const withoutApplets = await compositionArtifactSetHashV1(members);
121
+ const first = appletCompositionMembersV1([
122
+ {
123
+ appletId: APPLET,
124
+ generationId: "g1",
125
+ tools: [tool("a_tool")],
126
+ provenance: { kind: "user" },
127
+ },
128
+ ]);
129
+ const second = appletCompositionMembersV1([
130
+ {
131
+ appletId: APPLET,
132
+ generationId: "g2",
133
+ tools: [tool("a_tool")],
134
+ provenance: { kind: "user" },
135
+ },
136
+ ]);
137
+ // A generation with no Applets hashes exactly as it always did.
138
+ expect(await compositionArtifactSetHashV1(members, [])).toBe(
139
+ withoutApplets,
140
+ );
141
+ expect(await compositionArtifactSetHashV1(members, first)).not.toBe(
142
+ withoutApplets,
143
+ );
144
+ expect(await compositionArtifactSetHashV1(members, first)).not.toBe(
145
+ await compositionArtifactSetHashV1(members, second),
146
+ );
147
+ });
148
+
149
+ test("a changed tool set, generation, or Applet is a different member set", () => {
150
+ const base = appletCompositionMembersV1([
151
+ {
152
+ appletId: APPLET,
153
+ generationId: "g1",
154
+ tools: [tool("a_tool")],
155
+ provenance: { kind: "user" },
156
+ },
157
+ ]);
158
+ expect(appletMembersDifferV1(base, base)).toBe(false);
159
+ expect(appletMembersDifferV1(base, [])).toBe(true);
160
+ expect(
161
+ appletMembersDifferV1(
162
+ base,
163
+ appletCompositionMembersV1([
164
+ {
165
+ appletId: APPLET,
166
+ generationId: "g2",
167
+ tools: [tool("a_tool")],
168
+ provenance: { kind: "user" },
169
+ },
170
+ ]),
171
+ ),
172
+ ).toBe(true);
173
+ expect(
174
+ appletMembersDifferV1(
175
+ base,
176
+ appletCompositionMembersV1([
177
+ {
178
+ appletId: APPLET,
179
+ generationId: "g1",
180
+ tools: [tool("a_tool"), tool("b_tool")],
181
+ provenance: { kind: "user" },
182
+ },
183
+ ]),
184
+ ),
185
+ ).toBe(true);
186
+ });
187
+ });
188
+
189
+ describe("Applet Composition resolution", () => {
190
+ async function resolveWith(
191
+ applets: {
192
+ appletId: string;
193
+ generationId: string;
194
+ tools: ReturnType<typeof tool>[];
195
+ provenance: { kind: "user" };
196
+ }[],
197
+ options: {
198
+ current?: CompositionGenerationV1;
199
+ revision?: number;
200
+ storage?: ReturnType<typeof memoryStorage>;
201
+ } = {},
202
+ ) {
203
+ const current = options.current ?? (await bootstrap());
204
+ const storage = options.storage ?? memoryStorage();
205
+ const proposed: CompositionGenerationV1[] = [];
206
+ const generation = await resolveAppletCompositionV1({
207
+ directory: {
208
+ compositionInput: () =>
209
+ Promise.resolve({ revision: options.revision ?? 1, applets }),
210
+ },
211
+ composition: {
212
+ current: () => Promise.resolve(current),
213
+ propose: (candidate) => {
214
+ proposed.push(candidate);
215
+ return Promise.resolve();
216
+ },
217
+ },
218
+ storage,
219
+ origin: { kind: "user-install", userId: USER },
220
+ });
221
+ return { generation, proposed, storage, current };
222
+ }
223
+
224
+ test("a published Applet's tools appear in the Bot's next generation", async () => {
225
+ const { generation, proposed, storage } = await resolveWith([
226
+ {
227
+ appletId: APPLET,
228
+ generationId: "g1",
229
+ tools: [tool("add_todo")],
230
+ provenance: { kind: "user" },
231
+ },
232
+ ]);
233
+ expect(generation).toBeDefined();
234
+ expect(proposed).toHaveLength(1);
235
+ expect(proposed[0]?.applets?.[0]).toMatchObject({
236
+ appletId: APPLET,
237
+ generationId: "g1",
238
+ });
239
+ expect(proposed[0]?.applets?.[0]?.tools.map((entry) => entry.name)).toEqual(
240
+ ["add_todo"],
241
+ );
242
+ // The Package members are carried through unchanged.
243
+ expect(proposed[0]?.members.map((member) => member.packageId)).toEqual([
244
+ "shell",
245
+ ]);
246
+ expect(storage.values.get(APPLET_DIRECTORY_REVISION_SEEN_KEY)).toBe(1);
247
+ });
248
+
249
+ test("an unchanged directory proposes nothing", async () => {
250
+ const first = await resolveWith([
251
+ {
252
+ appletId: APPLET,
253
+ generationId: "g1",
254
+ tools: [tool("add_todo")],
255
+ provenance: { kind: "user" },
256
+ },
257
+ ]);
258
+ const again = await resolveWith(
259
+ [
260
+ {
261
+ appletId: APPLET,
262
+ generationId: "g1",
263
+ tools: [tool("add_todo")],
264
+ provenance: { kind: "user" },
265
+ },
266
+ ],
267
+ { current: first.proposed[0], storage: first.storage, revision: 1 },
268
+ );
269
+ expect(again.generation).toBeUndefined();
270
+ expect(again.proposed).toEqual([]);
271
+ });
272
+
273
+ test("a deleted Applet's tools disappear at the next resolution", async () => {
274
+ const published = await resolveWith([
275
+ {
276
+ appletId: APPLET,
277
+ generationId: "g1",
278
+ tools: [tool("add_todo")],
279
+ provenance: { kind: "user" },
280
+ },
281
+ ]);
282
+ const afterDelete = await resolveWith([], {
283
+ current: published.proposed[0],
284
+ storage: published.storage,
285
+ revision: 2,
286
+ });
287
+ expect(afterDelete.proposed).toHaveLength(1);
288
+ expect(afterDelete.proposed[0]?.applets).toBeUndefined();
289
+ // The pinned generation the in-flight Turn holds is untouched: a new
290
+ // generation was proposed beside it, never a mutation of it.
291
+ expect(published.proposed[0]?.applets?.[0]?.appletId).toBe(APPLET);
292
+ expect(afterDelete.proposed[0]?.parentGenerationId).toBe(
293
+ published.proposed[0]?.generationId,
294
+ );
295
+ });
296
+ });
297
+
298
+ describe("Applet build manifest", () => {
299
+ test("a well-formed manifest decodes; anything else is refused", () => {
300
+ const manifest = {
301
+ contract: 1,
302
+ tools: [tool("add_todo")],
303
+ hashes: { server: "a".repeat(64), ui: "b".repeat(64) },
304
+ };
305
+ expect(decodeAppletBuildManifestV1(manifest)).toMatchObject({
306
+ contract: 1,
307
+ });
308
+ expect(() =>
309
+ decodeAppletBuildManifestV1({ ...manifest, contract: 2 }),
310
+ ).toThrow(/contract/);
311
+ expect(() =>
312
+ decodeAppletBuildManifestV1({ ...manifest, extra: true }),
313
+ ).toThrow(/invalid fields/);
314
+ expect(() =>
315
+ decodeAppletBuildManifestV1({
316
+ ...manifest,
317
+ hashes: { server: "short", ui: "b".repeat(64) },
318
+ }),
319
+ ).toThrow(/server/);
320
+ expect(() =>
321
+ decodeAppletBuildManifestV1({
322
+ ...manifest,
323
+ tools: [tool("add_todo"), tool("add_todo")],
324
+ }),
325
+ ).toThrow(/duplicate/);
326
+ });
327
+
328
+ test("the built files are read from the Applets Package's declared root", () => {
329
+ expect(appletDistPathV1(USER, APPLET, "dist/server.js")).toEqual({
330
+ root: {
331
+ kind: "package-declared",
332
+ userId: USER,
333
+ packageId: "applets",
334
+ rootId: "source",
335
+ },
336
+ path: `${APPLET}/dist/server.js`,
337
+ });
338
+ });
339
+ });
340
+
341
+ describe("ctx.applets", () => {
342
+ function host(options: {
343
+ files?: Record<string, string>;
344
+ directory?: Partial<AppletUserDirectoryV1>;
345
+ instance?: Partial<AppletInstanceBindingV1>;
346
+ storage?: ReturnType<typeof memoryStorage>;
347
+ }) {
348
+ const storage = options.storage ?? memoryStorage();
349
+ const recorded: unknown[] = [];
350
+ const artifacts: Record<string, string> = {};
351
+ const directory: AppletUserDirectoryV1 = {
352
+ list: () =>
353
+ Promise.resolve({
354
+ revision: 1,
355
+ applets: [
356
+ {
357
+ appletId: APPLET,
358
+ displayName: "Todo",
359
+ status: "published" as const,
360
+ currentGenerationId: "g1",
361
+ tools: ["add_todo"],
362
+ createdAt: "2026-09-03T00:00:00.000Z",
363
+ },
364
+ ],
365
+ }),
366
+ compositionInput: () => Promise.resolve({ revision: 1, applets: [] }),
367
+ create: () =>
368
+ Promise.resolve({
369
+ appletId: APPLET,
370
+ displayName: "Todo",
371
+ status: "draft" as const,
372
+ tools: [],
373
+ createdAt: "2026-09-03T00:00:00.000Z",
374
+ }),
375
+ recordGeneration: (input) => {
376
+ recorded.push(input);
377
+ return Promise.resolve({
378
+ appletId: input.appletId,
379
+ displayName: "Todo",
380
+ status: "published" as const,
381
+ currentGenerationId: input.generationId,
382
+ tools: input.tools.map((entry) => entry.name),
383
+ createdAt: "2026-09-03T00:00:00.000Z",
384
+ });
385
+ },
386
+ delete: () =>
387
+ Promise.resolve({
388
+ appletId: APPLET,
389
+ displayName: "Todo",
390
+ status: "deleted" as const,
391
+ tools: [],
392
+ createdAt: "2026-09-03T00:00:00.000Z",
393
+ }),
394
+ ...options.directory,
395
+ };
396
+ const instance: AppletInstanceBindingV1 = {
397
+ publish: (input) =>
398
+ Promise.resolve({
399
+ status: "active" as const,
400
+ generationId: input.generation.generationId,
401
+ tools: input.generation.tools.map((entry) => entry.name),
402
+ }),
403
+ revert: (input) =>
404
+ Promise.resolve({
405
+ status: "active" as const,
406
+ generationId: input.generation.generationId,
407
+ tools: input.generation.tools.map((entry) => entry.name),
408
+ }),
409
+ invokeTool: () => Promise.resolve({ status: "ok" as const, content: "" }),
410
+ read: () => Promise.resolve({ generations: [] as AppletGenerationV1[] }),
411
+ ...options.instance,
412
+ };
413
+ return {
414
+ storage,
415
+ recorded,
416
+ artifacts,
417
+ host: createAppletCapabilityHostV1({
418
+ userId: USER,
419
+ botId: "bot-1",
420
+ storage,
421
+ directory,
422
+ instanceFor: () => instance,
423
+ artifacts: {
424
+ putPackageArtifact: (hash, module) => {
425
+ artifacts[hash] = module;
426
+ return Promise.resolve();
427
+ },
428
+ putPackageUiArtifact: (hash, html) => {
429
+ artifacts[hash] = html;
430
+ return Promise.resolve();
431
+ },
432
+ },
433
+ workspace: {
434
+ read: (path) => {
435
+ const bytes = options.files?.[path.path];
436
+ return Promise.resolve(
437
+ bytes === undefined
438
+ ? {
439
+ status: "not-found" as const,
440
+ reason: "no such file",
441
+ }
442
+ : {
443
+ status: "ok" as const,
444
+ file: {
445
+ path: path.path,
446
+ size: bytes.length,
447
+ contentHash: "0".repeat(64),
448
+ writer: { kind: "unattributed" as const },
449
+ bytes: new TextEncoder().encode(bytes),
450
+ },
451
+ },
452
+ ) as never;
453
+ },
454
+ list: () => Promise.resolve({ status: "ok", entries: [] }) as never,
455
+ stat: () =>
456
+ Promise.resolve({ status: "not-found", reason: "" }) as never,
457
+ },
458
+ composition: {
459
+ current: () => bootstrap(),
460
+ lastKnownGood: () => bootstrap(),
461
+ propose: () => Promise.resolve(),
462
+ },
463
+ }),
464
+ };
465
+ }
466
+
467
+ const scope = {
468
+ sessionId: `${USER}:bot-1`,
469
+ runId: "run-1",
470
+ turnId: "turn-1",
471
+ effectId: "applet:turn-1:publish:x",
472
+ };
473
+
474
+ test("create mints an entry and focuses it", async () => {
475
+ const { host: capability, storage } = host({});
476
+ const created = await capability.create({ displayName: "Todo" }, scope);
477
+ expect(created.appletId).toBe(APPLET);
478
+ expect(await capability.readFocused()).toMatchObject({ appletId: APPLET });
479
+ expect(storage.values.has(APPLET_FOCUSED_KEY)).toBe(true);
480
+ });
481
+
482
+ test("publish refuses an Applet that has not been built", async () => {
483
+ const { host: capability } = host({ files: {} });
484
+ const outcome = await capability.publish({ appletId: APPLET }, scope);
485
+ expect(outcome.status).toBe("failed");
486
+ expect(outcome.status === "failed" && outcome.reason).toMatch(
487
+ /applet build/,
488
+ );
489
+ });
490
+
491
+ test("publish refuses a manifest whose hashes do not match the bytes", async () => {
492
+ const { host: capability } = host({
493
+ files: {
494
+ [`${APPLET}/dist/server.js`]: "export class Applet {}",
495
+ [`${APPLET}/dist/ui.html`]: "<h1>hi</h1>",
496
+ [`${APPLET}/dist/manifest.json`]: JSON.stringify({
497
+ contract: 1,
498
+ tools: [tool("add_todo")],
499
+ hashes: { server: "a".repeat(64), ui: "b".repeat(64) },
500
+ }),
501
+ },
502
+ });
503
+ const outcome = await capability.publish({ appletId: APPLET }, scope);
504
+ expect(outcome.status).toBe("failed");
505
+ expect(outcome.status === "failed" && outcome.reason).toMatch(
506
+ /does not match the built files/,
507
+ );
508
+ });
509
+
510
+ test("publish is idempotent by effect id", async () => {
511
+ const shared = memoryStorage();
512
+ const first = host({ files: {}, storage: shared });
513
+ const outcome = await first.host.publish({ appletId: APPLET }, scope);
514
+ let calls = 0;
515
+ const second = host({
516
+ files: {},
517
+ storage: shared,
518
+ directory: {
519
+ recordGeneration: () => {
520
+ calls += 1;
521
+ throw new Error("must not run twice");
522
+ },
523
+ },
524
+ });
525
+ // The recorded effect answers rather than repeating the read and the write.
526
+ expect(await second.host.publish({ appletId: APPLET }, scope)).toEqual(
527
+ outcome,
528
+ );
529
+ expect(calls).toBe(0);
530
+ });
531
+
532
+ test("delete clears the focus when the deleted Applet was focused", async () => {
533
+ const { host: capability } = host({});
534
+ await capability.focus({ appletId: APPLET });
535
+ await capability.delete({ appletId: APPLET });
536
+ expect(await capability.readFocused()).toMatchObject({ appletId: null });
537
+ });
538
+
539
+ test("generations answers newest first and marks the current one", async () => {
540
+ const generation = (id: string): AppletGenerationV1 => ({
541
+ schemaVersion: 1,
542
+ generationId: id,
543
+ server: {
544
+ contentHash: "a".repeat(64),
545
+ size: 1,
546
+ mediaType: "application/javascript",
547
+ bundlerVersion: "test",
548
+ },
549
+ ui: {
550
+ contentHash: "b".repeat(64),
551
+ size: 1,
552
+ mediaType: "text/html",
553
+ bundlerVersion: "test",
554
+ },
555
+ tools: [tool("add_todo")],
556
+ contract: 1,
557
+ origin: "publish",
558
+ provenance: {
559
+ botId: "bot-1",
560
+ sessionId: `${USER}:bot-1`,
561
+ turnId: "turn-1",
562
+ runId: "run-1",
563
+ },
564
+ createdAt: "2026-09-03T00:00:00.000Z",
565
+ status: "active",
566
+ });
567
+ const { host: capability } = host({
568
+ instance: {
569
+ read: () =>
570
+ Promise.resolve({
571
+ current: { generationId: "g2" },
572
+ generations: [generation("g1"), generation("g2")],
573
+ }),
574
+ },
575
+ });
576
+ const rows = await capability.generations({ appletId: APPLET });
577
+ expect(rows.map((row) => row.generationId)).toEqual(["g2", "g1"]);
578
+ expect(rows[0]?.isCurrent).toBe(true);
579
+ expect(rows[1]?.isCurrent).toBe(false);
580
+ });
581
+ });