@frockbot/plugin-settings 0.0.0 → 0.1.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 (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,223 @@
1
+ <script setup lang="ts">
2
+ import { clientSurfaceRegistryKey } from "@frockbot/client-core";
3
+ import { authSessionClientKey } from "@frockbot/plugin-auth/shared";
4
+ import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
5
+ import { computed, inject, onBeforeUnmount, onMounted, ref } from "vue";
6
+ import { resolveUserDisplayName } from "./user-display-name.js";
7
+
8
+ const providedAuth = inject(authSessionClientKey);
9
+ const surfaces = inject(clientSurfaceRegistryKey);
10
+ const web = inject(frockBotWebDataKey);
11
+ if (!providedAuth || !surfaces || !web)
12
+ throw new Error("settings client services were not provided");
13
+ const auth = providedAuth;
14
+ const menuOpen = ref(false);
15
+ const signOutError = ref<string>();
16
+ const sessionUser = computed(() =>
17
+ auth.projection.value.status === "authenticated"
18
+ ? auth.projection.value.user
19
+ : undefined,
20
+ );
21
+ const displayName = computed(() =>
22
+ resolveUserDisplayName({
23
+ savedName: web.value.userSettings?.profile.name,
24
+ sessionName: sessionUser.value?.name,
25
+ sessionEmail: sessionUser.value?.email,
26
+ }),
27
+ );
28
+ const developmentIdentity = computed(
29
+ () =>
30
+ auth.projection.value.status === "authenticated" &&
31
+ auth.projection.value.mode === "development",
32
+ );
33
+ const isAdmin = computed(
34
+ () =>
35
+ auth.projection.value.status === "authenticated" &&
36
+ auth.projection.value.user.isAdmin,
37
+ );
38
+
39
+ function closeMenu(): void {
40
+ menuOpen.value = false;
41
+ }
42
+
43
+ function openSurface(id: string): void {
44
+ closeMenu();
45
+ surfaces?.open(id);
46
+ }
47
+
48
+ function openAdmin(): void {
49
+ closeMenu();
50
+ surfaces?.open("admin");
51
+ }
52
+
53
+ async function signOut(): Promise<void> {
54
+ signOutError.value = undefined;
55
+ try {
56
+ await auth.signOut();
57
+ closeMenu();
58
+ } catch (error) {
59
+ signOutError.value =
60
+ error instanceof Error ? error.message : "Could not sign out";
61
+ }
62
+ }
63
+
64
+ onMounted(() => window.addEventListener("pointerdown", closeMenu));
65
+ onBeforeUnmount(() => window.removeEventListener("pointerdown", closeMenu));
66
+ </script>
67
+
68
+ <template>
69
+ <div class="profile-area" @pointerdown.stop>
70
+ <button
71
+ class="profile-trigger"
72
+ type="button"
73
+ :aria-expanded="menuOpen"
74
+ :title="sessionUser?.email"
75
+ aria-haspopup="menu"
76
+ @click="menuOpen = !menuOpen"
77
+ >
78
+ <span class="profile-face" aria-hidden="true" />
79
+ {{ displayName }}
80
+ </button>
81
+ <div v-if="menuOpen" class="profile-menu" role="menu">
82
+ <button v-if="isAdmin" type="button" role="menuitem" @click="openAdmin">
83
+ Admin
84
+ </button>
85
+ <button
86
+ type="button"
87
+ role="menuitem"
88
+ @click="openSurface('user-settings')"
89
+ >
90
+ Settings
91
+ </button>
92
+ <!-- The three Package surfaces are reachable where the User already is,
93
+ and gated exactly as the sidebar's Plugins trigger is: a shell
94
+ without the Connection protocol shows none of them. -->
95
+ <template v-if="web.connectionsAvailable">
96
+ <button type="button" role="menuitem" @click="openSurface('models')">
97
+ Models
98
+ </button>
99
+ <button type="button" role="menuitem" @click="openSurface('plugins')">
100
+ Plugins
101
+ </button>
102
+ <button
103
+ type="button"
104
+ role="menuitem"
105
+ @click="openSurface('connections')"
106
+ >
107
+ Connections
108
+ </button>
109
+ </template>
110
+ <button v-if="developmentIdentity" type="button" role="menuitem" disabled>
111
+ Sign out unavailable
112
+ </button>
113
+ <button
114
+ v-else
115
+ type="button"
116
+ role="menuitem"
117
+ :disabled="auth.signingOut.value"
118
+ @click="signOut"
119
+ >
120
+ {{ auth.signingOut.value ? "Signing out…" : "Sign out" }}
121
+ </button>
122
+ <p v-if="developmentIdentity" class="profile-menu-hint">
123
+ Local development identity is selected by the development login URL.
124
+ </p>
125
+ <p v-if="signOutError" class="profile-menu-error" role="alert">
126
+ {{ signOutError }}
127
+ </p>
128
+ </div>
129
+ </div>
130
+ </template>
131
+
132
+ <style scoped>
133
+ .profile-area {
134
+ position: relative;
135
+ }
136
+
137
+ .profile-trigger {
138
+ display: flex;
139
+ width: 100%;
140
+ height: 40px;
141
+ align-items: center;
142
+ gap: 10px;
143
+ padding: 0 8px;
144
+ border-radius: var(--frock-radius-control);
145
+ color: var(--frock-text);
146
+ background: transparent;
147
+ font-size: var(--frock-text-md);
148
+ font-weight: 500;
149
+ text-align: left;
150
+ cursor: pointer;
151
+ transition: background-color var(--frock-motion-fast);
152
+ }
153
+
154
+ .profile-trigger:hover,
155
+ .profile-trigger[aria-expanded="true"] {
156
+ background: var(--frock-fill-hover);
157
+ }
158
+
159
+ .profile-trigger:active {
160
+ background: var(--frock-fill-pressed);
161
+ }
162
+
163
+ .profile-face {
164
+ width: var(--frock-avatar-sm);
165
+ height: var(--frock-avatar-sm);
166
+ flex: 0 0 auto;
167
+ border-radius: 50%;
168
+ background: linear-gradient(
169
+ 135deg,
170
+ var(--frock-surface-accent),
171
+ var(--frock-action-primary)
172
+ );
173
+ }
174
+
175
+ .profile-menu {
176
+ position: absolute;
177
+ z-index: var(--frock-layer-menu);
178
+ right: 0;
179
+ bottom: 44px;
180
+ left: 0;
181
+ padding: 6px;
182
+ border: 1px solid var(--frock-border);
183
+ border-radius: var(--frock-radius-control);
184
+ background: var(--frock-surface-raised);
185
+ box-shadow: var(--frock-shadow-floating);
186
+ animation: frock-rise-in var(--frock-motion-fast) both;
187
+ }
188
+
189
+ .profile-menu button {
190
+ width: 100%;
191
+ padding: 8px 10px;
192
+ border-radius: 7px;
193
+ background: transparent;
194
+ font-size: var(--frock-text-base);
195
+ text-align: left;
196
+ cursor: pointer;
197
+ transition: background-color var(--frock-motion-fast);
198
+ }
199
+
200
+ .profile-menu button:hover:not(:disabled) {
201
+ background: var(--frock-fill-hover);
202
+ }
203
+
204
+ .profile-menu button:disabled {
205
+ color: var(--frock-text-muted);
206
+ cursor: not-allowed;
207
+ }
208
+
209
+ .profile-menu-hint,
210
+ .profile-menu-error {
211
+ margin: 5px 10px;
212
+ font-size: var(--frock-text-xs);
213
+ line-height: var(--frock-leading-snug);
214
+ }
215
+
216
+ .profile-menu-hint {
217
+ color: var(--frock-text-muted);
218
+ }
219
+
220
+ .profile-menu-error {
221
+ color: var(--frock-danger-text);
222
+ }
223
+ </style>
@@ -0,0 +1,242 @@
1
+ <script setup lang="ts">
2
+ import { clientSurfaceRegistryKey } from "@frockbot/client-core";
3
+ import { UiAnchor, UiButton, UiField, UiIcon } from "@frockbot/client-ui";
4
+ import { authSessionClientKey } from "@frockbot/plugin-auth/shared";
5
+ import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
6
+ import { settingsLinkV1 } from "@frockbot/plugin-shell/settings-links";
7
+ import { inject, onMounted, ref } from "vue";
8
+ import PackageSettingsSection from "./PackageSettingsSection.vue";
9
+ import { resolveUserDisplayName } from "./user-display-name.js";
10
+
11
+ const providedSurfaces = inject(clientSurfaceRegistryKey);
12
+ const providedAuth = inject(authSessionClientKey);
13
+ const providedWeb = inject(frockBotWebDataKey);
14
+ if (!providedSurfaces || !providedAuth || !providedWeb) {
15
+ throw new Error("settings client services were not provided");
16
+ }
17
+ const surfaces = providedSurfaces;
18
+ const auth = providedAuth;
19
+ const web = providedWeb;
20
+ // Application settings rows are User-scoped, so their links name no Bot.
21
+ const profileLink = settingsLinkV1({ anchor: "user-profile" });
22
+ const packageSettingsLink = settingsLinkV1({ anchor: "user-package-settings" });
23
+ const name = ref("");
24
+ const email = ref("");
25
+ const saving = ref(false);
26
+
27
+ onMounted(async () => {
28
+ await web.value.loadPluginCatalog();
29
+ await web.value.loadUserSettings();
30
+ const settings = web.value.userSettings;
31
+ if (!settings) return;
32
+ const sessionUser =
33
+ auth.projection.value.status === "authenticated"
34
+ ? auth.projection.value.user
35
+ : undefined;
36
+ name.value = resolveUserDisplayName({
37
+ savedName: settings.profile.name,
38
+ sessionName: sessionUser?.name,
39
+ sessionEmail: sessionUser?.email,
40
+ });
41
+ email.value = settings.profile.email ?? sessionUser?.email ?? "";
42
+ });
43
+
44
+ async function save(): Promise<void> {
45
+ saving.value = true;
46
+ try {
47
+ await web.value.saveUserProfile({
48
+ name: name.value,
49
+ email: email.value || undefined,
50
+ });
51
+ surfaces?.close();
52
+ } catch (error) {
53
+ web.value.settingsError =
54
+ error instanceof Error ? error.message : "Could not save settings";
55
+ } finally {
56
+ saving.value = false;
57
+ }
58
+ }
59
+ </script>
60
+
61
+ <template>
62
+ <form class="settings-form" @submit.prevent="save">
63
+ <UiAnchor
64
+ anchor="user-profile"
65
+ label="Your profile"
66
+ :href="profileLink"
67
+ class="settings-row"
68
+ >
69
+ <div class="profile-intro">
70
+ <span class="profile-face" aria-hidden="true" />
71
+ <div>
72
+ <strong>Your profile</strong>
73
+ <p>This identity is shared across your Bots.</p>
74
+ </div>
75
+ </div>
76
+ <UiField label="Name">
77
+ <input v-model="name" maxlength="100" required />
78
+ </UiField>
79
+ <UiField label="Email" hint="optional">
80
+ <input v-model="email" maxlength="320" type="email" />
81
+ </UiField>
82
+ </UiAnchor>
83
+ <UiAnchor
84
+ anchor="user-package-settings"
85
+ label="Package settings"
86
+ :href="packageSettingsLink"
87
+ class="settings-row"
88
+ >
89
+ <p class="field-hint">
90
+ Settings of the Packages you have enabled. Model providers are
91
+ configured in Models and accounts in Connections.
92
+ </p>
93
+ <PackageSettingsSection />
94
+ </UiAnchor>
95
+ <p v-if="web.settingsError" class="settings-error" role="alert">
96
+ {{ web.settingsError }}
97
+ </p>
98
+ <div class="settings-actions">
99
+ <UiButton type="submit" variant="primary" :disabled="saving">
100
+ {{ saving ? "Saving…" : "Save settings" }}
101
+ </UiButton>
102
+ </div>
103
+ <details class="advanced">
104
+ <summary>
105
+ <span>Advanced</span>
106
+ <span class="advanced__marker" aria-hidden="true"
107
+ ><UiIcon name="arrow-down" size="sm"
108
+ /></span>
109
+ </summary>
110
+ <div class="advanced__body">
111
+ <k-slot name="frockbot.user-settings-sections" />
112
+ </div>
113
+ </details>
114
+ </form>
115
+ </template>
116
+
117
+ <style scoped>
118
+ .settings-row {
119
+ display: flex;
120
+ flex-direction: column;
121
+ gap: 16px;
122
+ padding-right: var(--frock-control-sm);
123
+ }
124
+
125
+ .settings-form {
126
+ display: flex;
127
+ flex-direction: column;
128
+ gap: 16px;
129
+ padding: 16px;
130
+ }
131
+
132
+ .profile-intro {
133
+ display: flex;
134
+ align-items: center;
135
+ gap: 12px;
136
+ padding: 12px;
137
+ border: 1px solid var(--frock-border);
138
+ border-radius: var(--frock-radius-card);
139
+ background: var(--frock-surface-subtle);
140
+ }
141
+
142
+ .profile-face {
143
+ width: var(--frock-avatar-md);
144
+ height: var(--frock-avatar-md);
145
+ flex: 0 0 auto;
146
+ border-radius: 50%;
147
+ background: linear-gradient(
148
+ 135deg,
149
+ var(--frock-surface-accent),
150
+ var(--frock-action-primary)
151
+ );
152
+ }
153
+
154
+ .profile-intro strong,
155
+ .profile-intro p {
156
+ display: block;
157
+ margin: 0;
158
+ }
159
+
160
+ .profile-intro strong {
161
+ font-size: var(--frock-text-lg);
162
+ font-weight: 600;
163
+ }
164
+
165
+ .profile-intro p {
166
+ margin-top: 4px;
167
+ color: var(--frock-text-muted);
168
+ font-size: var(--frock-text-sm);
169
+ }
170
+
171
+ .field-hint {
172
+ margin: -8px 0 0;
173
+ color: var(--frock-text-muted);
174
+ font-size: var(--frock-text-sm);
175
+ }
176
+
177
+ .model-empty {
178
+ display: flex;
179
+ flex-direction: column;
180
+ align-items: flex-start;
181
+ gap: 10px;
182
+ padding: 12px;
183
+ border: 1px solid var(--frock-border);
184
+ border-radius: var(--frock-radius-card);
185
+ background: var(--frock-surface-subtle);
186
+ }
187
+
188
+ .model-empty p {
189
+ margin: 0;
190
+ color: var(--frock-text-muted);
191
+ font-size: var(--frock-text-sm);
192
+ }
193
+
194
+ .settings-error {
195
+ margin: 0;
196
+ color: var(--frock-danger-text);
197
+ font-size: var(--frock-text-sm);
198
+ }
199
+
200
+ .settings-actions {
201
+ display: flex;
202
+ justify-content: flex-end;
203
+ }
204
+
205
+ .advanced {
206
+ border-top: 1px solid var(--frock-border);
207
+ padding-top: 12px;
208
+ }
209
+
210
+ .advanced summary {
211
+ display: flex;
212
+ align-items: center;
213
+ justify-content: space-between;
214
+ gap: 8px;
215
+ color: var(--frock-text-muted);
216
+ font-size: var(--frock-text-sm);
217
+ font-weight: 600;
218
+ list-style: none;
219
+ cursor: pointer;
220
+ }
221
+
222
+ .advanced summary::-webkit-details-marker {
223
+ display: none;
224
+ }
225
+
226
+ .advanced__marker {
227
+ display: grid;
228
+ place-items: center;
229
+ transition: transform var(--frock-motion-fast);
230
+ }
231
+
232
+ .advanced[open] .advanced__marker {
233
+ transform: rotate(180deg);
234
+ }
235
+
236
+ .advanced__body {
237
+ display: flex;
238
+ flex-direction: column;
239
+ gap: 8px;
240
+ padding-top: 12px;
241
+ }
242
+ </style>
@@ -0,0 +1,22 @@
1
+ import type {
2
+ BotSettingsViewV1,
3
+ CapabilityAssignmentOperationViewV1,
4
+ } from "@frockbot/configuration-core";
5
+
6
+ /** Projects every durable Assignment operation without requiring catalog state. */
7
+ export function projectAssignmentOperations(
8
+ settings: Pick<BotSettingsViewV1, "assignmentOperations"> | undefined,
9
+ ): CapabilityAssignmentOperationViewV1[] {
10
+ return (settings?.assignmentOperations ?? []).map((operation) =>
11
+ structuredClone(operation),
12
+ );
13
+ }
14
+
15
+ export function assignmentHasPendingOperation(
16
+ operations: readonly CapabilityAssignmentOperationViewV1[],
17
+ assignmentId: string,
18
+ ): boolean {
19
+ return operations.some(
20
+ (operation) => operation.assignmentId === assignmentId,
21
+ );
22
+ }
@@ -0,0 +1,218 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ decodeModelSelection,
4
+ describeModelAssignment,
5
+ encodeModelSelection,
6
+ eligibleModelConnections,
7
+ isModelConnectionEligible,
8
+ modelSelectOptions,
9
+ resolveBotSettingsModel,
10
+ } from "./bot-settings.js";
11
+
12
+ describe("Bot settings model selection", () => {
13
+ test("excludes model Connections owned by disabled Packages", () => {
14
+ const connection = {
15
+ connectionId: "ollama-1",
16
+ packageId: "provider-ollama-cloud",
17
+ connectionTypeId: "ollama-cloud-account",
18
+ displayName: "Work",
19
+ state: "ready" as const,
20
+ providerType: "ollama-cloud",
21
+ generation: "generation-1",
22
+ safeMetadata: {},
23
+ };
24
+ const catalog = [
25
+ {
26
+ packageId: "provider-ollama-cloud",
27
+ displayName: "Ollama Cloud",
28
+ version: "0.0.1",
29
+ capabilities: [
30
+ {
31
+ id: "ollama-cloud-models",
32
+ kind: "model" as const,
33
+ connectionTypes: ["ollama-cloud-account"],
34
+ },
35
+ ],
36
+ connectionTypes: [
37
+ {
38
+ id: "ollama-cloud-account",
39
+ displayName: "Ollama Cloud account",
40
+ allowMultiple: true,
41
+ authorizationKind: "api-key" as const,
42
+ capabilities: ["ollama-cloud-models"],
43
+ },
44
+ ],
45
+ },
46
+ ];
47
+
48
+ expect(
49
+ isModelConnectionEligible({
50
+ connection,
51
+ packages: [
52
+ {
53
+ packageId: "provider-ollama-cloud",
54
+ version: "0.0.1",
55
+ state: "disabled",
56
+ },
57
+ ],
58
+ catalog,
59
+ }),
60
+ ).toBe(false);
61
+ expect(
62
+ isModelConnectionEligible({
63
+ connection,
64
+ packages: [
65
+ {
66
+ packageId: "provider-ollama-cloud",
67
+ version: "0.0.1",
68
+ state: "installed",
69
+ },
70
+ ],
71
+ catalog,
72
+ }),
73
+ ).toBe(true);
74
+ });
75
+
76
+ test("preserves a model-less Bot while unrelated settings save", () => {
77
+ expect(
78
+ resolveBotSettingsModel({
79
+ useExactModel: false,
80
+ selectedModel: "",
81
+ exactConnectionId: "",
82
+ exactProviderModelId: "",
83
+ }),
84
+ ).toBeUndefined();
85
+ });
86
+
87
+ test("rejects malformed model selections before settings mutate", () => {
88
+ expect(() =>
89
+ resolveBotSettingsModel({
90
+ useExactModel: false,
91
+ selectedModel: "not-json",
92
+ exactConnectionId: "",
93
+ exactProviderModelId: "",
94
+ }),
95
+ ).toThrow("A Connection and model ID are required");
96
+ });
97
+
98
+ test("preserves an existing binding without a new selection", () => {
99
+ expect(
100
+ resolveBotSettingsModel({
101
+ current: {
102
+ connectionId: "ollama-1",
103
+ providerModelId: "glm:cloud",
104
+ },
105
+ useExactModel: false,
106
+ selectedModel: "",
107
+ exactConnectionId: "",
108
+ exactProviderModelId: "",
109
+ }),
110
+ ).toEqual({
111
+ connectionId: "ollama-1",
112
+ providerModelId: "glm:cloud",
113
+ });
114
+ });
115
+ });
116
+
117
+ describe("shared model option building", () => {
118
+ const catalog = [
119
+ {
120
+ packageId: "provider-ollama-cloud",
121
+ displayName: "Ollama Cloud",
122
+ version: "0.0.1",
123
+ capabilities: [
124
+ {
125
+ id: "ollama-cloud-models",
126
+ kind: "model" as const,
127
+ connectionTypes: ["ollama-cloud-account"],
128
+ },
129
+ ],
130
+ connectionTypes: [
131
+ {
132
+ id: "ollama-cloud-account",
133
+ displayName: "Ollama Cloud account",
134
+ allowMultiple: true,
135
+ authorizationKind: "api-key" as const,
136
+ capabilities: ["ollama-cloud-models"],
137
+ },
138
+ ],
139
+ },
140
+ ];
141
+ const packages = [
142
+ {
143
+ packageId: "provider-ollama-cloud",
144
+ version: "0.0.1",
145
+ state: "installed" as const,
146
+ },
147
+ ];
148
+ const connections = [
149
+ {
150
+ connectionId: "ollama-1",
151
+ packageId: "provider-ollama-cloud",
152
+ connectionTypeId: "ollama-cloud-account",
153
+ displayName: "Work",
154
+ state: "ready" as const,
155
+ providerType: "ollama-cloud",
156
+ modelCatalog: {
157
+ schemaVersion: 1 as const,
158
+ generation: "catalog-1",
159
+ state: "fresh" as const,
160
+ models: [
161
+ {
162
+ providerModelId: "llama-3:cloud",
163
+ displayName: "Llama 3",
164
+ capabilities: { tools: true, vision: false, reasoning: false },
165
+ source: "discovered" as const,
166
+ },
167
+ ],
168
+ },
169
+ safeMetadata: {},
170
+ },
171
+ {
172
+ connectionId: "ollama-2",
173
+ packageId: "provider-ollama-cloud",
174
+ connectionTypeId: "ollama-cloud-account",
175
+ displayName: "Revoked",
176
+ state: "revoked" as const,
177
+ safeMetadata: {},
178
+ },
179
+ ];
180
+
181
+ test("offers one option per advertised model of an eligible Connection", () => {
182
+ const eligible = eligibleModelConnections({
183
+ connections,
184
+ packages,
185
+ catalog,
186
+ });
187
+ expect(eligible.map((connection) => connection.connectionId)).toEqual([
188
+ "ollama-1",
189
+ ]);
190
+ expect(modelSelectOptions(eligible)).toEqual([
191
+ {
192
+ value: JSON.stringify(["ollama-1", "llama-3:cloud"]),
193
+ label: "Llama 3 — Work",
194
+ },
195
+ ]);
196
+ });
197
+
198
+ test("round-trips a selection and names it for prose", () => {
199
+ const model = {
200
+ connectionId: "ollama-1",
201
+ providerModelId: "llama-3:cloud",
202
+ };
203
+ expect(decodeModelSelection(encodeModelSelection(model))).toEqual(model);
204
+ expect(encodeModelSelection(undefined)).toBe("");
205
+ expect(decodeModelSelection("")).toBeUndefined();
206
+ expect(() => decodeModelSelection("not-json")).toThrow(
207
+ "A Connection and model ID are required",
208
+ );
209
+ expect(describeModelAssignment(model, connections)).toBe("Llama 3 — Work");
210
+ expect(
211
+ describeModelAssignment(
212
+ { connectionId: "ollama-1", providerModelId: "custom:cloud" },
213
+ connections,
214
+ ),
215
+ ).toBe("custom:cloud — Work");
216
+ expect(describeModelAssignment(undefined, connections)).toBeUndefined();
217
+ });
218
+ });