@frockbot/plugin-shell 0.1.3 → 0.2.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 (41) hide show
  1. package/frockbot.json +4 -0
  2. package/package.json +30 -28
  3. package/src/agent.ts +10 -13
  4. package/src/backend-authoring.test.ts +356 -2
  5. package/src/backend-authoring.ts +585 -20
  6. package/src/backend-composition-input.test.ts +65 -0
  7. package/src/backend-composition-input.ts +58 -0
  8. package/src/backend-composition.ts +23 -5
  9. package/src/backend-configuration.test.ts +549 -1468
  10. package/src/backend-contracts.test.ts +28 -0
  11. package/src/backend-contracts.ts +3 -1
  12. package/src/backend-execution.ts +0 -4
  13. package/src/backend-iframe-ui.test.ts +131 -0
  14. package/src/backend-image.test.ts +8 -8
  15. package/src/backend-image.ts +13 -13
  16. package/src/backend-isolate.test.ts +230 -89
  17. package/src/backend-isolate.ts +103 -200
  18. package/src/backend-package-catalog.test.ts +451 -0
  19. package/src/backend-package-catalog.ts +923 -0
  20. package/src/backend-recovery-integration.test.ts +17 -67
  21. package/src/backend-routines.ts +1 -1
  22. package/src/backend-runner-iframe.test.ts +74 -0
  23. package/src/backend-runner.ts +192 -0
  24. package/src/backend.ts +1198 -1781
  25. package/src/client/FrockBotApp.vue +44 -73
  26. package/src/client/PackageIframeHost.vue +218 -0
  27. package/src/client/PackageIframeSettings.vue +52 -0
  28. package/src/client/SendPayloadView.vue +0 -60
  29. package/src/client/index.test.ts +439 -380
  30. package/src/client/index.ts +163 -257
  31. package/src/client/model-presentation.test.ts +21 -8
  32. package/src/client/model-presentation.ts +14 -7
  33. package/src/client/package-iframe-host-message.test.ts +41 -0
  34. package/src/client/package-iframe-host-message.ts +27 -0
  35. package/src/client/styles.css +0 -27
  36. package/src/composition-views.ts +54 -0
  37. package/src/settings-links.test.ts +2 -10
  38. package/src/settings-links.ts +1 -13
  39. package/src/shared.ts +10 -13
  40. package/src/backend-assignment.test.ts +0 -161
  41. package/src/backend-assignment.ts +0 -274
@@ -173,7 +173,7 @@ describe("application manifest protocol", () => {
173
173
  ],
174
174
  }),
175
175
  ).toEqual([]);
176
- // A tool Package a User installs and assigns with no credential at all:
176
+ // A tool Package a User enables with no credential at all:
177
177
  // one Capability, no Connection Type. It stays in the catalog, because
178
178
  // needing no Connection is not the same as offering nothing.
179
179
  expect(
@@ -202,6 +202,55 @@ describe("application manifest protocol", () => {
202
202
  connectionTypes: [],
203
203
  }),
204
204
  ]);
205
+ // A settings-only Package still has User-visible enablement: turning it on
206
+ // is what makes its otherwise-inert controls available.
207
+ expect(
208
+ decodePluginCatalog({
209
+ ...emptyManifest,
210
+ packages: [
211
+ {
212
+ id: "custom-models",
213
+ displayName: "Custom models",
214
+ version: "0.0.1",
215
+ contributions: ["client"],
216
+ configuration: {
217
+ settings: [
218
+ {
219
+ id: "account-model",
220
+ schemaVersion: 1,
221
+ scopes: ["user"],
222
+ role: "model",
223
+ schema: {
224
+ type: "object",
225
+ properties: {
226
+ connectionId: { type: "string" },
227
+ providerModelId: { type: "string" },
228
+ },
229
+ required: ["connectionId", "providerModelId"],
230
+ additionalProperties: false,
231
+ },
232
+ },
233
+ ],
234
+ connectionTypes: [],
235
+ capabilities: [],
236
+ },
237
+ },
238
+ ],
239
+ }),
240
+ ).toEqual([
241
+ expect.objectContaining({
242
+ packageId: "custom-models",
243
+ capabilities: [],
244
+ connectionTypes: [],
245
+ settings: [
246
+ expect.objectContaining({
247
+ id: "account-model",
248
+ scopes: ["user"],
249
+ role: "model",
250
+ }),
251
+ ],
252
+ }),
253
+ ]);
205
254
  // The same Package with a turn-type admission ceiling: manifest v4 is what
206
255
  // the catalog wraps the served configuration as, so the field decodes.
