@frockbot/plugin-settings 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 (36) hide show
  1. package/frockbot.json +55 -0
  2. package/package.json +43 -6
  3. package/src/backend.test.ts +397 -0
  4. package/src/backend.ts +262 -0
  5. package/src/client/BotPanel.vue +57 -0
  6. package/src/client/BotSettingsSurface.vue +1076 -0
  7. package/src/client/BotSettingsTrigger.vue +26 -0
  8. package/src/client/ConnectionsSurface.vue +813 -0
  9. package/src/client/ModelsSurface.vue +419 -0
  10. package/src/client/PackageAccounts.vue +330 -0
  11. package/src/client/PackageCatalogSurface.vue +584 -0
  12. package/src/client/PackageSettingsForm.vue +150 -0
  13. package/src/client/PackageSettingsSection.vue +66 -0
  14. package/src/client/PluginsSurface.vue +412 -0
  15. package/src/client/PluginsTrigger.vue +62 -0
  16. package/src/client/UserProfileTrigger.vue +223 -0
  17. package/src/client/UserSettingsSurface.vue +242 -0
  18. package/src/client/assignment-operations.ts +22 -0
  19. package/src/client/bot-settings.test.ts +218 -0
  20. package/src/client/bot-settings.ts +150 -0
  21. package/src/client/index.test.ts +113 -0
  22. package/src/client/index.ts +78 -0
  23. package/src/client/package-settings.test.ts +63 -0
  24. package/src/client/package-settings.ts +77 -0
  25. package/src/client/package-surfaces.test.ts +147 -0
  26. package/src/client/package-surfaces.ts +93 -0
  27. package/src/client/user-display-name.test.ts +44 -0
  28. package/src/client/user-display-name.ts +16 -0
  29. package/src/env.d.ts +6 -0
  30. package/src/index.ts +1 -0
  31. package/src/manifest.ts +3 -0
  32. package/src/user.test.ts +1277 -0
  33. package/src/user.ts +1221 -0
  34. package/tsconfig.json +15 -0
  35. package/vite.config.ts +30 -0
  36. package/README.md +0 -3
