@frockbot/plugin-shell 0.3.11 → 0.3.13

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 (42) hide show
  1. package/package.json +35 -33
  2. package/src/agent.test.ts +78 -0
  3. package/src/agent.ts +130 -2
  4. package/src/backend-configuration.test.ts +26 -26
  5. package/src/backend-recovery-integration.test.ts +10 -10
  6. package/src/backend-runner.ts +19 -2
  7. package/src/backend.ts +85 -18
  8. package/src/client/AppletCanvas.vue +19 -6
  9. package/src/client/FrockBotApp.vue +405 -75
  10. package/src/client/activity-trail.test.ts +205 -0
  11. package/src/client/activity-trail.ts +227 -0
  12. package/src/client/applets-client.test.ts +62 -0
  13. package/src/client/applets-client.ts +19 -0
  14. package/src/client/index.test.ts +128 -21
  15. package/src/client/index.ts +359 -114
  16. package/src/client/model-presentation.test.ts +3 -3
  17. package/src/client/no-bot-model-label.test.ts +7 -7
  18. package/src/client/skill-invocation.test.ts +34 -0
  19. package/src/client/skill-invocation.ts +22 -0
  20. package/src/client/styles.css +69 -19
  21. package/src/client/transcript-cache.test.ts +125 -0
  22. package/src/client/transcript-cache.ts +190 -0
  23. package/src/compaction-scheduler.test.ts +96 -0
  24. package/src/compaction-scheduler.ts +108 -0
  25. package/src/compaction-transcript.test.ts +174 -0
  26. package/src/compaction.test.ts +596 -0
  27. package/src/compaction.ts +539 -0
  28. package/src/focus.test.ts +222 -0
  29. package/src/focus.ts +93 -0
  30. package/src/history.ts +86 -8
  31. package/src/legacy-frock-model-id.test.ts +148 -0
  32. package/src/notification-id.ts +0 -0
  33. package/src/run-failure-copy.test.ts +150 -0
  34. package/src/run-failure-copy.ts +110 -0
  35. package/src/run-protocol.test.ts +50 -7
  36. package/src/run-protocol.ts +152 -43
  37. package/src/settings-links.test.ts +8 -2
  38. package/src/settings-links.ts +11 -2
  39. package/src/shared.ts +36 -0
  40. package/tsconfig.json +1 -2
  41. package/src/client/activity-ring.test.ts +0 -89
  42. package/src/client/activity-ring.ts +0 -94
