@frockbot/plugin-shell 0.3.2 → 0.3.4

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.
@@ -826,6 +826,84 @@ describe("Bot selection", () => {
826
826
  },
827
827
  );
828
828
 
829
+ /**
830
+ * The composer placeholder and the not-ready line in `FrockBotApp.vue` are
831
+ * `state.modelLabel`, so a genuinely broken binding has to reach that label
832
+ * as the resolver's own repairable sentence rather than a flat "Model
833
+ * unavailable" the User cannot act on.
834
+ */
835
+ test("labels a Bot whose model Connection is disabled with the resolver's failure", async () => {
836
+ let provided: Ref<FrockBotWebData> | undefined;
837
+ const bot = initializeBotSettingsV1("broken-model-bot");
838
+ const user: UserSettingsViewV1 = {
839
+ schemaVersion: 1,
840
+ revision: 1,
841
+ profile: { name: "User" },
842
+ packages: [
843
+ { packageId: "model-provider", version: "0.0.1", state: "installed" },
844
+ ],
845
+ connections: [
846
+ {
847
+ connectionId: "model-connection",
848
+ packageId: "model-provider",
849
+ connectionTypeId: "model-account",
850
+ displayName: "Work",
851
+ state: "disabled",
852
+ providerType: "model-provider",
853
+ safeMetadata: {},
854
+ },
855
+ ],
856
+ platformModel: {
857
+ connectionId: "model-connection",
858
+ providerModelId: "model-id",
859
+ },
860
+ };
861
+ await shellClientPlugin({
862
+ transport: {
863
+ turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
864
+ readConfiguration: (query) =>
865
+ Promise.resolve(query.type === "user/get" ? user : bot),
866
+ },
867
+ slot: () => () => {},
868
+ inject: () => {
869
+ throw new Error("unexpected client provider injection");
870
+ },
871
+ provide: (_key, value) => {
872
+ provided = value as Ref<FrockBotWebData>;
873
+ return () => {};
874
+ },
875
+ });
876
+ if (!provided) throw new Error("shell data was not provided");
877
+ provided.value.activeBotId = bot.botId;
878
+ provided.value.pluginCatalog = [
879
+ {
880
+ packageId: "model-provider",
881
+ displayName: "Model provider",
882
+ version: "0.0.1",
883
+ capabilities: [
884
+ { id: "models", kind: "model", connectionTypes: ["model-account"] },
885
+ ],
886
+ connectionTypes: [
887
+ {
888
+ id: "model-account",
889
+ displayName: "Model account",
890
+ allowMultiple: false,
891
+ authorizationKind: "api-key",
892
+ capabilities: ["models"],
893
+ },
894
+ ],
895
+ settings: [],
896
+ },
897
+ ];
898
+
899
+ await provided.value.loadBotSettings();
900
+
901
+ expect(provided.value.modelReady).toBe(false);
902
+ expect(provided.value.modelLabel).toBe(
903
+ 'Connection "model-connection" is disabled; enable or reconnect it',
904
+ );
905
+ });
906
+
829
907
  test("saves Bot-scoped Package settings through the generic command", async () => {
830
908
  type PackageSettingsWebData = FrockBotWebData & {
831
909
  saveBotPackageSettings(
@@ -974,6 +1052,52 @@ describe("Bot selection", () => {
974
1052
  });
975
1053
 
976
1054
  describe("detached Turn projection", () => {
1055
+ test("shows a dynamic call as its namespace/tool with inner arguments", () => {
1056
+ const messages: Parameters<typeof projectCompletedRuns>[0] = [];
1057
+ projectCompletedRuns(
1058
+ messages,
1059
+ [],
1060
+ [
1061
+ {
1062
+ runId: "run-dynamic",
1063
+ input: "Find open issues",
1064
+ status: "completed",
1065
+ responseText: "Done",
1066
+ events: [
1067
+ {
1068
+ type: "tool/call",
1069
+ call: {
1070
+ id: "tool-1",
1071
+ name: "call_dynamic_tool",
1072
+ input: {
1073
+ namespace: "user-Github--acme",
1074
+ toolName: "search_issues",
1075
+ argumentsJson: '{"query":"is:open"}',
1076
+ },
1077
+ },
1078
+ },
1079
+ {
1080
+ type: "tool/result",
1081
+ callId: "tool-1",
1082
+ content: "[]",
1083
+ isError: false,
1084
+ },
1085
+ ],
1086
+ },
1087
+ ],
1088
+ );
1089
+
1090
+ expect(messages[1]?.tools).toEqual([
1091
+ {
1092
+ id: "tool-1",
1093
+ name: "user-Github--acme/search_issues",
1094
+ input: { query: "is:open" },
1095
+ status: "completed",
1096
+ text: "[]",
1097
+ },
1098
+ ]);
1099
+ });
1100
+
977
1101
  test("projects a completed run before it can be acknowledged", () => {
978
1102
  const messages: Parameters<typeof projectCompletedRuns>[0] = [];
979
1103
  const projected = projectCompletedRuns(
@@ -2651,3 +2775,165 @@ describe("Connection operation reconciliation", () => {
2651
2775
  expect(commandIds[1]).not.toBe(commandIds[0]);
2652
2776
  });
2653
2777
  });
2778
+
2779
+ describe("a message sent while a Turn is running", () => {
2780
+ test("is accepted, and carries the intent to supersede what is running", async () => {
2781
+ Object.defineProperty(globalThis, "window", {
2782
+ configurable: true,
2783
+ value: {
2784
+ location: { href: "https://app.example/?bot=primary" },
2785
+ history: { replaceState: () => undefined },
2786
+ },
2787
+ });
2788
+ let provided: Ref<FrockBotWebData> | undefined;
2789
+ const sent: {
2790
+ text: string;
2791
+ supersedes?: { runId?: string };
2792
+ }[] = [];
2793
+ let releaseFirst!: () => void;
2794
+ const firstTurn = new Promise<void>((resolve) => {
2795
+ releaseFirst = resolve;
2796
+ });
2797
+ await shellClientPlugin({
2798
+ transport: {
2799
+ turn: async (_botId, text, _signal, commandId, _skills, supersedes) => {
2800
+ sent.push({ text, ...(supersedes ? { supersedes } : {}) });
2801
+ if (sent.length === 1) await firstTurn;
2802
+ return { runId: commandId, text: `answer to ${text}`, events: [] };
2803
+ },
2804
+ readConfiguration: () =>
2805
+ Promise.resolve(initializeBotSettingsV1("primary")),
2806
+ listRuns: () => Promise.resolve([]),
2807
+ listNotifications: () => Promise.resolve([]),
2808
+ },
2809
+ slot: () => () => {},
2810
+ inject: () => {
2811
+ throw new Error("unexpected client provider injection");
2812
+ },
2813
+ provide: (_key, value) => {
2814
+ provided = value as Ref<FrockBotWebData>;
2815
+ return () => {};
2816
+ },
2817
+ });
2818
+ if (!provided) throw new Error("shell data was not provided");
2819
+ provided.value.activeBotId = "primary";
2820
+ provided.value.composerContext = "primary";
2821
+
2822
+ const first = provided.value.sendPrompt("first");
2823
+ await Promise.resolve();
2824
+ const runningRunId = provided.value.activeRunId;
2825
+ expect(runningRunId).toBeString();
2826
+
2827
+ // The composer is open while a Turn runs, and what it sends replaces it.
2828
+ const second = provided.value.sendPrompt("second");
2829
+ releaseFirst();
2830
+ expect(await first).toMatchObject({ accepted: true });
2831
+ expect(await second).toMatchObject({ accepted: true });
2832
+
2833
+ expect(sent.map((entry) => entry.text).sort()).toEqual(["first", "second"]);
2834
+ // Every send carries the intent. The first had observed no run, so it
2835
+ // carries no provenance — and that is exactly the send that used to be
2836
+ // refused when a person typed faster than the client could observe the
2837
+ // Turn it had just started.
2838
+ expect(sent.find((entry) => entry.text === "first")?.supersedes).toEqual(
2839
+ {},
2840
+ );
2841
+ expect(sent.find((entry) => entry.text === "second")?.supersedes).toEqual({
2842
+ runId: runningRunId!,
2843
+ });
2844
+ });
2845
+
2846
+ test("greys the queued Turn, and un-greys it when it starts", () => {
2847
+ const state: Pick<
2848
+ FrockBotWebData,
2849
+ "messages" | "activeRunId" | "runningRunId" | "activeRun"
2850
+ > = { messages: [] };
2851
+
2852
+ projectDurableRuns(
2853
+ state,
2854
+ [],
2855
+ [
2856
+ { runId: "run-1", input: "first", events: [], status: "running" },
2857
+ {
2858
+ runId: "run-2",
2859
+ input: "second",
2860
+ events: [],
2861
+ status: "running",
2862
+ queued: true,
2863
+ },
2864
+ ],
2865
+ );
2866
+
2867
+ // Ordinary messages; the waiting one is simply held back.
2868
+ expect(state.messages.map((message) => message.pending)).toEqual([
2869
+ undefined,
2870
+ undefined,
2871
+ true,
2872
+ true,
2873
+ ]);
2874
+ // A new message supersedes the newest unsettled Turn; Stop cancels the one
2875
+ // that is actually executing.
2876
+ expect(state.activeRunId).toBe("run-2");
2877
+ expect(state.runningRunId).toBe("run-1");
2878
+
2879
+ projectDurableRuns(
2880
+ state,
2881
+ [],
2882
+ [
2883
+ {
2884
+ runId: "run-1",
2885
+ input: "first",
2886
+ events: [],
2887
+ status: "superseded",
2888
+ failure: "Interrupted by your next message.",
2889
+ },
2890
+ { runId: "run-2", input: "second", events: [], status: "running" },
2891
+ ],
2892
+ );
2893
+
2894
+ expect(state.messages.every((message) => !message.pending)).toBe(true);
2895
+ expect(state.activeRunId).toBe("run-2");
2896
+ expect(state.runningRunId).toBe("run-2");
2897
+ // The superseded Turn keeps the quiet treatment a stopped one gets.
2898
+ expect(state.messages[1]).toMatchObject({
2899
+ status: "aborted",
2900
+ text: "Interrupted by your next message.",
2901
+ });
2902
+ });
2903
+
2904
+ test("a reload reconstructs the greyed state from durable runs alone", () => {
2905
+ const reloaded: Pick<
2906
+ FrockBotWebData,
2907
+ "messages" | "activeRunId" | "runningRunId" | "activeRun"
2908
+ > = { messages: [] };
2909
+
2910
+ projectDurableRuns(
2911
+ reloaded,
2912
+ [],
2913
+ [
2914
+ {
2915
+ runId: "run-1",
2916
+ input: "first",
2917
+ events: [],
2918
+ status: "superseded",
2919
+ failure: "Interrupted by your next message.",
2920
+ },
2921
+ {
2922
+ runId: "run-2",
2923
+ input: "second",
2924
+ events: [],
2925
+ status: "running",
2926
+ queued: true,
2927
+ },
2928
+ ],
2929
+ );
2930
+
2931
+ expect(reloaded.messages[2]).toMatchObject({
2932
+ role: "user",
2933
+ text: "second",
2934
+ pending: true,
2935
+ });
2936
+ expect(reloaded.runningRunId).toBeUndefined();
2937
+ expect(reloaded.activeRunId).toBe("run-2");
2938
+ });
2939
+ });
@@ -98,13 +98,49 @@ import "@frockbot/client-core/fonts.css";
98
98
  import "./styles.css";
99
99
  import { defineClientContribution } from "@frockbot/kernel-contracts/contributions";
100
100
 
101
+ function presentedToolCall(call: NonNullable<ClientTurnEvent["call"]>): {
102
+ name: string;
103
+ input?: unknown;
104
+ } {
105
+ if (
106
+ call.name === "call_dynamic_tool" &&
107
+ typeof call.input === "object" &&
108
+ call.input !== null &&
109
+ !Array.isArray(call.input)
110
+ ) {
111
+ const input = call.input as Record<string, unknown>;
112
+ if (
113
+ typeof input.namespace === "string" &&
114
+ typeof input.toolName === "string"
115
+ ) {
116
+ let innerArguments: unknown = {};
117
+ if (typeof input.argumentsJson === "string") {
118
+ try {
119
+ innerArguments = JSON.parse(input.argumentsJson) as unknown;
120
+ } catch {
121
+ innerArguments = {};
122
+ }
123
+ }
124
+ return {
125
+ name: `${input.namespace}/${input.toolName}`,
126
+ input: innerArguments,
127
+ };
128
+ }
129
+ }
130
+ return {
131
+ name: call.name,
132
+ ...(call.input === undefined ? {} : { input: call.input }),
133
+ };
134
+ }
135
+
101
136
  function toolsFrom(events: ClientTurnEvent[]): WebToolActivity[] {
102
137
  const tools = new Map<string, WebToolActivity>();
103
138
  for (const event of events) {
104
139
  if (event.type === "tool/call" && event.call) {
140
+ const presented = presentedToolCall(event.call);
105
141
  tools.set(event.call.id, {
106
142
  id: event.call.id,
107
- name: event.call.name,
143
+ ...presented,
108
144
  status: "running",
109
145
  });
110
146
  }
@@ -179,7 +215,7 @@ function tasksFrom(events: ClientTurnEvent[]): WebTaskChip[] {
179
215
 
180
216
  type DurableRunProjectionState = Pick<
181
217
  FrockBotWebData,
182
- "messages" | "activeRunId" | "activeRun" | "error"
218
+ "messages" | "activeRunId" | "runningRunId" | "activeRun" | "error"
183
219
  >;
184
220
 
185
221
  /**
@@ -218,7 +254,8 @@ function isTerminalRun(run: ClientRun): boolean {
218
254
  return (
219
255
  run.status === "completed" ||
220
256
  run.status === "failed" ||
221
- run.status === "cancelled"
257
+ run.status === "cancelled" ||
258
+ run.status === "superseded"
222
259
  );
223
260
  }
224
261
 
@@ -235,6 +272,23 @@ function assistantMessage(
235
272
  role: "assistant",
236
273
  text: run.responseText ?? "",
237
274
  status: "streaming",
275
+ // A Turn that has not started shows nothing of its own: the greyed user
276
+ // message is the whole of what the thread says about it.
277
+ ...(run.queued ? { pending: true } : {}),
278
+ tools: toolsFrom(run.events),
279
+ sends: sendsFrom(run.events),
280
+ tasks: tasksFrom(run.events),
281
+ };
282
+ }
283
+ if (run.status === "superseded") {
284
+ // The same quiet treatment a stopped Turn gets. It keeps everything it
285
+ // already sent; the line only says why it ends where it does.
286
+ return {
287
+ id: `${run.runId}:assistant`,
288
+ runId: run.runId,
289
+ role: "assistant",
290
+ text: run.failure ?? "Interrupted by your next message.",
291
+ status: "aborted",
238
292
  tools: toolsFrom(run.events),
239
293
  sends: sendsFrom(run.events),
240
294
  tasks: tasksFrom(run.events),
@@ -321,6 +375,9 @@ export function projectDurableRuns(
321
375
  // Busy state and the banner are separate: a running Turn keeps the composer
322
376
  // busy without producing a banner of its own.
323
377
  let busyRunId: string | undefined;
378
+ // The Turn Stop targets: the one that is executing, never the one waiting
379
+ // behind it.
380
+ let runningRunId: string | undefined;
324
381
  for (const run of runs) {
325
382
  const notification = notifications.find(
326
383
  (candidate) => candidate.runId === run.runId,
@@ -340,6 +397,9 @@ export function projectDurableRuns(
340
397
  ? { at: existingUser.at }
341
398
  : {}),
342
399
  status: "completed",
400
+ // Greyed while its Turn waits, ordinary the moment it is running. The
401
+ // flag comes from durable run state, so a reload draws the same thing.
402
+ ...(run.queued ? { pending: true } : {}),
343
403
  tools: [],
344
404
  sends: [],
345
405
  };
@@ -360,6 +420,7 @@ export function projectDurableRuns(
360
420
  activeRun = activeRunView(run) ?? activeRun;
361
421
  if (run.status === "running" || run.status === "reconciliation-required") {
362
422
  busyRunId = run.runId;
423
+ if (!run.queued) runningRunId = run.runId;
363
424
  }
364
425
  if (notification && isTerminalRun(run)) {
365
426
  projected.add(notification.notificationId);
@@ -377,6 +438,10 @@ export function projectDurableRuns(
377
438
  else if (state.activeRunId && terminalRunIds.has(state.activeRunId)) {
378
439
  state.activeRunId = undefined;
379
440
  }
441
+ if (runningRunId) state.runningRunId = runningRunId;
442
+ else if (state.runningRunId && terminalRunIds.has(state.runningRunId)) {
443
+ state.runningRunId = undefined;
444
+ }
380
445
  if (activeRun) state.activeRun = activeRun;
381
446
  else if (
382
447
  state.activeRun &&
@@ -1023,14 +1088,18 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1023
1088
  function updateModelLabel(): void {
1024
1089
  const bot = web.value.botSettings;
1025
1090
  const user = web.value.userSettings;
1026
- if (!bot || !user) {
1091
+ if (!user) {
1027
1092
  web.value.modelSource = "none";
1028
1093
  web.value.modelReady = false;
1029
1094
  web.value.modelLabel = modelRuntimeLabel({ source: "none" });
1030
1095
  return;
1031
1096
  }
1032
1097
  const effective = resolveEffectiveBotModelV1({
1033
- bot: toRaw(bot),
1098
+ // Before the first Bot exists — and in the window before a selected
1099
+ // Bot's settings arrive — the account's own effective model is still
1100
+ // the truth. An empty Bot scope simply declines to override it, so the
1101
+ // shell reports the account model instead of claiming it is unavailable.
1102
+ bot: bot ? toRaw(bot) : { packageValues: {} },
1034
1103
  user: toRaw(user),
1035
1104
  packages: toRaw(web.value.pluginCatalog).map((pkg) => ({
1036
1105
  packageId: pkg.packageId,
@@ -1156,6 +1225,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1156
1225
  web.value.messages = [];
1157
1226
  web.value.activeRun = undefined;
1158
1227
  web.value.activeRunId = undefined;
1228
+ web.value.runningRunId = undefined;
1159
1229
  web.value.skillCatalog = [];
1160
1230
  web.value.approvals = [];
1161
1231
  web.value.tasks = [];
@@ -2211,12 +2281,20 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2211
2281
  text: string,
2212
2282
  skills?: readonly SkillRefV1[],
2213
2283
  ): Promise<SendPromptResult> {
2214
- if (web.value.activeRunId) return { accepted: false, error: "busy" };
2215
2284
  const botId = web.value.activeBotId;
2216
2285
  if (!botId) return { accepted: false, error: "no-bot" };
2217
2286
  const generation = selectionGeneration;
2218
2287
  const pendingRunId = crypto.randomUUID();
2219
2288
  const optimisticAt = new Date().toISOString();
2289
+ // Every send carries the intent, because "do this instead" is what a
2290
+ // person means by pressing send and it does not depend on what this
2291
+ // client had managed to observe first. Whether a run was showing as
2292
+ // active is a race — the composer unlocks the instant a Turn settles,
2293
+ // and a fast typist beats the next poll — so gating the intent on
2294
+ // `activeRunId` made the Bot refuse a message the User had every right
2295
+ // to send. The observed run rides along as provenance when there is one.
2296
+ const observed = web.value.activeRunId;
2297
+ const supersedes = observed ? { runId: observed } : {};
2220
2298
  web.value.activeRunId = pendingRunId;
2221
2299
  web.value.error = undefined;
2222
2300
  web.value.messages.push(
@@ -2227,6 +2305,9 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2227
2305
  text,
2228
2306
  at: optimisticAt,
2229
2307
  status: "completed",
2308
+ // Greyed until its own Turn is admitted and running. Optimistic
2309
+ // only: the durable projection replaces it by run id.
2310
+ ...(observed ? { pending: true } : {}),
2230
2311
  tools: [],
2231
2312
  sends: [],
2232
2313
  },
@@ -2237,6 +2318,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2237
2318
  text: "",
2238
2319
  at: optimisticAt,
2239
2320
  status: "streaming",
2321
+ ...(observed ? { pending: true } : {}),
2240
2322
  tools: [],
2241
2323
  sends: [],
2242
2324
  },
@@ -2257,6 +2339,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2257
2339
  requestController.signal,
2258
2340
  pendingRunId,
2259
2341
  skills,
2342
+ supersedes,
2260
2343
  );
2261
2344
  if (
2262
2345
  generation !== selectionGeneration ||
@@ -2385,7 +2468,13 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2385
2468
  },
2386
2469
  async stopRun(): Promise<void> {
2387
2470
  const botId = web.value.activeBotId;
2388
- const runId = web.value.activeRun?.runId ?? web.value.activeRunId;
2471
+ // The Turn that is executing, never the message waiting behind it: Stop
2472
+ // cancels what the Bot is doing and does not discard what the User just
2473
+ // sent.
2474
+ const runId =
2475
+ web.value.activeRun?.runId ??
2476
+ web.value.runningRunId ??
2477
+ web.value.activeRunId;
2389
2478
  if (!botId || !runId) return;
2390
2479
  if (!ctx.transport.stopRun) {
2391
2480
  web.value.settingsError = "Stop is unavailable";
@@ -0,0 +1,122 @@
1
+ import { plugin } from "bun";
2
+ import { expect, test } from "bun:test";
3
+ import type { UserSettingsViewV1 } from "@frockbot/configuration-core";
4
+ import type { Ref } from "vue";
5
+ import type { FrockBotWebData } from "../shared.js";
6
+
7
+ // Bun has no single-file-component loader; the shell's Vue modules stand in as
8
+ // empty components, exactly as `index.test.ts` does.
9
+ plugin({
10
+ name: "shell-client-vue-no-bot-loader",
11
+ setup(build) {
12
+ build.onLoad({ filter: /\.vue$/ }, () => ({
13
+ contents: "export default {};",
14
+ loader: "js",
15
+ }));
16
+ },
17
+ });
18
+
19
+ const { shellClientPlugin } = await import("./index.js");
20
+
21
+ /**
22
+ * A first-run account: the platform model resolves against a ready ambient
23
+ * Flock AI Connection whose Catalog is fresh, and no Bot has been created yet.
24
+ * The account's model is available, so the shell must not tell the User it is
25
+ * unavailable before they have made their first Bot.
26
+ */
27
+ test("does not report the account model unavailable before a Bot exists", async () => {
28
+ const user: UserSettingsViewV1 = {
29
+ schemaVersion: 1,
30
+ revision: 3,
31
+ profile: { name: "FrockBot user" },
32
+ packages: [
33
+ { packageId: "provider-flock-ai", version: "0.0.1", state: "installed" },
34
+ ],
35
+ connections: [
36
+ {
37
+ connectionId: "flock-ai-ambient",
38
+ packageId: "provider-flock-ai",
39
+ connectionTypeId: "flock-ai-account",
40
+ displayName: "Flock AI",
41
+ state: "ready",
42
+ providerType: "flock-ai",
43
+ safeMetadata: {},
44
+ modelCatalog: {
45
+ schemaVersion: 1,
46
+ generation: "flock-ai-static-v1",
47
+ state: "fresh",
48
+ models: [
49
+ {
50
+ providerModelId: "@flock/auto",
51
+ displayName: "Auto (recommended)",
52
+ capabilities: { tools: true, vision: false, reasoning: true },
53
+ source: "discovered",
54
+ },
55
+ ],
56
+ },
57
+ },
58
+ ],
59
+ platformModel: {
60
+ connectionId: "flock-ai-ambient",
61
+ providerModelId: "@flock/auto",
62
+ },
63
+ };
64
+
65
+ let provided: Ref<FrockBotWebData> | undefined;
66
+ await shellClientPlugin({
67
+ transport: {
68
+ turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
69
+ readApplicationManifest: () =>
70
+ Promise.resolve({
71
+ schemaVersion: 1,
72
+ deployment: { userId: "development", applicationHash: "hash-1" },
73
+ applicationHash: "hash-1",
74
+ packages: [
75
+ {
76
+ id: "provider-flock-ai",
77
+ displayName: "Flock AI",
78
+ version: "0.0.1",
79
+ contributions: ["backend", "runtime"],
80
+ configuration: {
81
+ settings: [],
82
+ connectionTypes: [
83
+ {
84
+ id: "flock-ai-account",
85
+ displayName: "Flock AI",
86
+ allowMultiple: false,
87
+ authorization: { kind: "ambient-native" },
88
+ capabilities: ["flock-ai-models"],
89
+ },
90
+ ],
91
+ capabilities: [
92
+ {
93
+ id: "flock-ai-models",
94
+ kind: "model",
95
+ connectionTypes: ["flock-ai-account"],
96
+ admission: { turnTypes: ["chat"] },
97
+ },
98
+ ],
99
+ },
100
+ },
101
+ ],
102
+ }),
103
+ readConfiguration: () => Promise.resolve(user),
104
+ },
105
+ slot: () => () => {},
106
+ inject: () => {
107
+ throw new Error("unexpected client provider injection");
108
+ },
109
+ provide: (_key, value) => {
110
+ provided = value as Ref<FrockBotWebData>;
111
+ return () => {};
112
+ },
113
+ });
114
+ if (!provided) throw new Error("shell data was not provided");
115
+
116
+ await provided.value.loadPluginCatalog();
117
+
118
+ // No Bot has been created, so `activeBotId` is unset.
119
+ expect(provided.value.activeBotId).toBeUndefined();
120
+ expect(provided.value.modelLabel).toBe("Auto (recommended) · Flock AI");
121
+ expect(provided.value.modelReady).toBe(true);
122
+ });
@@ -263,6 +263,17 @@
263
263
  margin-top: 18px;
264
264
  }
265
265
 
266
+ /*
267
+ * A message the User sent while the Bot was still working. It is an ordinary
268
+ * message the Bot has not reached yet, so it is drawn as one and simply held
269
+ * back — no label, no icon, no word for the User to learn. It goes to full
270
+ * strength the moment its own Turn starts.
271
+ */
272
+ .message-pending {
273
+ opacity: 0.55;
274
+ transition: opacity var(--frock-motion-enter);
275
+ }
276
+
266
277
  /* A Session announcement: the conversation narrating itself, not a party. */
267
278
  .message-system {
268
279
  align-items: center;