@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
@@ -17,7 +17,12 @@ import type {
17
17
  ConnectionCommandV1,
18
18
  } from "@frockbot/connection-core";
19
19
  import { decodeFrockBotManifest } from "@frockbot/kernel-composition";
20
- import { decodeSendToUserPayloadV1 } from "@frockbot/kernel-contracts";
20
+ import {
21
+ decodePackageIframeCatalogV1,
22
+ packageIframeToolAllowedV1,
23
+ decodeSendToUserPayloadV1,
24
+ type PackageIframeContributionViewV1,
25
+ } from "@frockbot/kernel-contracts";
21
26
  import { createClientSurfaceRegistry } from "@frockbot/client-ui";
22
27
  import type {
23
28
  BotNameProvenanceV1,
@@ -25,12 +30,11 @@ import type {
25
30
  BotProfile,
26
31
  BotProfilePatchV1,
27
32
  BotSettingsViewV1,
28
- ConfigurationCommandV1,
29
33
  JsonValue,
30
- ModelAssignment,
31
- OperationReceiptV1,
34
+ PackageSettingValueV1,
32
35
  UserSettingsViewV1,
33
36
  } from "@frockbot/configuration-core";
37
+ import { resolveEffectiveBotModelV1 } from "@frockbot/configuration-core";
34
38
  import {
35
39
  decodeCatalogEntryV1,
36
40
  decodeCatalogIndexV1,
@@ -49,6 +53,7 @@ import {
49
53
  import { MCP_OAUTH_CONNECTION_TYPE_ID } from "@frockbot/plugin-mcp/agent";
50
54
  import { decodeStartConnectionResultV1 } from "@frockbot/connection-core";
51
55
  import { decodeClientSkillCatalogV1 } from "../skill-protocol.js";
56
+ import { decodeClientTurnV1 } from "../run-protocol.js";
52
57
  import {
53
58
  decodeApprovalDecisionReceiptV1,
54
59
  decodeApprovalListViewV1,
@@ -57,7 +62,7 @@ import {
57
62
  decodeTaskListViewV1,
58
63
  decodeTaskViewV1,
59
64
  } from "@frockbot/plugin-subagents/shared";
60
- import { ref } from "vue";
65
+ import { ref, toRaw, type Ref } from "vue";
61
66
  import {
62
67
  frockBotWebDataKey,
63
68
  type FrockBotWebData,
@@ -70,6 +75,7 @@ import {
70
75
  type WebToolActivity,
71
76
  } from "../shared.js";
72
77
  import FrockBotApp from "./FrockBotApp.vue";
78
+ import PackageIframeSettings from "./PackageIframeSettings.vue";
73
79
  import { modelRuntimeLabel } from "./model-presentation.js";
74
80
  import { showClientNotificationV1 } from "./notify.js";
75
81
  import "@frockbot/client-core/fonts.css";
@@ -172,7 +178,7 @@ function activeRunView(run: ClientRun): WebActiveRun | undefined {
172
178
  return {
173
179
  runId: run.runId,
174
180
  status: run.status,
175
- message: "Stop accepted; waiting for durable settlement.",
181
+ message: "Stop requested; finishing up.",
176
182
  canResume: false,
177
183
  };
178
184
  }
@@ -660,11 +666,21 @@ export function decodePluginCatalog(value: unknown): PluginCatalogItem[] {
660
666
  connectionTypes: capability.connectionTypes,
661
667
  }),
662
668
  );
663
- // A Package with neither a Connection Type nor a Capability contributes
664
- // nothing the Plugins surface can install or assign. A Capability that
665
- // takes no Connection still counts: a tool Package a User installs and
666
- // assigns without any credential is exactly that shape.
667
- if (connectionTypes.length === 0 && decodedCapabilities.length === 0) {
669
+ // User- and Bot-scoped declarations are needed for generic effective
670
+ // model resolution. Connection-scoped settings stay with their Connection
671
+ // and never enter Package-level settings forms.
672
+ const settings = (decoded.configuration?.settings ?? []).filter((setting) =>
673
+ setting.scopes.some((scope) => scope === "user" || scope === "bot"),
674
+ );
675
+ // A settings-only Package still contributes enablement: disabling it is
676
+ // what makes its retained controls inert. A Capability that takes no
677
+ // Connection likewise counts because enabling its Package grants it to
678
+ // all of the User's Bots.
679
+ if (
680
+ connectionTypes.length === 0 &&
681
+ decodedCapabilities.length === 0 &&
682
+ settings.length === 0
683
+ ) {
668
684
  return [];
669
685
  }
670
686
  const decodedConnections = connectionTypes.map((connection) => {
@@ -688,12 +704,7 @@ export function decodePluginCatalog(value: unknown): PluginCatalogItem[] {
688
704
  version: candidate.version,
689
705
  capabilities: decodedCapabilities,
690
706
  connectionTypes: decodedConnections,
691
- // User-scoped settings only: a `bot`-scoped one is not the Plugins
692
- // surface's to edit, and a Connection-scoped one is edited with its
693
- // Connection.
694
- settings: (decoded.configuration?.settings ?? []).filter((setting) =>
695
- setting.scopes.includes("user"),
696
- ),
707
+ settings,
697
708
  },
698
709
  ];
699
710
  });
@@ -977,31 +988,6 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
977
988
  }
978
989
  }
979
990
 
980
- async function executeAssignmentOperation(
981
- command: Extract<
982
- ConfigurationCommandV1,
983
- {
984
- type:
985
- | "bot/assign-capability"
986
- | "bot/replace-capability"
987
- | "bot/unassign-capability";
988
- }
989
- >,
990
- ): Promise<void> {
991
- const execute = ctx.transport.executeConfiguration;
992
- if (!execute) throw new Error("Settings are unavailable");
993
- const receipt = (await execute(command)) as OperationReceiptV1;
994
- await web.value.loadBotSettings();
995
- if (receipt.status === "rejected") {
996
- const failure = receipt.failure ?? "Assignment operation was rejected";
997
- web.value.settingsError = failure;
998
- throw new Error(failure);
999
- }
1000
- if (receipt.status === "pending") {
1001
- web.value.settingsError = "Assignment operation is retrying.";
1002
- }
1003
- }
1004
-
1005
991
  function updateSettingsLoadError(
1006
992
  source: "bot" | "user" | "catalog" | "package-catalog",
1007
993
  message?: string,
@@ -1011,63 +997,55 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1011
997
  web.value.settingsError = [...settingsLoadErrors.values()].at(-1);
1012
998
  }
1013
999
 
1014
- /**
1015
- * The Bot runs on its own model when it has one and on the User's default
1016
- * otherwise, so readiness and the composer label follow the effective model.
1017
- * A Bot following the default is ready as soon as the User's Connection is:
1018
- * the Bot's own Assignment for that Connection is claimed durably when the
1019
- * Turn is admitted.
1020
- */
1000
+ /** Readiness and the composer label follow the generic effective model. */
1021
1001
  function updateModelLabel(): void {
1022
1002
  const bot = web.value.botSettings;
1023
1003
  const user = web.value.userSettings;
1024
- const model = bot?.model ?? user?.newBotModelTemplate;
1025
- web.value.modelSource = bot?.model ? "bot" : model ? "default" : "none";
1026
- const connection = (user?.connections ?? []).find(
1027
- (candidate) => candidate.connectionId === model?.connectionId,
1028
- );
1029
- const packageInstalled = (user?.packages ?? []).some(
1030
- (pkg) =>
1031
- pkg.packageId === connection?.packageId && pkg.state === "installed",
1004
+ if (!bot || !user) {
1005
+ web.value.modelSource = "none";
1006
+ web.value.modelReady = false;
1007
+ web.value.modelLabel = modelRuntimeLabel({ source: "none" });
1008
+ return;
1009
+ }
1010
+ const effective = resolveEffectiveBotModelV1({
1011
+ bot: toRaw(bot),
1012
+ user: toRaw(user),
1013
+ packages: toRaw(web.value.pluginCatalog).map((pkg) => ({
1014
+ packageId: pkg.packageId,
1015
+ version: pkg.version,
1016
+ settings: pkg.settings ?? [],
1017
+ capabilities: pkg.capabilities,
1018
+ connectionTypes: pkg.connectionTypes,
1019
+ })),
1020
+ });
1021
+ // `FrockBotWebData`'s source vocabulary is owned outside this lane. Until
1022
+ // it adopts the core's four sources, both inherited choices are its
1023
+ // existing `default` projection; the label still preserves the exact core
1024
+ // source below.
1025
+ web.value.modelSource =
1026
+ effective.source === "bot"
1027
+ ? "bot"
1028
+ : effective.source === "none"
1029
+ ? "none"
1030
+ : "default";
1031
+ web.value.modelReady = Boolean(
1032
+ effective.binding && effective.binding.state !== "unavailable",
1032
1033
  );
1034
+ const connection = effective.binding?.connection;
1033
1035
  const catalogPackage = web.value.pluginCatalog.find(
1034
- (pkg) => pkg.packageId === connection?.packageId,
1035
- );
1036
- const connectionType = catalogPackage?.connectionTypes.find(
1037
- (candidate) => candidate.id === connection?.connectionTypeId,
1038
- );
1039
- const modelCapabilities = new Set(
1040
- catalogPackage?.capabilities.flatMap((capability) =>
1041
- capability.kind === "model" &&
1042
- connectionType?.capabilities.includes(capability.id)
1043
- ? [capability.id]
1044
- : [],
1045
- ) ?? [],
1046
- );
1047
- const authorized =
1048
- web.value.modelSource === "bot"
1049
- ? Boolean(
1050
- bot?.assignments.some(
1051
- (assignment) =>
1052
- assignment.connectionId === model?.connectionId &&
1053
- assignment.packageId === connection?.packageId &&
1054
- assignment.state === "enabled" &&
1055
- modelCapabilities.has(assignment.capabilityId),
1056
- ),
1057
- )
1058
- : modelCapabilities.size > 0;
1059
- web.value.modelReady = Boolean(
1060
- model && connection?.state === "ready" && packageInstalled && authorized,
1036
+ (pkg) => pkg.packageId === effective.binding?.packageId,
1061
1037
  );
1062
1038
  const catalogModel = connection?.modelCatalog?.models.find(
1063
- (candidate) => candidate.providerModelId === model?.providerModelId,
1039
+ (candidate) =>
1040
+ candidate.providerModelId === effective.model?.providerModelId,
1064
1041
  );
1065
1042
  web.value.modelLabel = modelRuntimeLabel({
1043
+ source: effective.source,
1066
1044
  modelDisplayName: catalogModel?.displayName,
1067
- providerModelId: model?.providerModelId,
1045
+ providerModelId: effective.model?.providerModelId,
1068
1046
  packageDisplayName: catalogPackage?.displayName,
1069
1047
  connectionDisplayName: connection?.displayName,
1070
- hasModel: Boolean(model),
1048
+ failure: effective.binding?.failure,
1071
1049
  });
1072
1050
  }
1073
1051
 
@@ -1102,9 +1080,16 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1102
1080
  await web.value.loadMcpServers();
1103
1081
  }
1104
1082
 
1105
- const web = ref<FrockBotWebData>({
1083
+ type ShellWebData = FrockBotWebData & {
1084
+ saveBotPackageSettings(
1085
+ packageId: string,
1086
+ values: Record<string, PackageSettingValueV1>,
1087
+ ): Promise<void>;
1088
+ };
1089
+
1090
+ const web: Ref<ShellWebData> = ref({
1106
1091
  connection: "ready",
1107
- modelLabel: "No default model",
1092
+ modelLabel: "Model unavailable",
1108
1093
  modelReady: false,
1109
1094
  modelSource: "none",
1110
1095
  settingsAvailable: true,
@@ -1117,6 +1102,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1117
1102
  skillCatalog: [],
1118
1103
  approvals: [],
1119
1104
  tasks: [],
1105
+ packageUi: undefined,
1120
1106
  async selectBot(botId: string): Promise<void> {
1121
1107
  // Re-selecting the open Bot is not a switch: aborting the live Turn and
1122
1108
  // clearing the transcript would discard state the User is watching.
@@ -1135,6 +1121,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1135
1121
  web.value.skillCatalog = [];
1136
1122
  web.value.approvals = [];
1137
1123
  web.value.tasks = [];
1124
+ web.value.packageUi = undefined;
1138
1125
  const url = URL.parse(window.location.href);
1139
1126
  if (url) {
1140
1127
  url.searchParams.set("bot", botId);
@@ -1218,6 +1205,68 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1218
1205
  web.value.tasks = [];
1219
1206
  }
1220
1207
  },
1208
+ async loadPackageUi(): Promise<void> {
1209
+ const read = ctx.transport.hostedRequest;
1210
+ const botId = web.value.activeBotId;
1211
+ if (!read || !botId) return;
1212
+ const generation = selectionGeneration;
1213
+ try {
1214
+ const catalog = decodePackageIframeCatalogV1(
1215
+ await read(`/api/bots/${encodeURIComponent(botId)}/package-ui`),
1216
+ );
1217
+ if (
1218
+ generation !== selectionGeneration ||
1219
+ web.value.activeBotId !== botId
1220
+ )
1221
+ return;
1222
+ web.value.packageUi = catalog;
1223
+ } catch {
1224
+ if (
1225
+ generation === selectionGeneration &&
1226
+ web.value.activeBotId === botId
1227
+ ) {
1228
+ web.value.packageUi = undefined;
1229
+ }
1230
+ }
1231
+ },
1232
+ async callPackageUiTool(
1233
+ contribution: PackageIframeContributionViewV1,
1234
+ name: string,
1235
+ input: unknown,
1236
+ ): Promise<unknown> {
1237
+ const post = ctx.transport.hostedRequest;
1238
+ const botId = web.value.activeBotId;
1239
+ const catalog = web.value.packageUi;
1240
+ if (!post || !botId || !catalog || catalog.botId !== botId) {
1241
+ throw new Error("Package UI is unavailable");
1242
+ }
1243
+ if (!packageIframeToolAllowedV1(contribution, name)) {
1244
+ throw new Error(
1245
+ `Package "${contribution.packageId}" did not declare tool "${name}"`,
1246
+ );
1247
+ }
1248
+ const turn = decodeClientTurnV1(
1249
+ await post(
1250
+ `/api/bots/${encodeURIComponent(botId)}/package-ui/tools`,
1251
+ "POST",
1252
+ JSON.stringify({
1253
+ schemaVersion: 1,
1254
+ commandId: crypto.randomUUID(),
1255
+ generationId: catalog.generationId,
1256
+ packageId: contribution.packageId,
1257
+ name,
1258
+ input,
1259
+ }),
1260
+ ),
1261
+ );
1262
+ await deliverNotifications(botId);
1263
+ const result = turn.events.findLast(
1264
+ (event) => event.type === "tool/result",
1265
+ );
1266
+ return result?.type === "tool/result"
1267
+ ? { content: result.content, isError: result.isError === true }
1268
+ : { content: turn.text, isError: false };
1269
+ },
1221
1270
  /**
1222
1271
  * Explicit, authenticated cancellation of one subagent from the client.
1223
1272
  *
@@ -1298,8 +1347,8 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1298
1347
  )
1299
1348
  return;
1300
1349
  web.value.botSettings = settings;
1301
- // The effective model may be the User's default, so Bot readiness
1302
- // needs the User settings too. Loading them never fails this read:
1350
+ // The effective model may come from account or platform state, so Bot
1351
+ // readiness needs the User settings too. Loading them never fails this read:
1303
1352
  // `loadUserSettings` reports its own failure.
1304
1353
  if (!web.value.userSettings) await web.value.loadUserSettings();
1305
1354
  if (
@@ -1310,6 +1359,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1310
1359
  updateModelLabel();
1311
1360
  updateSettingsLoadError("bot");
1312
1361
  await deliverNotifications(botId, generation);
1362
+ await web.value.loadPackageUi();
1313
1363
  } catch (error) {
1314
1364
  if (
1315
1365
  generation !== selectionGeneration ||
@@ -1389,153 +1439,23 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1389
1439
  });
1390
1440
  await web.value.loadBotSettings();
1391
1441
  },
1392
- async assignCapability(assignment): Promise<void> {
1393
- const current = web.value.botSettings;
1394
- const botId = web.value.activeBotId;
1395
- if (!current || !botId || !ctx.transport.executeConfiguration) {
1396
- throw new Error("Settings are unavailable");
1397
- }
1398
- await executeAssignmentOperation({
1399
- schemaVersion: 1,
1400
- type: "bot/assign-capability",
1401
- commandId: crypto.randomUUID(),
1402
- botId,
1403
- expectedRevision: current.revision,
1404
- assignment,
1405
- });
1406
- },
1407
- async replaceCapability(assignment): Promise<void> {
1408
- const current = web.value.botSettings;
1409
- const botId = web.value.activeBotId;
1410
- if (!current || !botId || !ctx.transport.executeConfiguration) {
1411
- throw new Error("Settings are unavailable");
1412
- }
1413
- await executeAssignmentOperation({
1414
- schemaVersion: 1,
1415
- type: "bot/replace-capability",
1416
- commandId: crypto.randomUUID(),
1417
- botId,
1418
- expectedRevision: current.revision,
1419
- assignment,
1420
- });
1421
- },
1422
- async unassignCapability(assignmentId): Promise<void> {
1442
+ async saveBotPackageSettings(
1443
+ packageId: string,
1444
+ values: Record<string, PackageSettingValueV1>,
1445
+ ): Promise<void> {
1423
1446
  const current = web.value.botSettings;
1424
1447
  const botId = web.value.activeBotId;
1425
1448
  if (!current || !botId || !ctx.transport.executeConfiguration) {
1426
1449
  throw new Error("Settings are unavailable");
1427
1450
  }
1428
- await executeAssignmentOperation({
1429
- schemaVersion: 1,
1430
- type: "bot/unassign-capability",
1431
- commandId: crypto.randomUUID(),
1432
- botId,
1433
- expectedRevision: current.revision,
1434
- assignmentId,
1435
- });
1436
- },
1437
- async saveBotModel(model: ModelAssignment): Promise<void> {
1438
- const current = web.value.botSettings;
1439
- const user = web.value.userSettings;
1440
- const botId = web.value.activeBotId;
1441
- if (!current || !user || !botId || !ctx.transport.executeConfiguration) {
1442
- throw new Error("Settings are unavailable");
1443
- }
1444
- const modelChanged =
1445
- current.model?.connectionId !== model.connectionId ||
1446
- current.model?.providerModelId !== model.providerModelId;
1447
- const connection = user.connections.find(
1448
- (candidate) => candidate.connectionId === model.connectionId,
1449
- );
1450
- if (!modelChanged && connection?.state !== "ready") return;
1451
- const pkg = web.value.pluginCatalog.find(
1452
- (candidate) => candidate.packageId === connection?.packageId,
1453
- );
1454
- const connectionType = pkg?.connectionTypes.find(
1455
- (candidate) => candidate.id === connection?.connectionTypeId,
1456
- );
1457
- const capability = pkg?.capabilities.find(
1458
- (candidate) =>
1459
- candidate.kind === "model" &&
1460
- connectionType?.capabilities.includes(candidate.id),
1461
- );
1462
- if (!connection || connection.state !== "ready" || !pkg || !capability) {
1463
- throw new Error("The selected Connection has no model capability");
1464
- }
1465
- const assigned = current.assignments.some(
1466
- (assignment) =>
1467
- assignment.state === "enabled" &&
1468
- assignment.packageId === pkg.packageId &&
1469
- assignment.capabilityId === capability.id &&
1470
- assignment.connectionId === connection.connectionId,
1471
- );
1472
- if (assigned && !modelChanged) return;
1473
- if (!assigned) {
1474
- // The binding commits inside the Assignment saga's commit phase, so
1475
- // the Connection claim and the Bot's model are one durable unit. An
1476
- // existing model Assignment on another Connection is replaced
1477
- // atomically rather than unassigned and assigned again.
1478
- const superseded = current.assignments.find(
1479
- (assignment) =>
1480
- assignment.packageId === pkg.packageId &&
1481
- assignment.capabilityId === capability.id,
1482
- );
1483
- await executeAssignmentOperation({
1484
- schemaVersion: 1,
1485
- type: superseded ? "bot/replace-capability" : "bot/assign-capability",
1486
- commandId: crypto.randomUUID(),
1487
- botId,
1488
- expectedRevision: current.revision,
1489
- assignment: {
1490
- assignmentId: superseded?.assignmentId ?? crypto.randomUUID(),
1491
- packageId: pkg.packageId,
1492
- capabilityId: capability.id,
1493
- connectionId: connection.connectionId,
1494
- },
1495
- model,
1496
- });
1497
- return;
1498
- }
1499
1451
  const receipt = await ctx.transport.executeConfiguration({
1500
1452
  schemaVersion: 1,
1501
- type: "bot/select-model",
1453
+ type: "bot/set-package-settings",
1502
1454
  commandId: crypto.randomUUID(),
1503
1455
  botId,
1504
1456
  expectedRevision: current.revision,
1505
- model,
1506
- });
1507
- await web.value.loadBotSettings();
1508
- if (receipt.status === "rejected") {
1509
- throw new Error(receipt.failure);
1510
- }
1511
- },
1512
- async clearBotModel(): Promise<void> {
1513
- const current = web.value.botSettings;
1514
- const botId = web.value.activeBotId;
1515
- if (!current?.model || !botId || !ctx.transport.executeConfiguration) {
1516
- throw new Error("Settings are unavailable");
1517
- }
1518
- const assignment = current.assignments.find((candidate) => {
1519
- const capability = web.value.pluginCatalog
1520
- .find((pkg) => pkg.packageId === candidate.packageId)
1521
- ?.capabilities.find(
1522
- (declared) => declared.id === candidate.capabilityId,
1523
- );
1524
- return (
1525
- (candidate.state === "enabled" ||
1526
- candidate.state === "unavailable") &&
1527
- candidate.connectionId === current.model?.connectionId &&
1528
- capability?.kind === "model"
1529
- );
1530
- });
1531
- if (!assignment) throw new Error("Bot model assignment is unavailable");
1532
- const receipt = await ctx.transport.executeConfiguration({
1533
- schemaVersion: 1,
1534
- type: "bot/unbind-model",
1535
- commandId: crypto.randomUUID(),
1536
- botId,
1537
- expectedRevision: current.revision,
1538
- assignmentId: assignment.assignmentId,
1457
+ packageId,
1458
+ values,
1539
1459
  });
1540
1460
  await web.value.loadBotSettings();
1541
1461
  if (receipt.status === "rejected") throw new Error(receipt.failure);
@@ -1585,30 +1505,6 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1585
1505
  });
1586
1506
  await web.value.loadUserSettings();
1587
1507
  },
1588
- async saveDefaultModel(model: ModelAssignment | undefined): Promise<void> {
1589
- const settings = web.value.userSettings;
1590
- if (!settings || !ctx.transport.executeConfiguration) {
1591
- throw new Error("Settings are unavailable");
1592
- }
1593
- const current = settings.newBotModelTemplate;
1594
- if (
1595
- current?.connectionId === model?.connectionId &&
1596
- current?.providerModelId === model?.providerModelId &&
1597
- settings.newBotModelTemplateSource === "user"
1598
- ) {
1599
- return;
1600
- }
1601
- const receipt = await ctx.transport.executeConfiguration({
1602
- schemaVersion: 1,
1603
- type: "user/set-new-bot-model",
1604
- commandId: crypto.randomUUID(),
1605
- expectedRevision: settings.revision,
1606
- ...(model ? { model } : {}),
1607
- source: "user",
1608
- });
1609
- await web.value.loadUserSettings();
1610
- if (receipt.status === "rejected") throw new Error(receipt.failure);
1611
- },
1612
1508
  async loadPluginCatalog(): Promise<void> {
1613
1509
  if (
1614
1510
  !ctx.transport.readApplicationManifest ||
@@ -1846,7 +1742,10 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1846
1742
  // Enablement is projected onto the installation row the Plugins surface
1847
1743
  // renders, so the toggle reads back what the User authority recorded.
1848
1744
  await web.value.loadPluginCatalog();
1849
- if (receipt.status === "rejected") throw new Error(receipt.failure);
1745
+ if (receipt.status === "rejected") {
1746
+ web.value.settingsError = receipt.failure;
1747
+ throw new Error(receipt.failure);
1748
+ }
1850
1749
  },
1851
1750
  async savePackageSettings(
1852
1751
  packageId: string,
@@ -2187,7 +2086,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2187
2086
  runId: pendingRunId,
2188
2087
  role: "assistant",
2189
2088
  text: aborted
2190
- ? "Request stopped locally; admission may still be durable."
2089
+ ? "Request stopped locally; checking whether it started."
2191
2090
  : "Confirming whether this Turn was admitted.",
2192
2091
  at: optimisticAt,
2193
2092
  status: "interrupted",
@@ -2246,7 +2145,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2246
2145
  web.value.activeRun = {
2247
2146
  runId,
2248
2147
  status: "running",
2249
- message: "Reconciliation requested; waiting for durable progress.",
2148
+ message: "Reconciliation requested; checking progress.",
2250
2149
  canResume: false,
2251
2150
  };
2252
2151
  const botId = web.value.activeBotId;
@@ -2314,16 +2213,23 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2314
2213
  async abort() {
2315
2214
  activeRequest?.abort();
2316
2215
  },
2317
- });
2216
+ } satisfies Partial<ShellWebData>) as unknown as Ref<ShellWebData>;
2318
2217
 
2319
2218
  return [
2320
2219
  ctx.provide(clientSurfaceRegistryKey, surfaces),
2321
- ctx.provide(frockBotWebDataKey, web),
2220
+ // The shared client projection is updated by the contracts lane. This cast
2221
+ // is the seam between its retired methods and this lane's replacement.
2222
+ ctx.provide(frockBotWebDataKey, web as unknown as Ref<FrockBotWebData>),
2322
2223
  ctx.slot({
2323
2224
  slot: "authenticated-root",
2324
2225
  order: 10_000,
2325
2226
  component: FrockBotApp,
2326
2227
  }),
2228
+ ctx.slot({
2229
+ slot: "frockbot.bot-settings-sections",
2230
+ order: 10_000,
2231
+ component: PackageIframeSettings,
2232
+ }),
2327
2233
  () => {
2328
2234
  activeRequest?.abort();
2329
2235
  admissionObserver?.abort();
@@ -2,34 +2,47 @@ import { describe, expect, test } from "bun:test";
2
2
  import { modelRuntimeLabel } from "./model-presentation.js";
3
3
 
4
4
  describe("model runtime presentation", () => {
5
- test("names the model and its provider Package", () => {
5
+ test("names platform models plainly", () => {
6
6
  expect(
7
7
  modelRuntimeLabel({
8
+ source: "platform",
8
9
  modelDisplayName: "Llama 3",
9
10
  providerModelId: "llama-3:cloud",
10
11
  packageDisplayName: "Ollama Cloud",
11
12
  connectionDisplayName: "Work",
12
- hasModel: true,
13
13
  }),
14
14
  ).toBe("Llama 3 · Ollama Cloud");
15
15
  expect(
16
16
  modelRuntimeLabel({
17
+ source: "platform",
17
18
  providerModelId: "llama-3:cloud",
18
19
  connectionDisplayName: "Custom provider",
19
- hasModel: true,
20
20
  }),
21
21
  ).toBe("llama-3:cloud · Custom provider");
22
22
  });
23
23
 
24
- test("reads the same whether the model is the Bot's or the User default", () => {
24
+ test("distinguishes opt-in account and Bot choices", () => {
25
25
  const label = {
26
26
  modelDisplayName: "Llama 3",
27
+ providerModelId: "llama-3:cloud",
27
28
  packageDisplayName: "Ollama Cloud",
28
- hasModel: true,
29
29
  };
30
- expect(modelRuntimeLabel(label)).toBe("Llama 3 · Ollama Cloud");
31
- expect(modelRuntimeLabel({ ...label, hasModel: false })).toBe(
32
- "No default model",
30
+ expect(modelRuntimeLabel({ ...label, source: "account" })).toBe(
31
+ "Llama 3 · Ollama Cloud · Account model",
33
32
  );
33
+ expect(modelRuntimeLabel({ ...label, source: "bot" })).toBe(
34
+ "Llama 3 · Ollama Cloud · Bot override",
35
+ );
36
+ });
37
+
38
+ test("shows unavailable and backend failure states", () => {
39
+ expect(modelRuntimeLabel({ source: "none" })).toBe("Model unavailable");
40
+ expect(
41
+ modelRuntimeLabel({
42
+ source: "account",
43
+ providerModelId: "llama-3:cloud",
44
+ failure: 'Connection "work" is revoked; enable or reconnect it',
45
+ }),
46
+ ).toBe('Connection "work" is revoked; enable or reconnect it');
34
47
  });
35
48
  });