@@ -0,0 +1,222 @@
1
+ // The rule the User stated, checked as arithmetic:
2
+ //
3
+ // "This is one notification per message for a bot that is out of focus. If I
4
+ // have the bot open then that shouldn't raise a notification (or a badge on
5
+ // the list of bots). And when I open a chat that should clear the badge."
6
+ //
7
+ // Everything here composes the real durable pieces — `advanceUnreadActivityV1`
8
+ // for a settled Turn, `projectBotUnreadViewV1` for the count, `markUnreadReadV1`
9
+ // for the read receipt — with the focus rule on top, so a change to either side
10
+ // has to keep the sentence true rather than only its own unit test.
11
+ import { describe, expect, test } from "bun:test";
12
+ import {
13
+ isBotFocusedV1,
14
+ readViewerFocusV1,
15
+ shouldNotifyForBotV1,
16
+ suppressUnreadWhileFocusedV1,
17
+ type ViewerFocusV1,
18
+ } from "./focus.js";
19
+ import {
20
+ advanceUnreadActivityV1,
21
+ emptyUnreadStateV1,
22
+ markUnreadReadV1,
23
+ projectBotUnreadViewV1,
24
+ type BotUnreadViewV1,
25
+ type UnreadStateV1,
26
+ } from "./unread.js";
27
+
28
+ const BOT = "alpha";
29
+ const OTHER = "beta";
30
+
31
+ /** A cursor in the exact shape the Bot's admission index writes. */
32
+ function cursor(minute: number): string {
33
+ const at = new Date(Date.UTC(2026, 8, 4, 0, minute, 0)).toISOString();
34
+ return `run-index:${at}:run-${minute}`;
35
+ }
36
+
37
+ function at(minute: number): string {
38
+ return new Date(Date.UTC(2026, 8, 4, 0, minute, 0)).toISOString();
39
+ }
40
+
41
+ /** Newest-first, exactly as the Bot's run index returns it. */
42
+ function index(minutes: readonly number[]): string[] {
43
+ return [...minutes].sort((a, b) => b - a).map(cursor);
44
+ }
45
+
46
+ /** One settled chat Turn landing on the Bot's durable unread record. */
47
+ function settle(state: UnreadStateV1, minute: number): UnreadStateV1 {
48
+ return advanceUnreadActivityV1(state, {
49
+ cursor: cursor(minute),
50
+ at: at(minute),
51
+ });
52
+ }
53
+
54
+ /**
55
+ * What the sidebar actually renders for a Bot: the Durable Object's own
56
+ * projection, with the focus rule applied by the client that knows which chat
57
+ * is on screen.
58
+ */
59
+ function rowFor(
60
+ state: UnreadStateV1,
61
+ minutes: readonly number[],
62
+ focus: ViewerFocusV1,
63
+ botId = BOT,
64
+ ): BotUnreadViewV1 {
65
+ const view = projectBotUnreadViewV1(botId, state, index(minutes));
66
+ return isBotFocusedV1(focus, botId)
67
+ ? suppressUnreadWhileFocusedV1(view)
68
+ : view;
69
+ }
70
+
71
+ const OPEN_AND_LOOKING: ViewerFocusV1 = {
72
+ activeBotId: BOT,
73
+ visible: true,
74
+ focused: true,
75
+ };
76
+
77
+ describe("what counts as focused", () => {
78
+ test("all three, and nothing less", () => {
79
+ expect(isBotFocusedV1(OPEN_AND_LOOKING, BOT)).toBe(true);
80
+ // A different chat is open.
81
+ expect(isBotFocusedV1(OPEN_AND_LOOKING, OTHER)).toBe(false);
82
+ expect(isBotFocusedV1({ visible: true, focused: true }, BOT)).toBe(false);
83
+ // The chat is open in a tab nobody can see.
84
+ expect(isBotFocusedV1({ ...OPEN_AND_LOOKING, visible: false }, BOT)).toBe(
85
+ false,
86
+ );
87
+ // Visible, but behind another window.
88
+ expect(isBotFocusedV1({ ...OPEN_AND_LOOKING, focused: false }, BOT)).toBe(
89
+ false,
90
+ );
91
+ });
92
+
93
+ test("notifying is exactly the inverse", () => {
94
+ for (const focus of [
95
+ OPEN_AND_LOOKING,
96
+ { ...OPEN_AND_LOOKING, visible: false },
97
+ { ...OPEN_AND_LOOKING, focused: false },
98
+ { visible: true, focused: true },
99
+ ] satisfies ViewerFocusV1[]) {
100
+ for (const botId of [BOT, OTHER]) {
101
+ expect(shouldNotifyForBotV1(focus, botId)).toBe(
102
+ !isBotFocusedV1(focus, botId),
103
+ );
104
+ }
105
+ }
106
+ });
107
+
108
+ test("a document that cannot be read is not a focused one", () => {
109
+ // Bun has no DOM: the reader answers for the environment it is in rather
110
+ // than assuming the User is watching.
111
+ expect(readViewerFocusV1(BOT)).toEqual({
112
+ activeBotId: BOT,
113
+ visible: false,
114
+ focused: false,
115
+ });
116
+ expect(readViewerFocusV1()).toEqual({ visible: false, focused: false });
117
+ });
118
+ });
119
+
120
+ describe("a message while the Bot is focused", () => {
121
+ test("raises no notification and no badge", () => {
122
+ const state = settle(emptyUnreadStateV1(), 1);
123
+ expect(shouldNotifyForBotV1(OPEN_AND_LOOKING, BOT)).toBe(false);
124
+ expect(rowFor(state, [1], OPEN_AND_LOOKING)).toMatchObject({
125
+ count: 0,
126
+ capped: false,
127
+ unread: false,
128
+ });
129
+ });
130
+
131
+ test("and neither does the tenth one", () => {
132
+ let state = emptyUnreadStateV1();
133
+ for (const minute of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) {
134
+ state = settle(state, minute);
135
+ expect(
136
+ rowFor(state, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], OPEN_AND_LOOKING),
137
+ ).toMatchObject({ count: 0, unread: false });
138
+ }
139
+ });
140
+
141
+ test("but a Bot the User marked unread on purpose stays bold", () => {
142
+ // Intent is not arithmetic: suppression clears a count, never a decision.
143
+ const state = { ...settle(emptyUnreadStateV1(), 1), manuallyUnread: true };
144
+ expect(rowFor(state, [1], OPEN_AND_LOOKING)).toMatchObject({
145
+ unread: true,
146
+ manuallyUnread: true,
147
+ });
148
+ });
149
+ });
150
+
151
+ describe("a message while the Bot is not focused", () => {
152
+ test("is one badge per message, not one per burst", () => {
153
+ let state = emptyUnreadStateV1();
154
+ const away: ViewerFocusV1 = { ...OPEN_AND_LOOKING, activeBotId: OTHER };
155
+ const settled: number[] = [];
156
+ const counts: number[] = [];
157
+ for (const minute of [1, 2, 3]) {
158
+ settled.push(minute);
159
+ state = settle(state, minute);
160
+ counts.push(rowFor(state, settled, away).count);
161
+ }
162
+ expect(counts).toEqual([1, 2, 3]);
163
+ });
164
+
165
+ test("counts for the open chat too when the tab is hidden", () => {
166
+ const state = settle(settle(emptyUnreadStateV1(), 1), 2);
167
+ const backgrounded = { ...OPEN_AND_LOOKING, visible: false };
168
+ expect(shouldNotifyForBotV1(backgrounded, BOT)).toBe(true);
169
+ expect(rowFor(state, [1, 2], backgrounded)).toMatchObject({
170
+ count: 2,
171
+ unread: true,
172
+ });
173
+ });
174
+
175
+ test("and when the window is behind another one", () => {
176
+ const state = settle(emptyUnreadStateV1(), 1);
177
+ const behind = { ...OPEN_AND_LOOKING, focused: false };
178
+ expect(shouldNotifyForBotV1(behind, BOT)).toBe(true);
179
+ expect(rowFor(state, [1], behind)).toMatchObject({ count: 1 });
180
+ });
181
+ });
182
+
183
+ describe("opening the chat", () => {
184
+ test("clears the badge, and it stays clear for the next message read there", () => {
185
+ let state = settle(settle(emptyUnreadStateV1(), 1), 2);
186
+ const away = { ...OPEN_AND_LOOKING, activeBotId: OTHER };
187
+ expect(rowFor(state, [1, 2], away).count).toBe(2);
188
+
189
+ // Opening is the authenticated read receipt, up to the cursor the fan-out
190
+ // named. Nothing about focus is stored: the durable record is a cursor.
191
+ const opened = projectBotUnreadViewV1(BOT, state, index([1, 2]));
192
+ state = markUnreadReadV1(state, {
193
+ upToCursor: opened.lastActivityCursor ?? cursor(2),
194
+ at: at(3),
195
+ });
196
+ expect(rowFor(state, [1, 2], OPEN_AND_LOOKING).count).toBe(0);
197
+ // Durable, not remembered: the same record read by any other tab is 0 too.
198
+ expect(projectBotUnreadViewV1(BOT, state, index([1, 2]))).toMatchObject({
199
+ count: 0,
200
+ unread: false,
201
+ });
202
+
203
+ // A reply that arrives while the chat is open and read renders nothing,
204
+ // and the receipt that follows keeps it that way after the tab is closed.
205
+ state = settle(state, 4);
206
+ expect(rowFor(state, [1, 2, 4], OPEN_AND_LOOKING).count).toBe(0);
207
+ state = markUnreadReadV1(state, { upToCursor: cursor(4), at: at(5) });
208
+ expect(projectBotUnreadViewV1(BOT, state, index([1, 2, 4]))).toMatchObject({
209
+ count: 0,
210
+ unread: false,
211
+ });
212
+ });
213
+
214
+ test("clears a manual unread as well", () => {
215
+ let state = { ...settle(emptyUnreadStateV1(), 1), manuallyUnread: true };
216
+ state = markUnreadReadV1(state, { upToCursor: cursor(1), at: at(2) });
217
+ expect(rowFor(state, [1], OPEN_AND_LOOKING)).toMatchObject({
218
+ unread: false,
219
+ manuallyUnread: false,
220
+ });
221
+ });
222
+ });
package/src/focus.ts ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * One definition of "focused", shared by every surface that has to decide
3
+ * whether a Bot's message is something the User is already looking at.
4
+ *
5
+ * The rule, stated once so the Shell and the Flock sidebar cannot drift:
6
+ *
7
+ * > A Bot is **focused** when its chat is the open one, the tab is visible,
8
+ * > and the window holds focus.
9
+ *
10
+ * All three, because each one on its own gets a case wrong. Only the open Bot
11
+ * is being read, so another Bot's reply is news however attentive the User is.
12
+ * A tab in the background is not being read even though its chat is still
13
+ * "open" — `document.visibilityState` is what says so. And a visible tab
14
+ * behind another window is not being read either, which only
15
+ * `document.hasFocus()` can tell: without it, a Bot that replied while the
16
+ * User was in another app stayed silent and left no badge, so the message was
17
+ * never heard about at all.
18
+ *
19
+ * A focused Bot raises no notification and carries no unread badge. Every
20
+ * other message raises exactly one of each.
21
+ */
22
+ import type { BotUnreadViewV1 } from "./unread.js";
23
+
24
+ /** What the rule needs to know about the browser, captured as plain data. */
25
+ export interface ViewerFocusV1 {
26
+ /** The Bot whose chat is open, if any. */
27
+ activeBotId?: string;
28
+ /** `document.visibilityState === "visible"`. */
29
+ visible: boolean;
30
+ /** `document.hasFocus()`. */
31
+ focused: boolean;
32
+ }
33
+
34
+ /**
35
+ * Reads the rule's three inputs out of the live document.
36
+ *
37
+ * Outside a browser there is no viewer, so nothing is focused and every
38
+ * message counts — the conservative answer, since a badge can be cleared and
39
+ * an unheard message cannot be un-missed.
40
+ */
41
+ export function readViewerFocusV1(activeBotId?: string): ViewerFocusV1 {
42
+ const identity = activeBotId === undefined ? {} : { activeBotId };
43
+ if (typeof document === "undefined") {
44
+ return { visible: false, focused: false, ...identity };
45
+ }
46
+ return {
47
+ visible: document.visibilityState === "visible",
48
+ // A document without `hasFocus` is not a background window; it is a
49
+ // runtime that does not report focus, and refusing to believe it would
50
+ // badge the chat the User is reading.
51
+ focused:
52
+ typeof document.hasFocus === "function" ? document.hasFocus() : true,
53
+ ...identity,
54
+ };
55
+ }
56
+
57
+ /** The rule itself. */
58
+ export function isBotFocusedV1(focus: ViewerFocusV1, botId: string): boolean {
59
+ return focus.activeBotId === botId && focus.visible && focus.focused;
60
+ }
61
+
62
+ /**
63
+ * Whether a message from this Bot should raise a notification. The inverse of
64
+ * the rule, named so the call site reads as the sentence it implements: one
65
+ * notification per message, for a Bot that is out of focus.
66
+ */
67
+ export function shouldNotifyForBotV1(
68
+ focus: ViewerFocusV1,
69
+ botId: string,
70
+ ): boolean {
71
+ return !isBotFocusedV1(focus, botId);
72
+ }
73
+
74
+ /**
75
+ * The unread view a focused Bot renders: none.
76
+ *
77
+ * The Bot Durable Object counts every settled Turn, because it cannot know
78
+ * which chat is on screen. A fan-out that returned mid-read would therefore
79
+ * paint a badge on the row the User is looking at, for as long as it took the
80
+ * read receipt to land. The receipt is still sent — "read" is durable, and it
81
+ * is what makes the badge stay gone on the next reload and the next tab — but
82
+ * the row never renders a count it is about to lose.
83
+ *
84
+ * `manuallyUnread` is the exception: a Bot the User deliberately marked unread
85
+ * stays bold while they look at it, because that flag is intent rather than
86
+ * arithmetic, and only opening the Bot again clears it.
87
+ */
88
+ export function suppressUnreadWhileFocusedV1(
89
+ view: BotUnreadViewV1,
90
+ ): BotUnreadViewV1 {
91
+ if (view.manuallyUnread) return view;
92
+ return { ...view, count: 0, capped: false, unread: false };
93
+ }
package/src/history.ts CHANGED
@@ -24,6 +24,13 @@ import {
24
24
  type SessionEvent,
25
25
  type TurnTypeV1,
26
26
  } from "@frockbot/kernel-contracts";
