@frockbot/plugin-flock 0.0.0 → 0.1.1

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 (92) hide show
  1. package/README.md +8 -1
  2. package/assets/amber-glasses.webp +0 -0
  3. package/assets/background-bright-orange.webp +0 -0
  4. package/assets/background-canary-yellow.webp +0 -0
  5. package/assets/background-electric-blue.webp +0 -0
  6. package/assets/background-hot-pink.webp +0 -0
  7. package/assets/background-lime-green.webp +0 -0
  8. package/assets/background-vivid-purple.webp +0 -0
  9. package/assets/blue-sailor-cap.webp +0 -0
  10. package/assets/bronze-aviator-goggles.webp +0 -0
  11. package/assets/burgundy-beret.webp +0 -0
  12. package/assets/canary-party-hat.webp +0 -0
  13. package/assets/canary-rain-cap.webp +0 -0
  14. package/assets/canonical.webp +0 -0
  15. package/assets/chef-toque.webp +0 -0
  16. package/assets/cobalt-round-glasses.webp +0 -0
  17. package/assets/coral-bucket-hat.webp +0 -0
  18. package/assets/emerald-necktie.webp +0 -0
  19. package/assets/forest-ranger-fedora.webp +0 -0
  20. package/assets/gold-royal-crown.webp +0 -0
  21. package/assets/gold-sun-medallion.webp +0 -0
  22. package/assets/graduation-cap.webp +0 -0
  23. package/assets/green-square-glasses.webp +0 -0
  24. package/assets/layers.css +159 -0
  25. package/assets/leather-bell.webp +0 -0
  26. package/assets/lilac-flower-crown.webp +0 -0
  27. package/assets/manifest.json +769 -0
  28. package/assets/matte-black-top-hat.webp +0 -0
  29. package/assets/navy-wizard-hat.webp +0 -0
  30. package/assets/orange-hard-hat.webp +0 -0
  31. package/assets/plum-glasses.webp +0 -0
  32. package/assets/purple-scarf.webp +0 -0
  33. package/assets/purple-witch-hat.webp +0 -0
  34. package/assets/rainbow-propeller-cap.webp +0 -0
  35. package/assets/rainbow-visor.webp +0 -0
  36. package/assets/ranger-fedora-feather.webp +0 -0
  37. package/assets/red-polka-bowtie.webp +0 -0
  38. package/assets/retro-3d-glasses.webp +0 -0
  39. package/assets/rose-heart-sunglasses.webp +0 -0
  40. package/assets/royal-crown-rubies.webp +0 -0
  41. package/assets/sailor-cap-anchor.webp +0 -0
  42. package/assets/silver-space-helmet.webp +0 -0
  43. package/assets/straw-boater.webp +0 -0
  44. package/assets/teal-bowtie.webp +0 -0
  45. package/assets/teal-glasses.webp +0 -0
  46. package/assets/terracotta-beanie.webp +0 -0
  47. package/assets/tortoiseshell-readers.webp +0 -0
  48. package/assets/turquoise-bandana.webp +0 -0
  49. package/assets/violet-masquerade-mask.webp +0 -0
  50. package/assets/white-snow-goggles.webp +0 -0
  51. package/assets/white-tennis-visor.webp +0 -0
  52. package/assets/witch-hat-moon.webp +0 -0
  53. package/assets/yellow-star-glasses.webp +0 -0
  54. package/frockbot.json +38 -0
  55. package/package.json +47 -6
  56. package/src/agent.test.ts +401 -0
  57. package/src/agent.ts +571 -0
  58. package/src/assets.test.ts +31 -0
  59. package/src/backend.test.ts +205 -0
  60. package/src/backend.ts +283 -0
  61. package/src/bot.test.ts +190 -0
  62. package/src/bot.ts +255 -0
  63. package/src/client/BotAvatar.vue +20 -0
  64. package/src/client/FlockAvatar.vue +33 -0
  65. package/src/client/FlockAvatarEditor.vue +44 -0
  66. package/src/client/FlockCreateButton.vue +18 -0
  67. package/src/client/FlockIdentity.vue +31 -0
  68. package/src/client/FlockOverlay.vue +209 -0
  69. package/src/client/FlockSidebar.vue +254 -0
  70. package/src/client/SheepAvatar.vue +28 -0
  71. package/src/client/dialog-focus.test.ts +15 -0
  72. package/src/client/dialog-focus.ts +13 -0
  73. package/src/client/index.test.ts +502 -0
  74. package/src/client/index.ts +619 -0
  75. package/src/client/mobile-dialog-layout.test.ts +50 -0
  76. package/src/client/pending-create.test.ts +71 -0
  77. package/src/client/pending-create.ts +93 -0
  78. package/src/client/sidebar.test.ts +77 -0
  79. package/src/client/sidebar.ts +77 -0
  80. package/src/client/state.ts +53 -0
  81. package/src/client/styles.css +611 -0
  82. package/src/env.d.ts +9 -0
  83. package/src/index.ts +4 -0
  84. package/src/manifest.ts +3 -0
  85. package/src/package.test.ts +12 -0
  86. package/src/shared.test.ts +197 -0
  87. package/src/shared.ts +735 -0
  88. package/src/user.test.ts +455 -0
  89. package/src/user.ts +465 -0
  90. package/tsconfig.json +15 -0
  91. package/validate_readability.py +64 -0
  92. package/vite.config.ts +30 -0