@@ -0,0 +1,150 @@
1
+ import type {
2
+ ConnectionView,
3
+ ModelAssignment,
4
+ PackageInstallationView,
5
+ } from "@frockbot/configuration-core";
6
+ import type { PluginCatalogItem } from "@frockbot/plugin-shell/shared";
7
+
8
+ export function isModelConnectionEligible(input: {
9
+ connection: ConnectionView;
10
+ packages: readonly PackageInstallationView[];
11
+ catalog: readonly PluginCatalogItem[];
12
+ }): boolean {
13
+ const pkg = input.catalog.find(
14
+ (candidate) => candidate.packageId === input.connection.packageId,
15
+ );
16
+ const connectionType = pkg?.connectionTypes.find(
17
+ (candidate) => candidate.id === input.connection.connectionTypeId,
18
+ );
19
+ return Boolean(
20
+ input.connection.state === "ready" &&
21
+ input.packages.some(
22
+ (candidate) =>
23
+ candidate.packageId === input.connection.packageId &&
24
+ candidate.state === "installed",
25
+ ) &&
26
+ pkg?.capabilities.some(
27
+ (capability) =>
28
+ capability.kind === "model" &&
29
+ connectionType?.capabilities.includes(capability.id),
30
+ ),
31
+ );
32
+ }
33
+
34
+ export function resolveBotSettingsModel(input: {
35
+ current?: ModelAssignment;
36
+ useExactModel: boolean;
37
+ selectedModel: string;
38
+ exactConnectionId: string;
39
+ exactProviderModelId: string;
40
+ }): ModelAssignment | undefined {
41
+ if (input.useExactModel) {
42
+ const selected = {
43
+ connectionId: input.exactConnectionId,
44
+ providerModelId: input.exactProviderModelId.trim(),
45
+ };
46
+ if (!selected.connectionId || !selected.providerModelId) {
47
+ throw new Error("A Connection and model ID are required");
48
+ }
49
+ return selected;
50
+ }
51
+ if (!input.selectedModel) return input.current;
52
+ let value: unknown;
53
+ try {
54
+ value = JSON.parse(input.selectedModel);
55
+ } catch {
56
+ throw new Error("A Connection and model ID are required");
57
+ }
58
+ if (
59
+ !Array.isArray(value) ||
60
+ value.length !== 2 ||
61
+ typeof value[0] !== "string" ||
62
+ value[0].length === 0 ||
63
+ typeof value[1] !== "string" ||
64
+ value[1].length === 0
65
+ ) {
66
+ throw new Error("A Connection and model ID are required");
67
+ }
68
+ return { connectionId: value[0], providerModelId: value[1] };
69
+ }
70
+
71
+ export interface ModelSelectOption {
72
+ value: string;
73
+ label: string;
74
+ }
75
+
76
+ /** The Connections a model can be chosen from: ready, installed, model-capable. */
77
+ export function eligibleModelConnections(input: {
78
+ connections: readonly ConnectionView[];
79
+ packages: readonly PackageInstallationView[];
80
+ catalog: readonly PluginCatalogItem[];
81
+ }): ConnectionView[] {
82
+ return input.connections.filter((connection) =>
83
+ isModelConnectionEligible({
84
+ connection,
85
+ packages: input.packages,
86
+ catalog: input.catalog,
87
+ }),
88
+ );
89
+ }
90
+
91
+ /** One `<option>` per advertised model, shared by the Bot and User surfaces. */
92
+ export function modelSelectOptions(
93
+ connections: readonly ConnectionView[],
94
+ ): ModelSelectOption[] {
95
+ return connections.flatMap((connection) =>
96
+ (connection.modelCatalog?.models ?? []).map((model) => ({
97
+ value: encodeModelSelection({
98
+ connectionId: connection.connectionId,
99
+ providerModelId: model.providerModelId,
100
+ }),
101
+ label: `${model.displayName} — ${connection.displayName}`,
102
+ })),
103
+ );
104
+ }
105
+
106
+ export function encodeModelSelection(model?: ModelAssignment): string {
107
+ return model
108
+ ? JSON.stringify([model.connectionId, model.providerModelId])
109
+ : "";
110
+ }
111
+
112
+ /** How a bound model reads in prose, e.g. "Llama 3 — Work". */
113
+ export function describeModelAssignment(
114
+ model: ModelAssignment | undefined,
115
+ connections: readonly ConnectionView[],
116
+ ): string | undefined {
117
+ if (!model) return undefined;
118
+ const connection = connections.find(
119
+ (candidate) => candidate.connectionId === model.connectionId,
120
+ );
121
+ const catalogModel = connection?.modelCatalog?.models.find(
122
+ (candidate) => candidate.providerModelId === model.providerModelId,
123
+ );
124
+ const name = catalogModel?.displayName ?? model.providerModelId;
125
+ return connection ? `${name} — ${connection.displayName}` : name;
126
+ }
127
+
128
+ /** The inverse of {@link encodeModelSelection}; an empty value means no model. */
129
+ export function decodeModelSelection(
130
+ value: string,
131
+ ): ModelAssignment | undefined {
132
+ if (!value) return undefined;
133
+ let parsed: unknown;
134
+ try {
135
+ parsed = JSON.parse(value);
136
+ } catch {
137
+ throw new Error("A Connection and model ID are required");
138
+ }
139
+ if (
140
+ !Array.isArray(parsed) ||
141
+ parsed.length !== 2 ||
142
+ typeof parsed[0] !== "string" ||
143
+ parsed[0].length === 0 ||
144
+ typeof parsed[1] !== "string" ||
145
+ parsed[1].length === 0
146
+ ) {
147
+ throw new Error("A Connection and model ID are required");
148
+ }
149
+ return { connectionId: parsed[0], providerModelId: parsed[1] };
150
+ }
@@ -0,0 +1,113 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ clientSurfaceRegistryKey,
4
+ type ClientPluginContext,
5
+ type ClientSlotRegistration,
6
+ } from "@frockbot/client-core";
7
+ import { createClientSurfaceRegistry } from "@frockbot/client-ui";
8
+ import { settingsClientPlugin } from "./index.js";
9
+ import {
10
+ assignmentHasPendingOperation,
11
+ projectAssignmentOperations,
12
+ } from "./assignment-operations.js";
13
+
14
+ describe("settings client contribution", () => {
15
+ it("projects every Assignment operation without catalog ownership", () => {
16
+ const operations = [
17
+ {
18
+ commandId: "assign-1",
19
+ kind: "assigning" as const,
20
+ assignmentId: "orphan-assign",
21
+ state: "retrying" as const,
22
+ target: {
23
+ assignmentId: "orphan-assign",
24
+ packageId: "missing-package",
25
+ capabilityId: "missing-capability",
26
+ },
27
+ },
28
+ {
29
+ commandId: "replace-1",
30
+ kind: "replacing" as const,
31
+ assignmentId: "orphan-replace",
32
+ state: "pending" as const,
33
+ },
34
+ {
35
+ commandId: "unassign-1",
36
+ kind: "unassigning" as const,
37
+ assignmentId: "orphan-unassign",
38
+ state: "retrying" as const,
39
+ },
40
+ ];
41
+ const projected = projectAssignmentOperations({
42
+ assignmentOperations: operations,
43
+ });
44
+ expect(projected).toEqual(operations);
45
+ expect(projected).not.toBe(operations);
46
+ expect(assignmentHasPendingOperation(projected, "orphan-unassign")).toBe(
47
+ true,
48
+ );
49
+ expect(assignmentHasPendingOperation(projected, "stable")).toBe(false);
50
+ });
51
+
52
+ it("registers feature surfaces and shell-owned trigger seats", () => {
53
+ const surfaces = createClientSurfaceRegistry();
54
+ const slots: ClientSlotRegistration[] = [];
55
+ const provided: unknown[] = [];
56
+ const context: ClientPluginContext = {
57
+ transport: {
58
+ turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
59
+ },
60
+ inject: (key) => {
61
+ if (key !== clientSurfaceRegistryKey) {
62
+ throw new Error("unexpected client provider");
63
+ }
64
+ return surfaces as never;
65
+ },
66
+ provide: (key) => {
67
+ provided.push(key);
68
+ return () => {};
69
+ },
70
+ slot: (registration) => {
71
+ slots.push(registration);
72
+ return () => slots.splice(slots.indexOf(registration), 1);
73
+ },
74
+ };
75
+
76
+ const result = settingsClientPlugin(context);
77
+ if (!Array.isArray(result)) throw new Error("expected owned registrations");
78
+
79
+ expect(slots.map((slot) => slot.slot)).toEqual([
80
+ "frockbot.sidebar-actions",
81
+ "frockbot.user-profile",
82
+ "frockbot.right-panel",
83
+ "frockbot.bot-actions",
84
+ ]);
85
+ // Composition is an internal detail the Settings Package no longer shows,
86
+ // so it provides no client state of its own.
87
+ expect(provided).toEqual([]);
88
+ for (const id of [
89
+ "bot-settings",
90
+ "plugins",
91
+ "models",
92
+ "connections",
93
+ "package-catalog",
94
+ "user-settings",
95
+ ]) {
96
+ expect(surfaces.has(id)).toBe(true);
97
+ }
98
+
99
+ for (const dispose of result.toReversed()) dispose();
100
+ expect(slots).toEqual([]);
101
+ expect(surfaces.active.value).toBeUndefined();
102
+ for (const id of [
103
+ "bot-settings",
104
+ "plugins",
105
+ "models",
106
+ "connections",
107
+ "package-catalog",
108
+ "user-settings",
109
+ ]) {
110
+ expect(surfaces.has(id)).toBe(false);
111
+ }
112
+ });
113
+ });
@@ -0,0 +1,78 @@
1
+ /// <reference path="../env.d.ts" />
2
+
3
+ import {
4
+ clientSurfaceRegistryKey,
5
+ type ClientPlugin,
6
+ } from "@frockbot/client-core";
7
+ import BotPanel from "./BotPanel.vue";
8
+ import BotSettingsSurface from "./BotSettingsSurface.vue";
9
+ import BotSettingsTrigger from "./BotSettingsTrigger.vue";
10
+ import ConnectionsSurface from "./ConnectionsSurface.vue";
11
+ import ModelsSurface from "./ModelsSurface.vue";
12
+ import PluginsSurface from "./PluginsSurface.vue";
13
+ import PackageCatalogSurface from "./PackageCatalogSurface.vue";
14
+ import PluginsTrigger from "./PluginsTrigger.vue";
15
+ import UserProfileTrigger from "./UserProfileTrigger.vue";
16
+ import UserSettingsSurface from "./UserSettingsSurface.vue";
17
+
18
+ export const settingsClientPlugin: ClientPlugin = (ctx) => {
19
+ const surfaces = ctx.inject(clientSurfaceRegistryKey);
20
+ return [
21
+ surfaces.register({
22
+ id: "bot-settings",
23
+ title: "Settings",
24
+ component: BotSettingsSurface,
25
+ placement: "panel",
26
+ }),
27
+ // Enablement and configuration are separate surfaces: Plugins turns a
28
+ // Package on and off, Models configures model providers and picks the
29
+ // model, and Connections authorizes the accounts a Bot may be given.
30
+ surfaces.register({
31
+ id: "plugins",
32
+ title: "Plugins",
33
+ component: PluginsSurface,
34
+ }),
35
+ surfaces.register({
36
+ id: "models",
37
+ title: "Models",
38
+ component: ModelsSurface,
39
+ }),
40
+ surfaces.register({
41
+ id: "connections",
42
+ title: "Connections",
43
+ component: ConnectionsSurface,
44
+ }),
45
+ surfaces.register({
46
+ id: "package-catalog",
47
+ title: "Package Catalog",
48
+ component: PackageCatalogSurface,
49
+ }),
50
+ surfaces.register({
51
+ id: "user-settings",
52
+ title: "Application settings",
53
+ component: UserSettingsSurface,
54
+ }),
55
+ ctx.slot({
56
+ slot: "frockbot.sidebar-actions",
57
+ order: 10,
58
+ component: PluginsTrigger,
59
+ }),
60
+ ctx.slot({
61
+ slot: "frockbot.user-profile",
62
+ order: 10,
63
+ component: UserProfileTrigger,
64
+ }),
65
+ ctx.slot({
66
+ slot: "frockbot.right-panel",
67
+ order: 10,
68
+ component: BotPanel,
69
+ }),
70
+ ctx.slot({
71
+ slot: "frockbot.bot-actions",
72
+ order: 10,
73
+ component: BotSettingsTrigger,
74
+ }),
75
+ ];
76
+ };
77
+
78
+ export default settingsClientPlugin;
@@ -0,0 +1,63 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { PackageSettingDefinition } from "@frockbot/kernel-composition";
3
+ import {
4
+ collectSettingsValues,
5
+ seedSettingsDraft,
6
+ settingFieldKind,
7
+ settingLabel,
8
+ } from "./package-settings.js";
9
+
10
+ function definition(
11
+ id: string,
12
+ schema: PackageSettingDefinition["schema"],
13
+ ): PackageSettingDefinition {
14
+ return { id, schemaVersion: 1, scopes: ["user"], schema };
15
+ }
16
+
17
+ const definitions = [
18
+ definition("model", { type: "string", title: "Model", enum: ["a", "b"] }),
19
+ definition("enabled", { type: "boolean" }),
20
+ definition("results", { type: "integer", title: "Web search results" }),
21
+ definition("prefix", { type: "string" }),
22
+ ];
23
+
24
+ describe("generated Package settings", () => {
25
+ test("derives a control from the declared schema", () => {
26
+ expect(definitions.map((entry) => settingFieldKind(entry.schema))).toEqual([
27
+ "enum",
28
+ "boolean",
29
+ "number",
30
+ "text",
31
+ ]);
32
+ expect(settingLabel(definitions[0]!)).toBe("Model");
33
+ // A schema with no title is named by its own id rather than by a label a
34
+ // surface invented for it.
35
+ expect(settingLabel(definitions[1]!)).toBe("enabled");
36
+ });
37
+
38
+ test("seeds the draft from durable values and neutral empties", () => {
39
+ expect(seedSettingsDraft(definitions, { model: "b", results: 7 })).toEqual({
40
+ model: "b",
41
+ enabled: false,
42
+ results: 7,
43
+ prefix: "",
44
+ });
45
+ });
46
+
47
+ test("sends only the fields the User filled in", () => {
48
+ expect(
49
+ collectSettingsValues(definitions, {
50
+ model: "",
51
+ enabled: true,
52
+ results: "9",
53
+ prefix: "hello",
54
+ }),
55
+ ).toEqual({ enabled: true, results: 9, prefix: "hello" });
56
+ });
57
+
58
+ test("drops a number that is not one", () => {
59
+ expect(
60
+ collectSettingsValues(definitions, { results: "not a number" }),
61
+ ).toEqual({ enabled: false });
62
+ });
63
+ });
@@ -0,0 +1,77 @@
1
+ import type {
2
+ PackageSettingDefinition,
3
+ PackageSettingSchema,
4
+ } from "@frockbot/kernel-composition";
5
+
6
+ /**
7
+ * The generated Package settings form, minus the rendering.
8
+ *
9
+ * The fields come from the schema each Package declares, so a Package that
10
+ * adds a setting gets a control with no edit to a surface: the manifest is the
11
+ * only description of the knob that exists.
12
+ */
13
+ export type SettingFieldKind = "enum" | "boolean" | "number" | "text";
14
+
15
+ export function settingFieldKind(
16
+ schema: PackageSettingSchema,
17
+ ): SettingFieldKind {
18
+ if (schema.enum && schema.enum.length > 0) return "enum";
19
+ if (schema.type === "boolean") return "boolean";
20
+ if (schema.type === "number" || schema.type === "integer") return "number";
21
+ return "text";
22
+ }
23
+
24
+ export function settingLabel(definition: PackageSettingDefinition): string {
25
+ return definition.schema.title ?? definition.id;
26
+ }
27
+
28
+ /** A draft seeded from durable state, so an untouched field saves unchanged. */
29
+ export function seedSettingsDraft(
30
+ definitions: readonly PackageSettingDefinition[],
31
+ stored: Record<string, unknown>,
32
+ ): Record<string, string | number | boolean> {
33
+ const draft: Record<string, string | number | boolean> = {};
34
+ for (const definition of definitions) {
35
+ const value = stored[definition.id];
36
+ if (
37
+ typeof value === "string" ||
38
+ typeof value === "number" ||
39
+ typeof value === "boolean"
40
+ ) {
41
+ draft[definition.id] = value;
42
+ continue;
43
+ }
44
+ draft[definition.id] =
45
+ settingFieldKind(definition.schema) === "boolean" ? false : "";
46
+ }
47
+ return draft;
48
+ }
49
+
50
+ /**
51
+ * The command payload. Only the fields the User filled in are sent: the
52
+ * command is a partial update, and an empty text or number box means "leave
53
+ * this one alone" rather than "store an empty string".
54
+ */
55
+ export function collectSettingsValues(
56
+ definitions: readonly PackageSettingDefinition[],
57
+ draft: Record<string, string | number | boolean | undefined>,
58
+ ): Record<string, string | number | boolean> {
59
+ const values: Record<string, string | number | boolean> = {};
60
+ for (const definition of definitions) {
61
+ const kind = settingFieldKind(definition.schema);
62
+ const raw = draft[definition.id];
63
+ if (kind === "boolean") {
64
+ values[definition.id] = raw === true;
65
+ continue;
66
+ }
67
+ if (raw === "" || raw === undefined) continue;
68
+ if (kind === "number") {
69
+ const parsed = typeof raw === "number" ? raw : Number(raw);
70
+ if (!Number.isFinite(parsed)) continue;
71
+ values[definition.id] = parsed;
72
+ continue;
73
+ }
74
+ values[definition.id] = String(raw);
75
+ }
76
+ return values;
77
+ }
@@ -0,0 +1,147 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { PackageInstallationView } from "@frockbot/configuration-core";
3
+ import type { PluginCatalogItem } from "@frockbot/plugin-shell/shared";
4
+ import {
5
+ configurablePackages,
6
+ configurationHomeLabel,
7
+ isPackageEnabled,
8
+ isPackageInstalled,
9
+ packageConfigurationHome,
10
+ packagesForHome,
11
+ } from "./package-surfaces.js";
12
+
13
+ function catalogItem(
14
+ overrides: Partial<PluginCatalogItem> & { packageId: string },
15
+ ): PluginCatalogItem {
16
+ return {
17
+ displayName: overrides.packageId,
18
+ version: "0.0.1",
19
+ capabilities: [],
20
+ connectionTypes: [],
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ const provider = catalogItem({
26
+ packageId: "provider-ollama-cloud",
27
+ capabilities: [
28
+ { id: "ollama-cloud-models", kind: "model", connectionTypes: ["account"] },
29
+ {
30
+ id: "ollama-cloud-web-search",
31
+ kind: "tool",
32
+ connectionTypes: ["account"],
33
+ },
34
+ ],
35
+ connectionTypes: [
36
+ {
37
+ id: "account",
38
+ displayName: "Ollama Cloud account",
39
+ allowMultiple: true,
40
+ authorizationKind: "api-key",
41
+ capabilities: ["ollama-cloud-models"],
42
+ },
43
+ ],
44
+ });
45
+
46
+ const connector = catalogItem({
47
+ packageId: "composio",
48
+ capabilities: [
49
+ { id: "gmail-tools", kind: "tool", connectionTypes: ["gmail"] },
50
+ ],
51
+ connectionTypes: [
52
+ {
53
+ id: "gmail",
54
+ displayName: "Gmail",
55
+ allowMultiple: false,
56
+ authorizationKind: "grant",
57
+ capabilities: ["gmail-tools"],
58
+ },
59
+ ],
60
+ });
61
+
62
+ const settingsOnly = catalogItem({
63
+ packageId: "image",
64
+ capabilities: [{ id: "image-tools", kind: "tool", connectionTypes: [] }],
65
+ settings: [
66
+ {
67
+ id: "model",
68
+ schemaVersion: 1,
69
+ scopes: ["user"],
70
+ schema: { type: "string", title: "Model" },
71
+ },
72
+ ] as PluginCatalogItem["settings"],
73
+ });
74
+
75
+ const plain = catalogItem({
76
+ packageId: "web",
77
+ capabilities: [{ id: "web-tools", kind: "tool", connectionTypes: [] }],
78
+ });
79
+
80
+ const catalog = [provider, connector, settingsOnly, plain];
81
+
82
+ function installation(
83
+ packageId: string,
84
+ state: PackageInstallationView["state"],
85
+ ): PackageInstallationView {
86
+ return { packageId, version: "0.0.1", state };
87
+ }
88
+
89
+ describe("Package configuration homes", () => {
90
+ test("routes every declared Package to exactly one home", () => {
91
+ expect(packageConfigurationHome(provider)).toBe("models");
92
+ expect(packageConfigurationHome(connector)).toBe("connections");
93
+ expect(packageConfigurationHome(settingsOnly)).toBe("user-settings");
94
+ // Nothing declared is nothing to configure: the Package appears in Plugins
95
+ // to be enabled and disabled, and on no configuration surface at all.
96
+ expect(packageConfigurationHome(plain)).toBe("none");
97
+
98
+ const homed = catalog.flatMap((item) => {
99
+ const home = packageConfigurationHome(item);
100
+ return home === "none" ? [] : [[item.packageId, home] as const];
101
+ });
102
+ expect(homed).toEqual([
103
+ ["provider-ollama-cloud", "models"],
104
+ ["composio", "connections"],
105
+ ["image", "user-settings"],
106
+ ]);
107
+ });
108
+
109
+ test("a model provider that declares Connections is Models', not Connections'", () => {
110
+ expect(packagesForHome(catalog, "models")).toEqual([provider]);
111
+ expect(packagesForHome(catalog, "connections")).toEqual([connector]);
112
+ expect(packagesForHome(catalog, "user-settings")).toEqual([settingsOnly]);
113
+ });
114
+
115
+ test("only an installed and enabled Package is configurable", () => {
116
+ const packages = [
117
+ installation("provider-ollama-cloud", "installed"),
118
+ installation("composio", "disabled"),
119
+ installation("image", "failed"),
120
+ ];
121
+ expect(
122
+ configurablePackages({ catalog, packages, home: "models" }).map(
123
+ (item) => item.packageId,
124
+ ),
125
+ ).toEqual(["provider-ollama-cloud"]);
126
+ // A disabled Package keeps its Connections and settings; it just stops
127
+ // being configurable until Plugins enables it again.
128
+ expect(
129
+ configurablePackages({ catalog, packages, home: "connections" }),
130
+ ).toEqual([]);
131
+ expect(
132
+ configurablePackages({ catalog, packages, home: "user-settings" }),
133
+ ).toEqual([]);
134
+ expect(isPackageEnabled(packages, "composio")).toBe(false);
135
+ expect(isPackageInstalled(packages, "composio")).toBe(true);
136
+ expect(isPackageInstalled(packages, "web")).toBe(false);
137
+ });
138
+
139
+ test("names the surface a Plugins row points at", () => {
140
+ expect(configurationHomeLabel("models")).toBe("Models");
141
+ expect(configurationHomeLabel("connections")).toBe("Connections");
142
+ expect(configurationHomeLabel("user-settings")).toBe(
143
+ "Application settings",
144
+ );
145
+ expect(configurationHomeLabel("none")).toBeUndefined();
146
+ });
147
+ });