@frockbot/configuration-core 0.3.9 → 0.3.11
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.
- package/package.json +3 -3
- package/src/bot-identity.test.ts +36 -0
- package/src/index.test.ts +139 -0
- package/src/index.ts +95 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/configuration-core",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.11",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@frockbot/connection-core": "0.3.
|
|
16
|
-
"@frockbot/kernel-composition": "0.3.
|
|
15
|
+
"@frockbot/connection-core": "0.3.11",
|
|
16
|
+
"@frockbot/kernel-composition": "0.3.11"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"@types/bun": "1.3.6",
|
package/src/bot-identity.test.ts
CHANGED
|
@@ -182,4 +182,40 @@ describe("bot/set-profile", () => {
|
|
|
182
182
|
),
|
|
183
183
|
).toEqual({ name: "Housework" });
|
|
184
184
|
});
|
|
185
|
+
|
|
186
|
+
test("pins with an instant and unpins with the empty string", () => {
|
|
187
|
+
const at = "2026-09-03T10:15:00.000Z";
|
|
188
|
+
expect(
|
|
189
|
+
decodeBotSettingsViewV1(settings({ name: "Housework", pinnedAt: at })),
|
|
190
|
+
).toMatchObject({ profile: { pinnedAt: at } });
|
|
191
|
+
expect(() =>
|
|
192
|
+
decodeBotSettingsViewV1(
|
|
193
|
+
settings({ name: "Housework", pinnedAt: "whenever" }),
|
|
194
|
+
),
|
|
195
|
+
).toThrow("profile.pinnedAt is invalid");
|
|
196
|
+
|
|
197
|
+
const current: BotProfile = { name: "Housework", pinnedAt: at };
|
|
198
|
+
// A later save must not reshuffle the pinned row: the instant is durable,
|
|
199
|
+
// so only an explicit change moves it.
|
|
200
|
+
expect(
|
|
201
|
+
applyBotProfilePatchV1(current, { title: "Chief" }, "user").pinnedAt,
|
|
202
|
+
).toBe(at);
|
|
203
|
+
expect(applyBotProfilePatchV1(current, { pinnedAt: "" }, "user")).toEqual({
|
|
204
|
+
name: "Housework",
|
|
205
|
+
});
|
|
206
|
+
expect(
|
|
207
|
+
applyBotProfilePatchV1({ name: "Housework" }, { pinnedAt: at }, "user")
|
|
208
|
+
.pinnedAt,
|
|
209
|
+
).toBe(at);
|
|
210
|
+
expect(() =>
|
|
211
|
+
decodeConfigurationCommandV1({
|
|
212
|
+
schemaVersion: 1,
|
|
213
|
+
type: "bot/set-profile",
|
|
214
|
+
commandId: "command-1",
|
|
215
|
+
botId: "primary",
|
|
216
|
+
expectedRevision: 3,
|
|
217
|
+
profile: { pinnedAt: "soon" },
|
|
218
|
+
}),
|
|
219
|
+
).toThrow("profile.pinnedAt is invalid");
|
|
220
|
+
});
|
|
185
221
|
});
|
package/src/index.test.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
resolveEffectiveBotModelV1,
|
|
27
27
|
type ExecutionPackageDefinition,
|
|
28
28
|
type ModelBindingV1,
|
|
29
|
+
type UserSettingsViewV1,
|
|
29
30
|
} from "./index.js";
|
|
30
31
|
import {
|
|
31
32
|
isApplicationDeploymentHash,
|
|
@@ -1657,6 +1658,144 @@ describe("effective Bot model resolution", () => {
|
|
|
1657
1658
|
expect(effective.model).toBeUndefined();
|
|
1658
1659
|
});
|
|
1659
1660
|
|
|
1661
|
+
/**
|
|
1662
|
+
* The real account shape: a platform provider that is always there, and a
|
|
1663
|
+
* separate provider Package the User switched on and bound their model to.
|
|
1664
|
+
*/
|
|
1665
|
+
const flockModel: ModelBindingV1 = {
|
|
1666
|
+
connectionId: "flock-ai",
|
|
1667
|
+
providerModelId: "@flock/auto",
|
|
1668
|
+
};
|
|
1669
|
+
const flockPackage: ExecutionPackageDefinition = {
|
|
1670
|
+
packageId: "provider-flock-ai",
|
|
1671
|
+
version: "0.0.1",
|
|
1672
|
+
settings: [],
|
|
1673
|
+
capabilities: [
|
|
1674
|
+
{
|
|
1675
|
+
id: "flock-ai-models",
|
|
1676
|
+
kind: "model",
|
|
1677
|
+
connectionTypes: ["flock-ai-account"],
|
|
1678
|
+
},
|
|
1679
|
+
],
|
|
1680
|
+
connectionTypes: [
|
|
1681
|
+
{ id: "flock-ai-account", capabilities: ["flock-ai-models"] },
|
|
1682
|
+
],
|
|
1683
|
+
};
|
|
1684
|
+
function withFlock(): UserSettingsViewV1 {
|
|
1685
|
+
const base = user();
|
|
1686
|
+
return {
|
|
1687
|
+
...base,
|
|
1688
|
+
packages: [
|
|
1689
|
+
...base.packages,
|
|
1690
|
+
{
|
|
1691
|
+
packageId: "provider-flock-ai",
|
|
1692
|
+
version: "0.0.1",
|
|
1693
|
+
state: "installed",
|
|
1694
|
+
},
|
|
1695
|
+
{
|
|
1696
|
+
packageId: "custom-models",
|
|
1697
|
+
version: "0.0.1",
|
|
1698
|
+
state: "installed",
|
|
1699
|
+
values: { model: accountModel },
|
|
1700
|
+
},
|
|
1701
|
+
],
|
|
1702
|
+
connections: [
|
|
1703
|
+
...base.connections,
|
|
1704
|
+
{
|
|
1705
|
+
connectionId: "flock-ai",
|
|
1706
|
+
packageId: "provider-flock-ai",
|
|
1707
|
+
connectionTypeId: "flock-ai-account",
|
|
1708
|
+
displayName: "Flock AI",
|
|
1709
|
+
state: "ready",
|
|
1710
|
+
providerType: "flock-ai",
|
|
1711
|
+
modelCatalog: {
|
|
1712
|
+
schemaVersion: 1,
|
|
1713
|
+
generation: "flock-catalog-1",
|
|
1714
|
+
state: "fresh",
|
|
1715
|
+
models: [
|
|
1716
|
+
{
|
|
1717
|
+
providerModelId: "@flock/auto",
|
|
1718
|
+
displayName: "Auto",
|
|
1719
|
+
capabilities: { tools: true, vision: false, reasoning: true },
|
|
1720
|
+
source: "discovered",
|
|
1721
|
+
},
|
|
1722
|
+
],
|
|
1723
|
+
},
|
|
1724
|
+
safeMetadata: {},
|
|
1725
|
+
},
|
|
1726
|
+
],
|
|
1727
|
+
platformModel: flockModel,
|
|
1728
|
+
};
|
|
1729
|
+
}
|
|
1730
|
+
const flockPackages = [...modelPackages, flockPackage];
|
|
1731
|
+
|
|
1732
|
+
test("falls back to the platform model when the bound provider is switched off", () => {
|
|
1733
|
+
const providerOff = (() => {
|
|
1734
|
+
const settings = withFlock();
|
|
1735
|
+
return {
|
|
1736
|
+
...settings,
|
|
1737
|
+
packages: settings.packages.map((pkg) =>
|
|
1738
|
+
pkg.packageId === "provider-ollama-cloud"
|
|
1739
|
+
? { ...pkg, state: "disabled" as const }
|
|
1740
|
+
: pkg,
|
|
1741
|
+
),
|
|
1742
|
+
};
|
|
1743
|
+
})();
|
|
1744
|
+
const effective = resolveEffectiveBotModelV1({
|
|
1745
|
+
bot: { packageValues: {} },
|
|
1746
|
+
user: providerOff,
|
|
1747
|
+
packages: flockPackages,
|
|
1748
|
+
});
|
|
1749
|
+
expect(effective.source).toBe("platform");
|
|
1750
|
+
expect(effective.model).toEqual(flockModel);
|
|
1751
|
+
expect(effective.binding?.state).toBe("ready");
|
|
1752
|
+
expect(effective.fallback).toMatchObject({
|
|
1753
|
+
from: "account",
|
|
1754
|
+
model: accountModel,
|
|
1755
|
+
});
|
|
1756
|
+
});
|
|
1757
|
+
|
|
1758
|
+
test("falls back when a Bot's model has lost its Connection", () => {
|
|
1759
|
+
const settings = withFlock();
|
|
1760
|
+
const connectionGone: UserSettingsViewV1 = {
|
|
1761
|
+
...settings,
|
|
1762
|
+
connections: settings.connections.filter(
|
|
1763
|
+
(connection) => connection.connectionId !== "ollama-work",
|
|
1764
|
+
),
|
|
1765
|
+
};
|
|
1766
|
+
const effective = resolveEffectiveBotModelV1({
|
|
1767
|
+
bot: { packageValues: { "custom-models": { model: accountModel } } },
|
|
1768
|
+
user: connectionGone,
|
|
1769
|
+
packages: flockPackages,
|
|
1770
|
+
});
|
|
1771
|
+
expect(effective).toMatchObject({ source: "platform", model: flockModel });
|
|
1772
|
+
expect(effective.fallback?.from).toBe("bot");
|
|
1773
|
+
expect(effective.binding?.state).toBe("ready");
|
|
1774
|
+
});
|
|
1775
|
+
|
|
1776
|
+
test("keeps the failure when the platform model cannot stand in either", () => {
|
|
1777
|
+
// Nothing to degrade to: the platform bootstrap is bound to the same
|
|
1778
|
+
// switched-off Package, so the User still gets the repair sentence.
|
|
1779
|
+
const settings = withFlock();
|
|
1780
|
+
const bothOff: UserSettingsViewV1 = {
|
|
1781
|
+
...settings,
|
|
1782
|
+
packages: settings.packages.map((pkg) =>
|
|
1783
|
+
pkg.packageId === "provider-ollama-cloud"
|
|
1784
|
+
? { ...pkg, state: "disabled" as const }
|
|
1785
|
+
: pkg,
|
|
1786
|
+
),
|
|
1787
|
+
platformModel,
|
|
1788
|
+
};
|
|
1789
|
+
const effective = resolveEffectiveBotModelV1({
|
|
1790
|
+
bot: { packageValues: {} },
|
|
1791
|
+
user: bothOff,
|
|
1792
|
+
packages: flockPackages,
|
|
1793
|
+
});
|
|
1794
|
+
expect(effective.source).toBe("account");
|
|
1795
|
+
expect(effective.binding?.state).toBe("unavailable");
|
|
1796
|
+
expect(effective.fallback).toBeUndefined();
|
|
1797
|
+
});
|
|
1798
|
+
|
|
1660
1799
|
test("reports none only when no Package or platform model supplies one", () => {
|
|
1661
1800
|
const withoutPlatform = user();
|
|
1662
1801
|
delete withoutPlatform.platformModel;
|
package/src/index.ts
CHANGED
|
@@ -99,6 +99,12 @@ export interface BotProfile {
|
|
|
99
99
|
namedBy?: BotNameProvenanceV1;
|
|
100
100
|
/** Keeps the Bot out of the default sidebar list without archiving it. */
|
|
101
101
|
hiddenFromSidebar?: boolean;
|
|
102
|
+
/**
|
|
103
|
+
* When the User pinned this Bot, as an ISO 8601 instant. Absent means not
|
|
104
|
+
* pinned. The instant rather than a flag, so the pinned row keeps a stable
|
|
105
|
+
* order — earliest pin first — without a second ordering field.
|
|
106
|
+
*/
|
|
107
|
+
pinnedAt?: string;
|
|
102
108
|
}
|
|
103
109
|
|
|
104
110
|
/**
|
|
@@ -112,6 +118,8 @@ export interface BotProfilePatchV1 {
|
|
|
112
118
|
description?: string;
|
|
113
119
|
title?: string;
|
|
114
120
|
hiddenFromSidebar?: boolean;
|
|
121
|
+
/** An ISO 8601 instant pins the Bot; the empty string unpins it. */
|
|
122
|
+
pinnedAt?: string;
|
|
115
123
|
}
|
|
116
124
|
|
|
117
125
|
export interface BotNotificationPolicy {
|
|
@@ -604,6 +612,17 @@ export interface EffectiveBotModelV1 {
|
|
|
604
612
|
source: "bot" | "account" | "platform" | "none";
|
|
605
613
|
model?: ModelBindingV1;
|
|
606
614
|
binding?: ResolvedModelBindingV1;
|
|
615
|
+
/**
|
|
616
|
+
* Set when a Bot or account choice was present but could not bind — its
|
|
617
|
+
* provider Package was switched off, or its Connection is gone — and the
|
|
618
|
+
* platform bootstrap answered in its place. The chosen scope and the reason
|
|
619
|
+
* it failed survive so the client can say the Bot is running on the default.
|
|
620
|
+
*/
|
|
621
|
+
fallback?: {
|
|
622
|
+
from: "bot" | "account";
|
|
623
|
+
model: ModelBindingV1;
|
|
624
|
+
failure: string;
|
|
625
|
+
};
|
|
607
626
|
}
|
|
608
627
|
|
|
609
628
|
/**
|
|
@@ -684,23 +703,68 @@ export function resolveEffectiveBotModelV1(input: {
|
|
|
684
703
|
};
|
|
685
704
|
};
|
|
686
705
|
|
|
706
|
+
/**
|
|
707
|
+
* A chosen model whose provider Package has been switched off, or whose
|
|
708
|
+
* Connection is gone, must not stop the Bot answering: the platform
|
|
709
|
+
* bootstrap stands in for it. Only a binding failure degrades this way — a
|
|
710
|
+
* scope conflict is the User's own contradiction and still fails closed,
|
|
711
|
+
* because there is no single choice to stand in for.
|
|
712
|
+
*/
|
|
713
|
+
const platformStandIn = (
|
|
714
|
+
from: "bot" | "account",
|
|
715
|
+
model: ModelBindingV1,
|
|
716
|
+
binding: ResolvedModelBindingV1,
|
|
717
|
+
): EffectiveBotModelV1 | undefined => {
|
|
718
|
+
const platform = input.user.platformModel;
|
|
719
|
+
if (!platform) return undefined;
|
|
720
|
+
if (
|
|
721
|
+
platform.connectionId === model.connectionId &&
|
|
722
|
+
platform.providerModelId === model.providerModelId
|
|
723
|
+
) {
|
|
724
|
+
return undefined;
|
|
725
|
+
}
|
|
726
|
+
const platformBinding = resolveBotModelBindingV1({
|
|
727
|
+
model: platform,
|
|
728
|
+
user: input.user,
|
|
729
|
+
packages: input.packages,
|
|
730
|
+
});
|
|
731
|
+
if (platformBinding.state === "unavailable") return undefined;
|
|
732
|
+
return {
|
|
733
|
+
source: "platform",
|
|
734
|
+
model: structuredClone(platform),
|
|
735
|
+
binding: platformBinding,
|
|
736
|
+
fallback: {
|
|
737
|
+
from,
|
|
738
|
+
model: structuredClone(model),
|
|
739
|
+
failure:
|
|
740
|
+
binding.failure ?? "This Bot's model isn't available right now.",
|
|
741
|
+
},
|
|
742
|
+
};
|
|
743
|
+
};
|
|
744
|
+
|
|
687
745
|
for (const scope of ["bot", "user"] as const) {
|
|
688
746
|
const resolved = fromScope(scope);
|
|
747
|
+
const source = scope === "bot" ? "bot" : "account";
|
|
689
748
|
if (resolved?.conflict) {
|
|
690
749
|
return {
|
|
691
|
-
source
|
|
750
|
+
source,
|
|
692
751
|
binding: { state: "unavailable", failure: resolved.conflict },
|
|
693
752
|
};
|
|
694
753
|
}
|
|
695
754
|
if (resolved?.model) {
|
|
755
|
+
const binding = resolveBotModelBindingV1({
|
|
756
|
+
model: resolved.model,
|
|
757
|
+
user: input.user,
|
|
758
|
+
packages: input.packages,
|
|
759
|
+
});
|
|
760
|
+
if (binding.state === "unavailable") {
|
|
761
|
+
const standIn = platformStandIn(source, resolved.model, binding);
|
|
762
|
+
if (standIn) return standIn;
|
|
763
|
+
}
|
|
696
764
|
return {
|
|
697
|
-
source
|
|
765
|
+
source,
|
|
698
766
|
model: structuredClone(resolved.model),
|
|
699
|
-
binding
|
|
700
|
-
model: resolved.model,
|
|
701
|
-
user: input.user,
|
|
702
|
-
packages: input.packages,
|
|
703
|
-
}),
|
|
767
|
+
binding,
|
|
704
768
|
};
|
|
705
769
|
}
|
|
706
770
|
}
|
|
@@ -1009,6 +1073,7 @@ const BOT_PROFILE_OPTIONAL_FIELDS = [
|
|
|
1009
1073
|
"title",
|
|
1010
1074
|
"namedBy",
|
|
1011
1075
|
"hiddenFromSidebar",
|
|
1076
|
+
"pinnedAt",
|
|
1012
1077
|
] as const;
|
|
1013
1078
|
|
|
1014
1079
|
function botProfile(value: unknown): BotProfile {
|
|
@@ -1040,9 +1105,21 @@ function botProfile(value: unknown): BotProfile {
|
|
|
1040
1105
|
"profile.hiddenFromSidebar",
|
|
1041
1106
|
),
|
|
1042
1107
|
}),
|
|
1108
|
+
...(profile.pinnedAt === undefined
|
|
1109
|
+
? {}
|
|
1110
|
+
: { pinnedAt: profileTimestamp(profile.pinnedAt, "profile.pinnedAt") }),
|
|
1043
1111
|
};
|
|
1044
1112
|
}
|
|
1045
1113
|
|
|
1114
|
+
/** An ISO 8601 instant a profile field carries. Never a duration or a count. */
|
|
1115
|
+
function profileTimestamp(value: unknown, label: string): string {
|
|
1116
|
+
const candidate = text(value, label, 64);
|
|
1117
|
+
if (!Number.isFinite(Date.parse(candidate))) {
|
|
1118
|
+
throw new ConfigurationDecodeError(`${label} is invalid`);
|
|
1119
|
+
}
|
|
1120
|
+
return candidate;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1046
1123
|
/**
|
|
1047
1124
|
* A patch field that carries text: a non-empty string sets it, and the empty
|
|
1048
1125
|
* string clears it. A partial update has no other way to say "remove this".
|
|
@@ -1067,7 +1144,7 @@ function botProfilePatch(value: unknown): BotProfilePatchV1 {
|
|
|
1067
1144
|
value,
|
|
1068
1145
|
"profile",
|
|
1069
1146
|
[],
|
|
1070
|
-
["name", "label", "description", "title", "hiddenFromSidebar"],
|
|
1147
|
+
["name", "label", "description", "title", "hiddenFromSidebar", "pinnedAt"],
|
|
1071
1148
|
);
|
|
1072
1149
|
if (Reflect.ownKeys(patch).length === 0) {
|
|
1073
1150
|
throw new ConfigurationDecodeError("profile has invalid fields");
|
|
@@ -1092,6 +1169,15 @@ function botProfilePatch(value: unknown): BotProfilePatchV1 {
|
|
|
1092
1169
|
"profile.hiddenFromSidebar",
|
|
1093
1170
|
),
|
|
1094
1171
|
}),
|
|
1172
|
+
// An instant pins; the empty string unpins, the same way text clears.
|
|
1173
|
+
...(patch.pinnedAt === undefined
|
|
1174
|
+
? {}
|
|
1175
|
+
: {
|
|
1176
|
+
pinnedAt:
|
|
1177
|
+
patchText(patch.pinnedAt, "profile.pinnedAt", 64) === ""
|
|
1178
|
+
? ""
|
|
1179
|
+
: profileTimestamp(patch.pinnedAt, "profile.pinnedAt"),
|
|
1180
|
+
}),
|
|
1095
1181
|
};
|
|
1096
1182
|
}
|
|
1097
1183
|
|
|
@@ -1111,7 +1197,7 @@ export function applyBotProfilePatchV1(
|
|
|
1111
1197
|
next.name = patch.name;
|
|
1112
1198
|
next.namedBy = namedBy;
|
|
1113
1199
|
}
|
|
1114
|
-
for (const key of ["label", "description", "title"] as const) {
|
|
1200
|
+
for (const key of ["label", "description", "title", "pinnedAt"] as const) {
|
|
1115
1201
|
const value = patch[key];
|
|
1116
1202
|
if (value === undefined) continue;
|
|
1117
1203
|
if (value === "") delete next[key];
|