@@ -0,0 +1,502 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import type { ClientPluginContext } from "@frockbot/client-core";
3
+ import type { FrockBotWebData } from "@frockbot/plugin-shell/shared";
4
+ import { ref, type Ref } from "vue";
5
+ import { randomSheepRecipeV1 } from "../shared.js";
6
+ import { flockClientPlugin } from "./index.js";
7
+ import { pendingCreateKey, pendingSheepKey } from "./pending-create.js";
8
+ import type { FlockWebData } from "./state.js";
9
+
10
+ const originalStorage = Object.getOwnPropertyDescriptor(
11
+ globalThis,
12
+ "localStorage",
13
+ );
14
+ const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
15
+
16
+ function installStorage(): Map<string, string> {
17
+ const values = new Map<string, string>();
18
+ Object.defineProperty(globalThis, "localStorage", {
19
+ configurable: true,
20
+ value: {
21
+ getItem: (key: string) => values.get(key) ?? null,
22
+ setItem: (key: string, value: string) => values.set(key, value),
23
+ removeItem: (key: string) => values.delete(key),
24
+ },
25
+ });
26
+ return values;
27
+ }
28
+
29
+ afterEach(() => {
30
+ if (originalStorage)
31
+ Object.defineProperty(globalThis, "localStorage", originalStorage);
32
+ else Reflect.deleteProperty(globalThis, "localStorage");
33
+ if (originalWindow)
34
+ Object.defineProperty(globalThis, "window", originalWindow);
35
+ else Reflect.deleteProperty(globalThis, "window");
36
+ });
37
+
38
+ function mount(
39
+ hostedRequest: NonNullable<ClientPluginContext["transport"]["hostedRequest"]>,
40
+ ): Ref<FlockWebData> {
41
+ let provided: Ref<FlockWebData> | undefined;
42
+ flockClientPlugin({
43
+ transport: {
44
+ turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
45
+ readAuthenticatedUserId: () => Promise.resolve("user-a"),
46
+ hostedRequest,
47
+ },
48
+ slot: () => () => undefined,
49
+ inject: () => {
50
+ throw new Error("unexpected client provider injection");
51
+ },
52
+ provide: (_key, value) => {
53
+ provided = value as Ref<FlockWebData>;
54
+ return () => undefined;
55
+ },
56
+ });
57
+ if (!provided) throw new Error("Flock state was not provided");
58
+ return provided;
59
+ }
60
+
61
+ describe("Flock client reconciliation", () => {
62
+ test("latest click wins when identity responses resolve out of order", async () => {
63
+ installStorage();
64
+ const resolvers = new Map<string, (value: unknown) => void>();
65
+ const state = mount(
66
+ (path) => new Promise((resolve) => resolvers.set(path, resolve)),
67
+ );
68
+ const selected: string[] = [];
69
+ state.value.bindShell(
70
+ ref({
71
+ selectBot: (botId: string) => {
72
+ selected.push(botId);
73
+ return Promise.resolve();
74
+ },
75
+ }) as unknown as Ref<FrockBotWebData>,
76
+ );
77
+ state.value.directory = {
78
+ schemaVersion: 1,
79
+ revision: 2,
80
+ bots: [
81
+ {
82
+ schemaVersion: 1,
83
+ botId: "old",
84
+ registeredAt: new Date(0).toISOString(),
85
+ initialName: "Old",
86
+ sheep: randomSheepRecipeV1(() => 0),
87
+ },
88
+ {
89
+ schemaVersion: 1,
90
+ botId: "new",
91
+ registeredAt: new Date(0).toISOString(),
92
+ initialName: "New",
93
+ sheep: randomSheepRecipeV1(() => 0),
94
+ },
95
+ ],
96
+ };
97
+ const oldSelection = state.value.select("old");
98
+ const newSelection = state.value.select("new");
99
+ resolvers.get("/api/bots/new/sheep")?.({
100
+ schemaVersion: 1,
101
+ botId: "new",
102
+ revision: 0,
103
+ sheep: randomSheepRecipeV1(() => 0),
104
+ });
105
+ await newSelection;
106
+ resolvers.get("/api/bots/old/sheep")?.({
107
+ schemaVersion: 1,
108
+ botId: "old",
109
+ revision: 0,
110
+ sheep: randomSheepRecipeV1(() => 0),
111
+ });
112
+ await oldSelection;
113
+ expect(selected).toEqual(["new"]);
114
+ });
115
+
116
+ test("cleans an archived or unknown URL and selects the deterministic active fallback", async () => {
117
+ installStorage();
118
+ const location = { href: "https://app.example/?bot=missing" };
119
+ Object.defineProperty(globalThis, "window", {
120
+ configurable: true,
121
+ value: {
122
+ location,
123
+ history: {
124
+ state: null,
125
+ replaceState: (_state: unknown, _title: string, url: URL) => {
126
+ location.href = url.href;
127
+ },
128
+ },
129
+ },
130
+ });
131
+ const sheep = randomSheepRecipeV1(() => 0);
132
+ const methods: Array<string | undefined> = [];
133
+ const state = mount((path, method) => {
134
+ if (path === "/api/bots/identities")
135
+ return Promise.resolve({ schemaVersion: 1, identities: [] });
136
+ methods.push(method);
137
+ if (path === "/api/bots/lifecycles")
138
+ return Promise.resolve({
139
+ schemaVersion: 1,
140
+ lifecycles: [
141
+ { schemaVersion: 1, botId: "alpha", status: "active", revision: 0 },
142
+ ],
143
+ });
144
+ if (path === "/api/bots")
145
+ return Promise.resolve({
146
+ schemaVersion: 1,
147
+ revision: 1,
148
+ bots: [
149
+ {
150
+ schemaVersion: 1,
151
+ botId: "alpha",
152
+ registeredAt: new Date(0).toISOString(),
153
+ initialName: "Alpha",
154
+ sheep,
155
+ },
156
+ ],
157
+ });
158
+ if (path === "/api/bots/alpha/sheep")
159
+ return Promise.resolve({
160
+ schemaVersion: 1,
161
+ botId: "alpha",
162
+ revision: 0,
163
+ sheep,
164
+ });
165
+ return Promise.reject(new Error(`unexpected request: ${path}`));
166
+ });
167
+ const selected: string[] = [];
168
+ state.value.bindShell(
169
+ ref({
170
+ selectBot: (botId: string) => {
171
+ selected.push(botId);
172
+ return Promise.resolve();
173
+ },
174
+ }) as unknown as Ref<FrockBotWebData>,
175
+ );
176
+
177
+ await state.value.load();
178
+
179
+ expect(state.value.overlay).toBeUndefined();
180
+ expect(selected).toEqual(["alpha"]);
181
+ expect(new URL(location.href).searchParams.get("bot")).toBe("alpha");
182
+ expect(methods).not.toContain("POST");
183
+ });
184
+
185
+ test("reconciles a pending update before accepting a new sheep draft", async () => {
186
+ const storage = installStorage();
187
+ const original = randomSheepRecipeV1(() => 0);
188
+ const pendingSheep = randomSheepRecipeV1(() => 0.5);
189
+ const finalSheep = randomSheepRecipeV1(() => 0.999);
190
+ storage.set(
191
+ pendingSheepKey("user-a", "alpha"),
192
+ JSON.stringify({
193
+ schemaVersion: 1,
194
+ type: "bot/update-sheep",
195
+ commandId: "pending-1",
196
+ expectedRevision: 0,
197
+ botId: "alpha",
198
+ sheep: pendingSheep,
199
+ }),
200
+ );
201
+ const posted: Array<Record<string, unknown>> = [];
202
+ let revision = 1;
203
+ const state = mount((path, method, body) => {
204
+ if (path === "/api/bots/identities")
205
+ return Promise.resolve({ schemaVersion: 1, identities: [] });
206
+ if (method === "POST") {
207
+ const command = JSON.parse(body ?? "") as Record<string, unknown>;
208
+ posted.push(command);
209
+ return Promise.resolve({
210
+ schemaVersion: 1,
211
+ commandId: command.commandId,
212
+ status: "applied",
213
+ revision: posted.length,
214
+ });
215
+ }
216
+ if (path === "/api/bots/alpha/sheep")
217
+ return Promise.resolve({
218
+ schemaVersion: 1,
219
+ botId: "alpha",
220
+ revision: revision++,
221
+ sheep: posted.length === 1 ? pendingSheep : finalSheep,
222
+ });
223
+ return Promise.reject(new Error(`unexpected request: ${path}`));
224
+ });
225
+ state.value.bindShell(
226
+ ref({ activeBotId: "alpha" }) as unknown as Ref<FrockBotWebData>,
227
+ );
228
+ state.value.identities.alpha = {
229
+ schemaVersion: 1,
230
+ botId: "alpha",
231
+ revision: 0,
232
+ sheep: original,
233
+ };
234
+
235
+ await state.value.openEdit();
236
+ expect(state.value.draftSheep).toEqual(pendingSheep);
237
+ state.value.draftSheep = finalSheep;
238
+ await state.value.saveSheep();
239
+
240
+ expect(posted).toHaveLength(2);
241
+ expect(posted[0]).toMatchObject({
242
+ commandId: "pending-1",
243
+ sheep: pendingSheep,
244
+ });
245
+ expect(posted[1]).toMatchObject({ expectedRevision: 1, sheep: finalSheep });
246
+ expect(storage.has(pendingSheepKey("user-a", "alpha"))).toBe(false);
247
+ expect(state.value.identities.alpha?.sheep).toEqual(finalSheep);
248
+ });
249
+
250
+ test("surfaces definitive pending creation failures", async () => {
251
+ const storage = installStorage();
252
+ Object.defineProperty(globalThis, "window", {
253
+ configurable: true,
254
+ value: { location: { href: "https://app.example/" } },
255
+ });
256
+ storage.set(
257
+ pendingCreateKey("user-a"),
258
+ JSON.stringify({
259
+ schemaVersion: 1,
260
+ type: "bot/create",
261
+ commandId: "pending-create",
262
+ expectedRevision: 0,
263
+ botId: "alpha",
264
+ name: "Alpha",
265
+ }),
266
+ );
267
+ const definitive = Object.assign(new Error("Directory changed"), {
268
+ definitive: true,
269
+ });
270
+ const state = mount((path, method) => {
271
+ if (path === "/api/bots/identities")
272
+ return Promise.resolve({ schemaVersion: 1, identities: [] });
273
+ if (path === "/api/bots" && method === "POST")
274
+ return Promise.reject(definitive);
275
+ if (path === "/api/bots/lifecycles")
276
+ return Promise.resolve({ schemaVersion: 1, lifecycles: [] });
277
+ if (path === "/api/bots")
278
+ return Promise.resolve({ schemaVersion: 1, revision: 1, bots: [] });
279
+ return Promise.reject(new Error(`unexpected request: ${path}`));
280
+ });
281
+
282
+ await state.value.load();
283
+
284
+ expect(state.value.error).toBe("Directory changed");
285
+ expect(storage.has(pendingCreateKey("user-a"))).toBe(false);
286
+ });
287
+
288
+ test("selects a newly created Bot from an unknown onboarding URL", async () => {
289
+ installStorage();
290
+ const location = { href: "https://app.example/?bot=missing" };
291
+ Object.defineProperty(globalThis, "window", {
292
+ configurable: true,
293
+ value: {
294
+ location,
295
+ history: {
296
+ state: null,
297
+ replaceState: (_state: unknown, _title: string, url: URL) => {
298
+ location.href = url.href;
299
+ },
300
+ },
301
+ },
302
+ });
303
+ const sheep = randomSheepRecipeV1(() => 0);
304
+ let created: Record<string, unknown> | undefined;
305
+ const state = mount((path, method, body) => {
306
+ if (path === "/api/bots/identities")
307
+ return Promise.resolve({ schemaVersion: 1, identities: [] });
308
+ if (path === "/api/bots" && method === "POST") {
309
+ created = JSON.parse(body ?? "") as Record<string, unknown>;
310
+ return Promise.resolve({
311
+ schemaVersion: 1,
312
+ commandId: created.commandId,
313
+ status: "applied",
314
+ revision: 1,
315
+ });
316
+ }
317
+ if (path === "/api/bots/lifecycles")
318
+ return Promise.resolve({
319
+ schemaVersion: 1,
320
+ lifecycles: created
321
+ ? [
322
+ {
323
+ schemaVersion: 1,
324
+ botId: created.botId,
325
+ status: "active",
326
+ revision: 0,
327
+ },
328
+ ]
329
+ : [],
330
+ });
331
+ if (path === "/api/bots")
332
+ return Promise.resolve({
333
+ schemaVersion: 1,
334
+ revision: 1,
335
+ bots: created
336
+ ? [
337
+ {
338
+ schemaVersion: 1,
339
+ botId: created.botId,
340
+ registeredAt: new Date(0).toISOString(),
341
+ initialName: created.name,
342
+ sheep,
343
+ },
344
+ ]
345
+ : [],
346
+ });
347
+ if (created && path === `/api/bots/${created.botId as string}/sheep`)
348
+ return Promise.resolve({
349
+ schemaVersion: 1,
350
+ botId: created.botId,
351
+ revision: 0,
352
+ sheep,
353
+ });
354
+ return Promise.reject(new Error(`unexpected request: ${path}`));
355
+ });
356
+ const selected: string[] = [];
357
+ state.value.bindShell(
358
+ ref({
359
+ selectBot: (botId: string) => {
360
+ selected.push(botId);
361
+ return Promise.resolve();
362
+ },
363
+ }) as unknown as Ref<FrockBotWebData>,
364
+ );
365
+ state.value.directory = { schemaVersion: 1, revision: 0, bots: [] };
366
+ state.value.draftName = "Alpha";
367
+ state.value.draftSheep = sheep;
368
+ state.value.overlay = "create";
369
+
370
+ await state.value.create();
371
+
372
+ const createdBotId = created?.botId;
373
+ if (typeof createdBotId !== "string")
374
+ throw new Error("Create command was not sent");
375
+ expect(new URL(location.href).searchParams.get("bot")).toBe(createdBotId);
376
+ expect(state.value.overlay).toBeUndefined();
377
+ expect(selected).not.toHaveLength(0);
378
+ expect(selected.every((botId) => botId === createdBotId)).toBe(true);
379
+ });
380
+
381
+ test("archives with confirmation, hides archived Bots, and selects a fallback", async () => {
382
+ installStorage();
383
+ const location = { href: "https://app.example/?bot=alpha" };
384
+ Object.defineProperty(globalThis, "window", {
385
+ configurable: true,
386
+ value: {
387
+ location,
388
+ history: {
389
+ state: null,
390
+ replaceState: (_state: unknown, _title: string, url: URL) => {
391
+ location.href = url.href;
392
+ },
393
+ },
394
+ },
395
+ });
396
+ const sheep = randomSheepRecipeV1(() => 0);
397
+ let alphaStatus: "active" | "archived" = "active";
398
+ const state = mount((path, method, body) => {
399
+ if (path === "/api/bots/identities")
400
+ return Promise.resolve({ schemaVersion: 1, identities: [] });
401
+ if (path === "/api/bots/alpha/lifecycle" && method === "POST") {
402
+ const command = JSON.parse(body ?? "") as {
403
+ commandId: string;
404
+ botId: string;
405
+ };
406
+ alphaStatus = "archived";
407
+ return Promise.resolve({
408
+ schemaVersion: 1,
409
+ commandId: command.commandId,
410
+ botId: command.botId,
411
+ status: "applied",
412
+ lifecycle: {
413
+ schemaVersion: 1,
414
+ botId: command.botId,
415
+ status: "archived",
416
+ revision: 1,
417
+ },
418
+ });
419
+ }
420
+ if (path === "/api/bots")
421
+ return Promise.resolve({
422
+ schemaVersion: 1,
423
+ revision: 2,
424
+ bots: ["alpha", "beta"].map((botId) => ({
425
+ schemaVersion: 1,
426
+ botId,
427
+ registeredAt: new Date(0).toISOString(),
428
+ initialName: botId,
429
+ sheep,
430
+ })),
431
+ });
432
+ if (path === "/api/bots/lifecycles")
433
+ return Promise.resolve({
434
+ schemaVersion: 1,
435
+ lifecycles: [
436
+ {
437
+ schemaVersion: 1,
438
+ botId: "alpha",
439
+ status: alphaStatus,
440
+ revision: alphaStatus === "archived" ? 1 : 0,
441
+ },
442
+ { schemaVersion: 1, botId: "beta", status: "active", revision: 0 },
443
+ ],
444
+ });
445
+ if (path.endsWith("/sheep")) {
446
+ const botId = path.split("/")[3]!;
447
+ return Promise.resolve({ schemaVersion: 1, botId, revision: 0, sheep });
448
+ }
449
+ return Promise.reject(new Error(`unexpected request: ${path}`));
450
+ });
451
+ const selected: string[] = [];
452
+ state.value.bindShell(
453
+ ref({
454
+ activeBotId: "alpha",
455
+ selectBot: (botId: string) => {
456
+ selected.push(botId);
457
+ return Promise.resolve();
458
+ },
459
+ }) as unknown as Ref<FrockBotWebData>,
460
+ );
461
+ state.value.openArchive("alpha");
462
+ expect(state.value.overlay).toBe("archive");
463
+ await state.value.archive();
464
+ expect(state.value.lifecycles.alpha).toBe("archived");
465
+ expect(selected.at(-1)).toBe("beta");
466
+ expect(new URL(location.href).searchParams.get("bot")).toBe("beta");
467
+ });
468
+
469
+ test("reconciles a lost sheep response and clears the exact pending command", async () => {
470
+ const storage = installStorage();
471
+ const original = randomSheepRecipeV1(() => 0);
472
+ const updated = randomSheepRecipeV1(() => 0.999);
473
+ const state = mount((path, method) => {
474
+ if (path === "/api/bots/identities")
475
+ return Promise.resolve({ schemaVersion: 1, identities: [] });
476
+ if (method === "POST") return Promise.reject(new Error("response lost"));
477
+ if (path === "/api/bots/alpha/sheep")
478
+ return Promise.resolve({
479
+ schemaVersion: 1,
480
+ botId: "alpha",
481
+ revision: 1,
482
+ sheep: updated,
483
+ });
484
+ return Promise.reject(new Error(`unexpected request: ${path}`));
485
+ });
486
+ state.value.bindShell(
487
+ ref({ activeBotId: "alpha" }) as unknown as Ref<FrockBotWebData>,
488
+ );
489
+ state.value.identities.alpha = {
490
+ schemaVersion: 1,
491
+ botId: "alpha",
492
+ revision: 0,
493
+ sheep: original,
494
+ };
495
+ state.value.draftSheep = updated;
496
+ state.value.overlay = "edit";
497
+ await state.value.saveSheep();
498
+ expect(state.value.identities.alpha?.sheep).toEqual(updated);
499
+ expect(state.value.overlay).toBeUndefined();
500
+ expect(storage.has(pendingSheepKey("user-a", "alpha"))).toBe(false);
501
+ });
502
+ });