207
256
  expect(
@@ -289,121 +338,6 @@ describe("composer hydration context", () => {
289
338
  });
290
339
  });
291
340
 
292
- describe("hosted Assignment commands", () => {
293
- test("uses distinct atomic Assign, Replace, and Unassign commands", async () => {
294
- let provided: Ref<FrockBotWebData> | undefined;
295
- const commands: unknown[] = [];
296
- await shellClientPlugin({
297
- transport: {
298
- turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
299
- readConfiguration: () =>
300
- Promise.resolve(initializeBotSettingsV1("primary")),
301
- executeConfiguration: (command) => {
302
- commands.push(command);
303
- return Promise.resolve({
304
- schemaVersion: 1,
305
- commandId: command.commandId,
306
- revision: command.expectedRevision + 1,
307
- status: "applied",
308
- });
309
- },
310
- },
311
- slot: () => () => {},
312
- inject: () => {
313
- throw new Error("unexpected client provider injection");
314
- },
315
- provide: (_key, value) => {
316
- provided = value as Ref<FrockBotWebData>;
317
- return () => {};
318
- },
319
- });
320
- if (!provided) throw new Error("shell data was not provided");
321
- provided.value.activeBotId = "primary";
322
- provided.value.botSettings = initializeBotSettingsV1("primary");
323
- const assignment = {
324
- assignmentId: "mail",
325
- packageId: "mail",
326
- capabilityId: "send",
327
- connectionId: "mail-1",
328
- };
329
- await provided.value.assignCapability(assignment);
330
- provided.value.botSettings = {
331
- ...initializeBotSettingsV1("primary"),
332
- revision: 1,
333
- };
334
- await provided.value.replaceCapability(assignment);
335
- provided.value.botSettings = {
336
- ...initializeBotSettingsV1("primary"),
337
- revision: 2,
338
- };
339
- await provided.value.unassignCapability("mail");
340
- expect(commands).toMatchObject([
341
- { type: "bot/assign-capability", expectedRevision: 0, assignment },
342
- { type: "bot/replace-capability", expectedRevision: 1, assignment },
343
- {
344
- type: "bot/unassign-capability",
345
- expectedRevision: 2,
346
- assignmentId: "mail",
347
- },
348
- ]);
349
- });
350
-
351
- test("surfaces rejected and retrying Assignment receipts", async () => {
352
- let provided: Ref<FrockBotWebData> | undefined;
353
- const receipts = [
354
- {
355
- schemaVersion: 1 as const,
356
- commandId: "rejected",
357
- revision: 0,
358
- status: "rejected" as const,
359
- failure: "Connection is unavailable",
360
- },
361
- {
362
- schemaVersion: 1 as const,
363
- commandId: "pending",
364
- revision: 0,
365
- status: "pending" as const,
366
- },
367
- ];
368
- await shellClientPlugin({
369
- transport: {
370
- turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
371
- readConfiguration: () =>
372
- Promise.resolve(initializeBotSettingsV1("primary")),
373
- executeConfiguration: () => Promise.resolve(receipts.shift()!),
374
- },
375
- slot: () => () => {},
376
- inject: () => {
377
- throw new Error("unexpected client provider injection");
378
- },
379
- provide: (_key, value) => {
380
- provided = value as Ref<FrockBotWebData>;
381
- return () => {};
382
- },
383
- });
384
- if (!provided) throw new Error("shell data was not provided");
385
- provided.value.activeBotId = "primary";
386
- provided.value.botSettings = initializeBotSettingsV1("primary");
387
- const assignment = {
388
- assignmentId: "mail",
389
- packageId: "mail",
390
- capabilityId: "send",
391
- connectionId: "mail-1",
392
- };
393
-
394
- await expect(provided.value.assignCapability(assignment)).rejects.toThrow(
395
- "Connection is unavailable",
396
- );
397
- expect(provided.value.settingsError).toBe("Connection is unavailable");
398
- await expect(provided.value.assignCapability(assignment)).resolves.toBe(
399
- undefined,
400
- );
401
- expect(provided.value.settingsError).toBe(
402
- "Assignment operation is retrying.",
403
- );
404
- });
405
- });
406
-
407
341
  describe("Bot selection", () => {
408
342
  test("passes explicit Bot IDs and ignores stale hydration", async () => {
409
343
  Object.defineProperty(globalThis, "window", {
@@ -461,6 +395,125 @@ describe("Bot selection", () => {
461
395
  expect(provided.value.activeBotId).toBe("new");
462
396
  expect(provided.value.botSettings?.botId).toBe("new");
463
397
  });
398
+
399
+ test("loads sandboxed Package UI and transports only declared iframe tools", async () => {
400
+ Object.defineProperty(globalThis, "window", {
401
+ configurable: true,
402
+ value: {
403
+ location: { href: "https://app.example/" },
404
+ history: { replaceState: () => undefined },
405
+ },
406
+ });
407
+ const contribution = {
408
+ packageId: "weather-page",
409
+ displayName: "Sydney Weather",
410
+ provenance: "Bot-authored" as const,
411
+ artifact: {
412
+ contentHash: "a".repeat(64),
413
+ size: 1,
414
+ mediaType: "text/html" as const,
415
+ bundlerVersion: "frockbot-inline-html@1",
416
+ },
417
+ mounts: [{ slot: "frockbot.bot-settings-sections", order: 20 }],
418
+ declaredTools: ["weather_lookup"],
419
+ };
420
+ const requests: Array<{
421
+ path: string;
422
+ method?: string;
423
+ body?: string;
424
+ }> = [];
425
+ const slots: string[] = [];
426
+ let provided: Ref<FrockBotWebData> | undefined;
427
+ await shellClientPlugin({
428
+ transport: {
429
+ turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
430
+ readConfiguration: (query) => {
431
+ if (query.type !== "bot/get")
432
+ throw new Error("unexpected User query");
433
+ return Promise.resolve(initializeBotSettingsV1(query.botId));
434
+ },
435
+ hostedRequest: (path, method, body) => {
436
+ requests.push({ path, method, body });
437
+ if (path.endsWith("/package-ui")) {
438
+ return Promise.resolve({
439
+ schemaVersion: 1,
440
+ botId: "primary",
441
+ generationId: "generation-ui",
442
+ artifactOrigin: "https://ui.app.example",
443
+ contributions: [contribution],
444
+ });
445
+ }
446
+ if (path.endsWith("/package-ui/tools")) {
447
+ return Promise.resolve({
448
+ schemaVersion: 1,
449
+ runId: "run-iframe-tool",
450
+ text: "Sydney Weather",
451
+ events: [],
452
+ });
453
+ }
454
+ return Promise.resolve({});
455
+ },
456
+ },
457
+ slot: (registration) => {
458
+ slots.push(registration.slot);
459
+ return () => {};
460
+ },
461
+ inject: () => {
462
+ throw new Error("unexpected client provider injection");
463
+ },
464
+ provide: (_key, value) => {
465
+ provided = value as Ref<FrockBotWebData>;
466
+ return () => {};
467
+ },
468
+ });
469
+ if (!provided) throw new Error("shell data was not provided");
470
+ provided.value.userSettings = {
471
+ schemaVersion: 1,
472
+ revision: 1,
473
+ profile: { name: "User" },
474
+ packages: [],
475
+ connections: [],
476
+ };
477
+
478
+ await provided.value.selectBot("primary");
479
+
480
+ expect(slots).toContain("frockbot.bot-settings-sections");
481
+ expect(requests.filter(({ path }) => path.endsWith("/package-ui"))).toEqual(
482
+ [
483
+ {
484
+ path: "/api/bots/primary/package-ui",
485
+ method: undefined,
486
+ body: undefined,
487
+ },
488
+ ],
489
+ );
490
+ expect(provided.value.packageUi?.contributions).toEqual([contribution]);
491
+
492
+ expect(
493
+ await provided.value.callPackageUiTool(contribution, "weather_lookup", {
494
+ city: "Sydney",
495
+ }),
496
+ ).toEqual({ content: "Sydney Weather", isError: false });
497
+ const toolRequest = requests.find(({ path }) =>
498
+ path.endsWith("/package-ui/tools"),
499
+ );
500
+ expect(toolRequest?.method).toBe("POST");
501
+ expect(JSON.parse(toolRequest?.body ?? "null")).toEqual({
502
+ schemaVersion: 1,
503
+ commandId: expect.any(String),
504
+ generationId: "generation-ui",
505
+ packageId: "weather-page",
506
+ name: "weather_lookup",
507
+ input: { city: "Sydney" },
508
+ });
509
+
510
+ const requestCount = requests.length;
511
+ await expect(
512
+ provided.value.callPackageUiTool(contribution, "package_author", {}),
513
+ ).rejects.toThrow("did not declare");
514
+ expect(requests).toHaveLength(requestCount);
515
+ });
516
+
464
517
  test("preserves load failures and ignores stale User settings", async () => {
465
518
  let provided: Ref<FrockBotWebData> | undefined;
466
519
  const older = Promise.withResolvers<UserSettingsViewV1>();
@@ -576,197 +629,218 @@ describe("Bot selection", () => {
576
629
  expect(provided.value.userSettings?.profile.name).toBe("Newer");
577
630
  });
578
631
 
579
- test("labels an explicitly bound Ollama Bot by its provider", async () => {
580
- let provided: Ref<FrockBotWebData> | undefined;
581
- const bot = {
582
- ...initializeBotSettingsV1("ollama-bot"),
583
- model: {
584
- connectionId: "ollama-work",
585
- providerModelId: "glm-5.3-flash:cloud",
586
- },
587
- assignments: [
588
- {
589
- assignmentId: "ollama-model",
590
- packageId: "provider-ollama-cloud",
591
- capabilityId: "ollama-cloud-models",
592
- connectionId: "ollama-work",
593
- state: "enabled" as const,
594
- },
595
- ],
596
- };
597
- const user: UserSettingsViewV1 = {
598
- schemaVersion: 1,
599
- revision: 1,
600
- profile: { name: "User" },
601
- packages: [
602
- {
603
- packageId: "provider-ollama-cloud",
604
- version: "0.0.1",
605
- state: "installed",
606
- },
607
- ],
608
- connections: [
609
- {
610
- connectionId: "ollama-work",
611
- packageId: "provider-ollama-cloud",
612
- connectionTypeId: "ollama-cloud-account",
613
- displayName: "Work",
614
- state: "ready",
615
- providerType: "ollama-cloud",
616
- safeMetadata: {},
632
+ test.each([
633
+ {
634
+ source: "bot" as const,
635
+ botValues: {
636
+ "custom-models": {
637
+ model: {
638
+ connectionId: "model-connection",
639
+ providerModelId: "model-id",
640
+ },
617
641
  },
618
- ],
619
- };
620
- await shellClientPlugin({
621
- transport: {
622
- turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
623
- readConfiguration: (query) =>
624
- Promise.resolve(query.type === "user/get" ? user : bot),
625
- },
626
- slot: () => () => {},
627
- inject: () => {
628
- throw new Error("unexpected client provider injection");
629
642
  },
630
- provide: (_key, value) => {
631
- provided = value as Ref<FrockBotWebData>;
632
- return () => {};
643
+ accountValues: undefined,
644
+ platformModel: undefined,
645
+ expectedLabel: "Model name · Model provider · Bot override",
646
+ expectedProjection: "bot",
647
+ ready: true,
648
+ },
649
+ {
650
+ source: "account" as const,
651
+ botValues: {},
652
+ accountValues: {
653
+ model: {
654
+ connectionId: "model-connection",
655
+ providerModelId: "model-id",
656
+ },
633
657
  },
634
- });
635
- if (!provided) throw new Error("shell data was not provided");
636
- provided.value.activeBotId = bot.botId;
637
- provided.value.pluginCatalog = [
638
- {
639
- packageId: "provider-ollama-cloud",
640
- displayName: "Ollama Cloud",
641
- version: "0.0.1",
642
- capabilities: [
658
+ platformModel: undefined,
659
+ expectedLabel: "Model name · Model provider · Account model",
660
+ expectedProjection: "default",
661
+ ready: true,
662
+ },
663
+ {
664
+ source: "platform" as const,
665
+ botValues: {},
666
+ accountValues: undefined,
667
+ platformModel: {
668
+ connectionId: "model-connection",
669
+ providerModelId: "model-id",
670
+ },
671
+ expectedLabel: "Model name · Model provider",
672
+ expectedProjection: "default",
673
+ ready: true,
674
+ },
675
+ {
676
+ source: "none" as const,
677
+ botValues: {},
678
+ accountValues: undefined,
679
+ platformModel: undefined,
680
+ expectedLabel: "Model unavailable",
681
+ expectedProjection: "none",
682
+ ready: false,
683
+ },
684
+ ])(
685
+ "labels the $source effective model",
686
+ async ({
687
+ botValues,
688
+ accountValues,
689
+ platformModel,
690
+ expectedLabel,
691
+ expectedProjection,
692
+ ready,
693
+ }) => {
694
+ let provided: Ref<FrockBotWebData> | undefined;
695
+ const bot = {
696
+ ...initializeBotSettingsV1("model-bot"),
697
+ packageValues: structuredClone(botValues) as Record<
698
+ string,
699
+ Record<string, unknown>
700
+ >,
701
+ };
702
+ const user: UserSettingsViewV1 = {
703
+ schemaVersion: 1,
704
+ revision: 1,
705
+ profile: { name: "User" },
706
+ packages: [
643
707
  {
644
- id: "ollama-cloud-models",
645
- kind: "model",
646
- connectionTypes: ["ollama-cloud-account"],
708
+ packageId: "custom-models",
709
+ version: "0.0.1",
710
+ state: "installed",
711
+ ...(accountValues ? { values: accountValues } : {}),
647
712
  },
648
713
  {
649
- id: "ollama-cloud-tools",
650
- kind: "tool",
651
- connectionTypes: ["ollama-cloud-account"],
714
+ packageId: "model-provider",
715
+ version: "0.0.1",
716
+ state: "installed",
652
717
  },
653
718
  ],
654
- connectionTypes: [
719
+ connections: [
655
720
  {
656
- id: "ollama-cloud-account",
657
- displayName: "Ollama Cloud account",
658
- allowMultiple: true,
659
- authorizationKind: "api-key",
660
- capabilities: ["ollama-cloud-models", "ollama-cloud-tools"],
721
+ connectionId: "model-connection",
722
+ packageId: "model-provider",
723
+ connectionTypeId: "model-account",
724
+ displayName: "Work",
725
+ state: "ready",
726
+ providerType: "model-provider",
727
+ safeMetadata: {},
728
+ modelCatalog: {
729
+ schemaVersion: 1,
730
+ generation: "catalog-1",
731
+ state: "fresh",
732
+ models: [
733
+ {
734
+ providerModelId: "model-id",
735
+ displayName: "Model name",
736
+ capabilities: {
737
+ tools: true,
738
+ vision: false,
739
+ reasoning: false,
740
+ },
741
+ source: "discovered",
742
+ },
743
+ ],
744
+ },
661
745
  },
662
746
  ],
663
- },
664
- ];
665
-
666
- await provided.value.loadBotSettings();
667
- await provided.value.loadUserSettings();
668
-
669
- expect(provided.value.modelLabel).toBe(
670
- "glm-5.3-flash:cloud · Ollama Cloud",
671
- );
672
- expect(provided.value.modelReady).toBe(true);
673
- expect(provided.value.modelSource).toBe("bot");
674
-
675
- bot.assignments[0] = {
676
- ...bot.assignments[0]!,
677
- capabilityId: "ollama-cloud-tools",
678
- };
679
- await provided.value.loadBotSettings();
680
- expect(provided.value.modelReady).toBe(false);
681
-
682
- // A Bot without a model of its own follows the User's default, and the
683
- // label never says so: the Bot simply runs on that model.
684
- bot.model = undefined as unknown as typeof bot.model;
685
- user.newBotModelTemplate = {
686
- connectionId: "ollama-work",
687
- providerModelId: "llama-3:cloud",
688
- };
689
- user.newBotModelTemplateSource = "auto";
690
- await provided.value.loadUserSettings();
691
- await provided.value.loadBotSettings();
692
- expect(provided.value.modelSource).toBe("default");
693
- expect(provided.value.modelLabel).toBe("llama-3:cloud · Ollama Cloud");
694
- expect(provided.value.modelReady).toBe(true);
695
-
696
- user.newBotModelTemplate = undefined;
697
- user.newBotModelTemplateSource = undefined;
698
- await provided.value.loadUserSettings();
699
- expect(provided.value.modelSource).toBe("none");
700
- expect(provided.value.modelLabel).toBe("No default model");
701
- expect(provided.value.modelReady).toBe(false);
702
- });
703
-
704
- test("assigns a newly connected model capability before model selection", async () => {
705
- let provided: Ref<FrockBotWebData> | undefined;
706
- let bot = initializeBotSettingsV1("ollama-bot");
707
- const commands: Array<{ type: string; expectedRevision: number }> = [];
708
- const user: UserSettingsViewV1 = {
709
- schemaVersion: 1,
710
- revision: 1,
711
- profile: { name: "User" },
712
- packages: [
747
+ ...(platformModel ? { platformModel } : {}),
748
+ };
749
+ await shellClientPlugin({
750
+ transport: {
751
+ turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
752
+ readConfiguration: (query) =>
753
+ Promise.resolve(query.type === "user/get" ? user : bot),
754
+ },
755
+ slot: () => () => {},
756
+ inject: () => {
757
+ throw new Error("unexpected client provider injection");
758
+ },
759
+ provide: (_key, value) => {
760
+ provided = value as Ref<FrockBotWebData>;
761
+ return () => {};
762
+ },
763
+ });
764
+ if (!provided) throw new Error("shell data was not provided");
765
+ provided.value.activeBotId = bot.botId;
766
+ provided.value.pluginCatalog = [
713
767
  {
714
- packageId: "provider-ollama-cloud",
768
+ packageId: "custom-models",
769
+ displayName: "Custom models",
715
770
  version: "0.0.1",
716
- state: "installed",
771
+ capabilities: [],
772
+ connectionTypes: [],
773
+ settings: [
774
+ {
775
+ id: "model",
776
+ schemaVersion: 1,
777
+ scopes: ["user", "bot"],
778
+ role: "model",
779
+ schema: {
780
+ type: "object",
781
+ properties: {
782
+ connectionId: { type: "string" },
783
+ providerModelId: { type: "string" },
784
+ },
785
+ required: ["connectionId", "providerModelId"],
786
+ additionalProperties: false,
787
+ },
788
+ },
789
+ ],
717
790
  },
718
- ],
719
- connections: [
720
791
  {
721
- connectionId: "ollama-work",
722
- packageId: "provider-ollama-cloud",
723
- connectionTypeId: "ollama-cloud-account",
724
- displayName: "Work",
725
- state: "ready",
726
- providerType: "ollama-cloud",
727
- safeMetadata: {},
792
+ packageId: "model-provider",
793
+ displayName: "Model provider",
794
+ version: "0.0.1",
795
+ capabilities: [
796
+ {
797
+ id: "models",
798
+ kind: "model",
799
+ connectionTypes: ["model-account"],
800
+ },
801
+ ],
802
+ connectionTypes: [
803
+ {
804
+ id: "model-account",
805
+ displayName: "Model account",
806
+ allowMultiple: false,
807
+ authorizationKind: "api-key",
808
+ capabilities: ["models"],
809
+ },
810
+ ],
811
+ settings: [],
728
812
  },
729
- ],
813
+ ];
814
+
815
+ await provided.value.loadBotSettings();
816
+
817
+ expect(provided.value.modelLabel).toBe(expectedLabel);
818
+ expect(provided.value.modelReady).toBe(ready);
819
+ expect(provided.value.modelSource).toBe(expectedProjection);
820
+ },
821
+ );
822
+
823
+ test("saves Bot-scoped Package settings through the generic command", async () => {
824
+ type PackageSettingsWebData = FrockBotWebData & {
825
+ saveBotPackageSettings(
826
+ packageId: string,
827
+ values: Record<string, unknown>,
828
+ ): Promise<void>;
730
829
  };
830
+ let provided: Ref<PackageSettingsWebData> | undefined;
831
+ const commands: unknown[] = [];
832
+ const bot = initializeBotSettingsV1("package-bot");
731
833
  await shellClientPlugin({
732
834
  transport: {
733
835
  turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
734
- readConfiguration: (query) =>
735
- Promise.resolve(query.type === "user/get" ? user : bot),
836
+ readConfiguration: () => Promise.resolve(bot),
736
837
  executeConfiguration: (command) => {
737
- if (!("botId" in command)) throw new Error("unexpected User command");
738
- commands.push({
739
- type: command.type,
740
- expectedRevision: command.expectedRevision,
741
- });
742
- if (command.type === "bot/assign-capability") {
743
- bot = {
744
- ...bot,
745
- revision: 1,
746
- model: command.model,
747
- assignments: [{ ...command.assignment, state: "enabled" }],
748
- };
749
- } else if (command.type === "bot/select-model") {
750
- bot = { ...bot, revision: bot.revision + 1, model: command.model };
751
- } else if (command.type === "bot/unbind-model") {
752
- bot = {
753
- ...bot,
754
- revision: bot.revision + 1,
755
- model: undefined,
756
- assignments: bot.assignments.map((assignment) =>
757
- assignment.assignmentId === command.assignmentId
758
- ? { ...assignment, state: "unavailable" }
759
- : assignment,
760
- ),
761
- };
762
- } else {
763
- throw new Error("unexpected Bot command");
764
- }
838
+ commands.push(command);
765
839
  return Promise.resolve({
766
840
  schemaVersion: 1,
767
841
  commandId: command.commandId,
768
- revision: bot.revision,
769
- status: "applied" as const,
842
+ revision: command.expectedRevision + 1,
843
+ status: "applied",
770
844
  });
771
845
  },
772
846
  },
@@ -775,102 +849,52 @@ describe("Bot selection", () => {
775
849
  throw new Error("unexpected client provider injection");
776
850
  },
777
851
  provide: (_key, value) => {
778
- provided = value as Ref<FrockBotWebData>;
852
+ provided = value as Ref<PackageSettingsWebData>;
779
853
  return () => {};
780
854
  },
781
855
  });
782
856
  if (!provided) throw new Error("shell data was not provided");
783
857
  provided.value.activeBotId = bot.botId;
784
858
  provided.value.botSettings = bot;
785
- provided.value.userSettings = user;
786
- provided.value.pluginCatalog = [
787
- {
788
- packageId: "provider-ollama-cloud",
789
- displayName: "Ollama Cloud",
790
- version: "0.0.1",
791
- capabilities: [
792
- {
793
- id: "ollama-cloud-models",
794
- kind: "model",
795
- connectionTypes: ["ollama-cloud-account"],
796
- },
797
- ],
798
- connectionTypes: [
799
- {
800
- id: "ollama-cloud-account",
801
- displayName: "Ollama Cloud account",
802
- allowMultiple: true,
803
- authorizationKind: "api-key",
804
- capabilities: ["ollama-cloud-models"],
805
- },
806
- ],
807
- },
808
- ];
809
859
 
810
- await provided.value.saveBotModel({
811
- connectionId: "ollama-work",
812
- providerModelId: "glm-5.3-flash:cloud",
813
- });
814
-
815
- expect(commands).toEqual([
816
- { type: "bot/assign-capability", expectedRevision: 0 },
817
- ]);
818
- expect(bot).toMatchObject({
819
- revision: 1,
860
+ await provided.value.saveBotPackageSettings("custom-models", {
820
861
  model: {
821
- connectionId: "ollama-work",
822
- providerModelId: "glm-5.3-flash:cloud",
862
+ connectionId: "model-connection",
863
+ providerModelId: "model-id",
823
864
  },
824
- assignments: [
825
- {
826
- packageId: "provider-ollama-cloud",
827
- capabilityId: "ollama-cloud-models",
828
- connectionId: "ollama-work",
829
- state: "enabled",
830
- },
831
- ],
832
865
  });
833
866
 
834
- bot = {
835
- ...bot,
836
- assignments: bot.assignments.map((assignment) => ({
837
- ...assignment,
838
- state: "unavailable",
839
- })),
840
- };
841
- provided.value.botSettings = bot;
842
-
843
- await provided.value.clearBotModel();
844
-
845
- expect(commands.at(-1)).toEqual({
846
- type: "bot/unbind-model",
847
- expectedRevision: 1,
848
- });
849
- expect(bot).toMatchObject({
850
- revision: 2,
851
- model: undefined,
852
- assignments: [{ state: "unavailable" }],
853
- });
867
+ expect(commands).toMatchObject([
868
+ {
869
+ type: "bot/set-package-settings",
870
+ botId: "package-bot",
871
+ packageId: "custom-models",
872
+ expectedRevision: 0,
873
+ values: {
874
+ model: {
875
+ connectionId: "model-connection",
876
+ providerModelId: "model-id",
877
+ },
878
+ },
879
+ },
880
+ ]);
854
881
  });
855
882
 
856
- test("does not resubmit an unchanged unavailable model", async () => {
883
+ test("shows the backend reason when Package enablement is refused", async () => {
857
884
  let provided: Ref<FrockBotWebData> | undefined;
858
- const bot = {
859
- ...initializeBotSettingsV1("ollama-bot"),
860
- model: {
861
- connectionId: "revoked-connection",
862
- providerModelId: "glm-5.3-flash:cloud",
863
- },
864
- assignments: [],
865
- };
866
- let executed = false;
885
+ const failure =
886
+ 'Enable dependency "custom-models" before enabling "provider-ollama-cloud"';
867
887
  await shellClientPlugin({
868
888
  transport: {
869
889
  turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
870
- executeConfiguration: () => {
871
- executed = true;
872
- throw new Error("model was resubmitted");
873
- },
890
+ executeConfiguration: (command) =>
891
+ Promise.resolve({
892
+ schemaVersion: 1,
893
+ commandId: command.commandId,
894
+ revision: command.expectedRevision,
895
+ status: "rejected",
896
+ failure,
897
+ }),
874
898
  },
875
899
  slot: () => () => {},
876
900
  inject: () => {
@@ -882,19 +906,24 @@ describe("Bot selection", () => {
882
906
  },
883
907
  });
884
908
  if (!provided) throw new Error("shell data was not provided");
885
- provided.value.activeBotId = bot.botId;
886
- provided.value.botSettings = bot;
887
909
  provided.value.userSettings = {
888
910
  schemaVersion: 1,
889
- revision: 1,
911
+ revision: 2,
890
912
  profile: { name: "User" },
891
- packages: [],
913
+ packages: [
914
+ {
915
+ packageId: "provider-ollama-cloud",
916
+ version: "0.0.1",
917
+ state: "disabled",
918
+ },
919
+ ],
892
920
  connections: [],
893
921
  };
894
922
 
895
- await provided.value.saveBotModel(bot.model);
896
-
897
- expect(executed).toBe(false);
923
+ await expect(
924
+ provided.value.setPackageEnabled("provider-ollama-cloud", true),
925
+ ).rejects.toThrow(failure);
926
+ expect(provided.value.settingsError).toBe(failure);
898
927
  });
899
928
  });
900
929
 
@@ -984,7 +1013,7 @@ describe("detached Turn projection", () => {
984
1013
  input: "Keep working",
985
1014
  events: [],
986
1015
  status: "completed",
987
- responseText: "Finished durably.",
1016
+ responseText: "Finished successfully.",
988
1017
  },
989
1018
  ],
990
1019
  );
@@ -992,7 +1021,7 @@ describe("detached Turn projection", () => {
992
1021
  expect(messages).toHaveLength(2);
993
1022
  expect(messages[1]).toMatchObject({
994
1023
  role: "assistant",
995
- text: "Finished durably.",
1024
+ text: "Finished successfully.",
996
1025
  status: "completed",
997
1026
  });
998
1027
  });
@@ -1423,8 +1452,18 @@ describe("active durable Turn projection", () => {
1423
1452
  await shellClientPlugin({
1424
1453
  transport: {
1425
1454
  turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
1426
- readConfiguration: () =>
1427
- Promise.resolve(initializeBotSettingsV1("default")),
1455
+ readConfiguration: (query) =>
1456
+ Promise.resolve(
1457
+ query.type === "bot/get"
1458
+ ? initializeBotSettingsV1("default")
1459
+ : {
1460
+ schemaVersion: 1 as const,
1461
+ revision: 0,
1462
+ profile: { name: "User" },
1463
+ packages: [],
1464
+ connections: [],
1465
+ },
1466
+ ),
1428
1467
  listRuns: () =>
1429
1468
  Promise.resolve([
1430
1469
  {
@@ -1583,8 +1622,18 @@ describe("hosted Stop", () => {
1583
1622
  await shellClientPlugin({
1584
1623
  transport: {
1585
1624
  turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
1586
- readConfiguration: () =>
1587
- Promise.resolve(initializeBotSettingsV1("default")),
1625
+ readConfiguration: (query) =>
1626
+ Promise.resolve(
1627
+ query.type === "bot/get"
1628
+ ? initializeBotSettingsV1("default")
1629
+ : {
1630
+ schemaVersion: 1 as const,
1631
+ revision: 0,
1632
+ profile: { name: "User" },
1633
+ packages: [],
1634
+ connections: [],
1635
+ },
1636
+ ),
1588
1637
  listRuns: () =>
1589
1638
  Promise.resolve([
1590
1639
  {
@@ -1619,7 +1668,7 @@ describe("hosted Stop", () => {
1619
1668
  expect(provided.value.activeRun).toMatchObject({
1620
1669
  runId: "run-1",
1621
1670
  status: "running",
1622
- message: "Stop accepted; waiting for durable settlement.",
1671
+ message: "Stop requested; finishing up.",
1623
1672
  canResume: false,
1624
1673
  });
1625
1674
 
@@ -1715,8 +1764,18 @@ describe("hosted Stop", () => {
1715
1764
  await shellClientPlugin({
1716
1765
  transport: {
1717
1766
  turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
1718
- readConfiguration: () =>
1719
- Promise.resolve(initializeBotSettingsV1("other")),
1767
+ readConfiguration: (query) =>
1768
+ Promise.resolve(
1769
+ query.type === "bot/get"
1770
+ ? initializeBotSettingsV1("other")
1771
+ : {
1772
+ schemaVersion: 1 as const,
1773
+ revision: 0,
1774
+ profile: { name: "User" },
1775
+ packages: [],
1776
+ connections: [],
1777
+ },
1778
+ ),
1720
1779
  listRuns: () => Promise.resolve([]),
1721
1780
  listNotifications: () => Promise.resolve([]),
1722
1781
  stopRun: () => {
@@ -1788,7 +1847,7 @@ describe("uncertain Turn admission", () => {
1788
1847
  input: "continue",
1789
1848
  status: lookups === 2 ? "running" : "completed",
1790
1849
  events: [],
1791
- ...(lookups === 2 ? {} : { responseText: "Done durably" }),
1850
+ ...(lookups === 2 ? {} : { responseText: "Done successfully" }),
1792
1851
  });
1793
1852
  },
1794
1853
  fenceRunAdmission: () =>
@@ -1822,7 +1881,7 @@ describe("uncertain Turn admission", () => {
1822
1881
  expect(provided.value.settingsError).toBeUndefined();
1823
1882
  expect(outstandingAbortListeners).toBe(0);
1824
1883
  expect(provided.value.messages.at(-1)).toMatchObject({
1825
- text: "Done durably",
1884
+ text: "Done successfully",
1826
1885
  status: "completed",
1827
1886
  });
1828
1887
  });