27
+ import {
28
+ compactionMessageV1,
29
+ compactionStateV1,
30
+ historyCharsV1,
31
+ pruneToolOutputsV1,
32
+ type CompactionStateV1,
33
+ } from "./compaction.js";
27
34
 
28
35
  export { currentTurnV1 };
29
36
 
@@ -140,6 +147,68 @@ function budgetedMessagesV1(
140
147
  : [{ role: "user", content: omittedHistoryNoticeV1(dropped) }, ...narrowed];
141
148
  }
142
149
 
150
+ /** The conversation's own messages, after any compaction already recorded. */
151
+ export interface ChatWindowV1 {
152
+ /** Chat-Turn messages the newest compaction does not already cover. */
153
+ messages: LlmMessage[];
154
+ /** The Turn each of those belongs to. */
155
+ turns: number[];
156
+ /** Every chat Turn on the log, in order, covered or not. */
157
+ chatTurns: number[];
158
+ /** What the log says about compaction for this conversation. */
159
+ state: CompactionStateV1;
160
+ /** The newest completed compaction, when there is one. */
161
+ compaction?: CompactionStateV1["compaction"];
162
+ }
163
+
164
+ /**
165
+ * The window a chat Turn's request is assembled from, before pruning and
166
+ * before the budget.
167
+ *
168
+ * Shared by request assembly and by the Turn-end evaluation on purpose: what
169
+ * compaction is measured against has to be what the next request would carry,
170
+ * or the trigger fires against a number nobody pays.
171
+ */
172
+ export function chatWindowV1(
173
+ events: readonly SessionEvent[],
174
+ messages: readonly LlmMessage[],
175
+ ): ChatWindowV1 {
176
+ const turns = messageTurnsV1(events);
177
+ const types = turnTypesByTurnV1(events);
178
+ const chat = (turn: number) => (types.get(turn) ?? "chat") === "chat";
179
+ const state = compactionStateV1(events);
180
+ const current = currentTurnV1(events);
181
+ // A compaction never covers the Turn being assembled, whatever the log says:
182
+ // the current Turn is carried whole, as it has been since ADR 0027.
183
+ const compaction =
184
+ state.compaction && state.compaction.throughTurn < current
185
+ ? state.compaction
186
+ : undefined;
187
+ const kept: LlmMessage[] = [];
188
+ const keptTurns: number[] = [];
189
+ for (const [index, message] of messages.entries()) {
190
+ const turn = turns[index]!;
191
+ if (!chat(turn)) continue;
192
+ if (compaction && turn <= compaction.throughTurn) continue;
193
+ kept.push(message);
194
+ keptTurns.push(turn);
195
+ }
196
+ const chatTurns = [
197
+ ...new Set(
198
+ events.flatMap((event) =>
199
+ event.type === "turn/start" && chat(event.turn) ? [event.turn] : [],
200
+ ),
201
+ ),
202
+ ];
203
+ return {
204
+ messages: kept,
205
+ turns: keptTurns,
206
+ chatTurns,
207
+ state,
208
+ ...(compaction ? { compaction } : {}),
209
+ };
210
+ }
211
+
143
212
  /**
144
213
  * The messages one Turn's request may carry, given the whole session log and
145
214
  * the messages derived from it.
@@ -162,15 +231,24 @@ export function turnScopedMessagesV1(
162
231
  const current = currentTurnV1(input.events);
163
232
  const chatTurn = (turn: number) => (types.get(turn) ?? "chat") === "chat";
164
233
  if (chatTurn(current)) {
165
- const conversation = input.messages.filter((_, index) =>
166
- chatTurn(turns[index]!),
167
- );
168
- return budgetedMessagesV1(
169
- conversation,
170
- turns.filter((turn) => chatTurn(turn)),
171
- current,
172
- input.budget ?? CHAT_HISTORY_BUDGET_CHARS_V1,
234
+ const window = chatWindowV1(input.events, input.messages);
235
+ // Tier 1 of ADR 0030, and the only one that costs nothing: a tool result
236
+ // older than the newest few Turns keeps its pairing and loses its payload.
237
+ const pruned = pruneToolOutputsV1(window.messages, window.turns);
238
+ const preamble = window.compaction
239
+ ? [compactionMessageV1(window.compaction)]
240
+ : [];
241
+ // The summary is never a candidate for eviction — it is what stands in for
242
+ // the Turns eviction would otherwise have dropped — so it is spent from
243
+ // the budget rather than measured against it.
244
+ const budget = Math.max(
245
+ 0,
246
+ (input.budget ?? CHAT_HISTORY_BUDGET_CHARS_V1) - historyCharsV1(preamble),
173
247
  );
248
+ return [
249
+ ...preamble,
250
+ ...budgetedMessagesV1(pruned, window.turns, current, budget),
251
+ ];
174
252
  }
175
253
  const own = input.messages.filter((_, index) => turns[index] === current);
176
254
  const chatTurns = new Set(
@@ -0,0 +1,148 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ decodeModelBindingV1,
4
+ decodeUserSettingsViewV1,
5
+ resolveBotModelBindingV1,
6
+ type UserSettingsViewV1,
7
+ } from "@frockbot/configuration-core";
8
+ import { modelRuntimeLabel } from "./client/model-presentation.js";
9
+
10
+ /**
11
+ * Bots bound before the provider was renamed carry `@flock/auto` in durable
12
+ * storage. Reading one must land in exactly the same place as reading
13
+ * `@frock/auto`: the same binding, the same resolution, the same line above
14
+ * the composer. Nothing writes the old spelling back.
15
+ */
16
+
17
+ const FROCK_PACKAGE = {
18
+ packageId: "provider-flock-ai",
19
+ version: "0.0.1",
20
+ settings: [],
21
+ capabilities: [
22
+ {
23
+ id: "flock-ai-models",
24
+ kind: "model" as const,
25
+ connectionTypes: ["flock-ai-account"],
26
+ },
27
+ ],
28
+ connectionTypes: [
29
+ { id: "flock-ai-account", capabilities: ["flock-ai-models"] },
30
+ ],
31
+ };
32
+
33
+ function storedUserSettings(providerModelId: string): Record<string, unknown> {
34
+ return {
35
+ schemaVersion: 1,
36
+ revision: 7,
37
+ profile: { name: "FrockBot user" },
38
+ packages: [
39
+ { packageId: "provider-flock-ai", version: "0.0.1", state: "installed" },
40
+ ],
41
+ connections: [
42
+ {
43
+ connectionId: "flock-ai-ambient",
44
+ packageId: "provider-flock-ai",
45
+ connectionTypeId: "flock-ai-account",
46
+ displayName: "Frock AI",
47
+ state: "ready",
48
+ generation: "flock-ai-ambient-v1",
49
+ providerType: "flock-ai",
50
+ safeMetadata: {},
51
+ modelCatalog: {
52
+ schemaVersion: 1,
53
+ generation: "flock-ai-static-v1",
54
+ state: "fresh",
55
+ models: [
56
+ {
57
+ providerModelId: "@frock/auto",
58
+ displayName: "Auto (recommended)",
59
+ capabilities: { tools: true, vision: false, reasoning: true },
60
+ source: "discovered",
61
+ },
62
+ ],
63
+ },
64
+ },
65
+ ],
66
+ platformModel: { connectionId: "flock-ai-ambient", providerModelId },
67
+ };
68
+ }
69
+
70
+ function storedBotModelOverride(
71
+ providerModelId: string,
72
+ ): Record<string, unknown> {
73
+ return { connectionId: "flock-ai-ambient", providerModelId };
74
+ }
75
+
76
+ function composerLine(user: UserSettingsViewV1): string {
77
+ const model = user.platformModel!;
78
+ const binding = resolveBotModelBindingV1({
79
+ model,
80
+ user,
81
+ packages: [FROCK_PACKAGE],
82
+ });
83
+ const connection = binding.connection;
84
+ return modelRuntimeLabel({
85
+ source: "platform",
86
+ modelDisplayName: connection?.modelCatalog?.models.find(
87
+ (candidate) => candidate.providerModelId === model.providerModelId,
88
+ )?.displayName,
89
+ providerModelId: model.providerModelId,
90
+ packageDisplayName: "Frock AI",
91
+ connectionDisplayName: connection?.displayName,
92
+ failure: binding.failure,
93
+ });
94
+ }
95
+
96
+ describe("a pre-rename @flock/ model binding", () => {
97
+ test("decodes to the @frock/ spelling", () => {
98
+ expect(decodeUserSettingsViewV1(storedUserSettings("@flock/auto"))).toEqual(
99
+ decodeUserSettingsViewV1(storedUserSettings("@frock/auto")),
100
+ );
101
+
102
+ expect(
103
+ decodeUserSettingsViewV1(storedUserSettings("@flock/auto")).platformModel
104
+ ?.providerModelId,
105
+ ).toBe("@frock/auto");
106
+ });
107
+
108
+ test("decodes the same way on a Bot's own model override", () => {
109
+ // A Bot override is stored opaquely in `packageValues` and read back
110
+ // through this decoder by `storedModelBindingV1`.
111
+ const legacy = decodeModelBindingV1(storedBotModelOverride("@flock/auto"));
112
+
113
+ expect(legacy).toEqual(
114
+ decodeModelBindingV1(storedBotModelOverride("@frock/auto")),
115
+ );
116
+ expect(legacy.providerModelId).toBe("@frock/auto");
117
+ });
118
+
119
+ test("resolves against the catalog exactly as the new spelling does", () => {
120
+ const legacy = resolveBotModelBindingV1({
121
+ model: decodeUserSettingsViewV1(storedUserSettings("@flock/auto"))
122
+ .platformModel!,
123
+ user: decodeUserSettingsViewV1(storedUserSettings("@flock/auto")),
124
+ packages: [FROCK_PACKAGE],
125
+ });
126
+
127
+ expect(legacy.state).toBe("ready");
128
+ expect(legacy).toEqual(
129
+ resolveBotModelBindingV1({
130
+ model: decodeUserSettingsViewV1(storedUserSettings("@frock/auto"))
131
+ .platformModel!,
132
+ user: decodeUserSettingsViewV1(storedUserSettings("@frock/auto")),
133
+ packages: [FROCK_PACKAGE],
134
+ }),
135
+ );
136
+ });
137
+
138
+ test("shows the Frock AI settings line, not a raw legacy id", () => {
139
+ const line = composerLine(
140
+ decodeUserSettingsViewV1(storedUserSettings("@flock/auto")),
141
+ );
142
+
143
+ expect(line).toBe("Auto (recommended) · Frock AI");
144
+ expect(line).toBe(
145
+ composerLine(decodeUserSettingsViewV1(storedUserSettings("@frock/auto"))),
146
+ );
147
+ });
148
+ });
Binary file