@frockbot/plugin-shell 0.3.6 → 0.3.8

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-shell",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -47,41 +47,41 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "@cordisjs/plugin-webui": "0.8.2",
50
- "@frockbot/agent-runtime": "0.3.6",
51
- "@frockbot/application-foundation": "0.3.6",
52
- "@frockbot/catalog-core": "0.3.6",
53
- "@frockbot/client-core": "0.3.6",
54
- "@frockbot/client-ui": "0.3.6",
55
- "@frockbot/computer-core": "0.3.6",
56
- "@frockbot/computer-host-protocol": "0.3.6",
57
- "@frockbot/configuration-core": "0.3.6",
58
- "@frockbot/connection-core": "0.3.6",
59
- "@frockbot/kernel-agent-loop": "0.3.6",
60
- "@frockbot/kernel-composition": "0.3.6",
61
- "@frockbot/kernel-contracts": "0.3.6",
62
- "@frockbot/kernel-do": "0.3.6",
63
- "@frockbot/machine-protocol": "0.3.6",
64
- "@frockbot/plugin-applets": "0.3.6",
65
- "@frockbot/plugin-authoring": "0.3.6",
66
- "@frockbot/plugin-bot-template": "0.3.6",
67
- "@frockbot/plugin-computer": "0.3.6",
68
- "@frockbot/plugin-flock": "0.3.6",
69
- "@frockbot/plugin-image": "0.3.6",
70
- "@frockbot/plugin-machine-messages": "0.3.6",
71
- "@frockbot/plugin-mcp": "0.3.6",
72
- "@frockbot/plugin-memory": "0.3.6",
73
- "@frockbot/plugin-package-catalog": "0.3.6",
74
- "@frockbot/plugin-routines": "0.3.6",
75
- "@frockbot/plugin-skills": "0.3.6",
76
- "@frockbot/plugin-subagents": "0.3.6",
77
- "@frockbot/plugin-user-machine": "0.3.6",
78
- "@frockbot/protocol": "0.3.6",
50
+ "@frockbot/agent-runtime": "0.3.8",
51
+ "@frockbot/application-foundation": "0.3.8",
52
+ "@frockbot/catalog-core": "0.3.8",
53
+ "@frockbot/client-core": "0.3.8",
54
+ "@frockbot/client-ui": "0.3.8",
55
+ "@frockbot/computer-core": "0.3.8",
56
+ "@frockbot/computer-host-protocol": "0.3.8",
57
+ "@frockbot/configuration-core": "0.3.8",
58
+ "@frockbot/connection-core": "0.3.8",
59
+ "@frockbot/kernel-agent-loop": "0.3.8",
60
+ "@frockbot/kernel-composition": "0.3.8",
61
+ "@frockbot/kernel-contracts": "0.3.8",
62
+ "@frockbot/kernel-do": "0.3.8",
63
+ "@frockbot/machine-protocol": "0.3.8",
64
+ "@frockbot/plugin-applets": "0.3.8",
65
+ "@frockbot/plugin-authoring": "0.3.8",
66
+ "@frockbot/plugin-bot-template": "0.3.8",
67
+ "@frockbot/plugin-computer": "0.3.8",
68
+ "@frockbot/plugin-flock": "0.3.8",
69
+ "@frockbot/plugin-image": "0.3.8",
70
+ "@frockbot/plugin-machine-messages": "0.3.8",
71
+ "@frockbot/plugin-mcp": "0.3.8",
72
+ "@frockbot/plugin-memory": "0.3.8",
73
+ "@frockbot/plugin-package-catalog": "0.3.8",
74
+ "@frockbot/plugin-routines": "0.3.8",
75
+ "@frockbot/plugin-skills": "0.3.8",
76
+ "@frockbot/plugin-subagents": "0.3.8",
77
+ "@frockbot/plugin-user-machine": "0.3.8",
78
+ "@frockbot/protocol": "0.3.8",
79
79
  "cordis": "4.0.0-rc.8",
80
80
  "vue": "3.5.41"
81
81
  },
82
82
  "devDependencies": {
83
83
  "@cloudflare/workers-types": "latest",
84
- "@frockbot/plugin-tools": "0.3.6",
84
+ "@frockbot/plugin-tools": "0.3.8",
85
85
  "@types/bun": "1.3.6",
86
86
  "@types/node": "26.2.0",
87
87
  "@vitejs/plugin-vue": "6.0.8",
@@ -354,26 +354,20 @@ describe("Bot recovery", () => {
354
354
  text: "uncertain",
355
355
  }),
356
356
  ).rejects.toThrow("response lost");
357
- expect(
358
- await storage.get<StoredRun>("run:ollama-run-uncertain"),
359
- ).toMatchObject({ status: "reconciliation-required" });
357
+ // Ollama keeps no addressable copy of a completion, so a failure raised
358
+ // before the first stream event is definitive rather than uncertain: the
359
+ // run settles as a failed Turn instead of parking on a retrieval this
360
+ // provider can never perform.
361
+ const uncertain = await storage.get<StoredRun>("run:ollama-run-uncertain");
362
+ expect(uncertain?.status).toBe("failed");
363
+ expect(uncertain?.failure).toContain("response lost");
364
+ expect(await storage.get("active-run")).toBeUndefined();
360
365
 
366
+ // The alarm has nothing left to recover: a settled run stays settled.
361
367
  await host().alarm();
362
- expect(
363
- await storage.get<StoredRun>("run:ollama-run-uncertain"),
364
- ).toMatchObject({ status: "reconciliation-required" });
365
- await expect(
366
- host().reconcileRun(
367
- { userId: "user-1", botId: "primary" },
368
- "ollama-run-uncertain",
369
- ),
370
- ).rejects.toThrow();
371
- expect(
372
- await storage.get<StoredRun>("run:ollama-run-uncertain"),
373
- ).toMatchObject({
374
- status: "failed",
375
- failure: expect.stringContaining("explicitly abandoned"),
376
- });
368
+ const settled = await storage.get<StoredRun>("run:ollama-run-uncertain");
369
+ expect(settled?.status).toBe("failed");
370
+ expect(settled?.failure).toContain("response lost");
377
371
  expect(await storage.get("active-run")).toBeUndefined();
378
372
  });
379
373
 
package/src/backend.ts CHANGED
@@ -403,6 +403,7 @@ import {
403
403
  projectClientAnnouncementsV1,
404
404
  projectClientTurnV1,
405
405
  type ClientRunLookupV1,
406
+ type ClientConversationListV1,
406
407
  type ClientRunListV1,
407
408
  type ClientRunStopReceiptV1,
408
409
  type ClientRunV1,
@@ -436,6 +437,7 @@ import {
436
437
  optionalSidebarMessagePreviewV1,
437
438
  optionalUnreadStateV1,
438
439
  projectBotUnreadViewV1,
440
+ sidebarMessagePreviewFromRunsV1,
439
441
  SIDEBAR_PREVIEW_KEY,
440
442
  unreadReceiptKeyV1,
441
443
  UNREAD_COUNT_CAP,
@@ -443,6 +445,8 @@ import {
443
445
  type BotUnreadCommandV1,
444
446
  type BotUnreadReceiptV1,
445
447
  type BotUnreadViewV1,
448
+ type SidebarMessagePreviewV1,
449
+ type SidebarPreviewRunV1,
446
450
  } from "./unread.js";
447
451
  import { defineBotBackendContribution } from "@frockbot/kernel-contracts/contributions";
448
452
 
@@ -5253,7 +5257,6 @@ export class ShellBotBackendContribution {
5253
5257
  ]),
5254
5258
  );
5255
5259
  const state = optionalUnreadStateV1(storedState);
5256
- const preview = optionalSidebarMessagePreviewV1(storedPreview);
5257
5260
  const index = await this.authority.listRunIndex({
5258
5261
  limit: UNREAD_COUNT_CAP + 1,
5259
5262
  });
@@ -5261,10 +5264,41 @@ export class ShellBotBackendContribution {
5261
5264
  identity.botId,
5262
5265
  state,
5263
5266
  index.map((entry) => entry.cursor),
5264
- preview,
5267
+ await this.sidebarPreview(storedPreview, index),
5265
5268
  );
5266
5269
  }
5267
5270
 
5271
+ /**
5272
+ * How many stored runs a read will open to recover a missing preview. The
5273
+ * newest settled chat Turn is almost always the first entry; the bound is
5274
+ * what keeps a Bot whose recent Turns are all automations from turning one
5275
+ * sidebar read into a scan of its whole history.
5276
+ */
5277
+ private static readonly SIDEBAR_PREVIEW_BACKFILL_RUNS_V1 = 5;
5278
+
5279
+ /**
5280
+ * The preview record, or the same line derived from the runs when there is
5281
+ * none. A Bot whose Turns settled before the preview projection existed has
5282
+ * a full transcript and no record, and the row read "No messages yet" over
5283
+ * it. A read never writes what it derives: the next settlement stores it.
5284
+ */
5285
+ private async sidebarPreview(
5286
+ storedPreview: unknown,
5287
+ index: readonly { runId: string }[],
5288
+ ): Promise<SidebarMessagePreviewV1 | undefined> {
5289
+ const stored = optionalSidebarMessagePreviewV1(storedPreview);
5290
+ if (stored) return stored;
5291
+ const runs: SidebarPreviewRunV1[] = [];
5292
+ for (const entry of index.slice(
5293
+ 0,
5294
+ ShellBotBackendContribution.SIDEBAR_PREVIEW_BACKFILL_RUNS_V1,
5295
+ )) {
5296
+ const run = await this.authority.readRun(entry.runId);
5297
+ if (run) runs.push(run);
5298
+ }
5299
+ return sidebarMessagePreviewFromRunsV1(runs);
5300
+ }
5301
+
5268
5302
  /**
5269
5303
  * `bot/mark-read` and `bot/mark-unread`. Idempotent on the command id and
5270
5304
  * monotonic in the cursor, so a replay or an out-of-order delivery can only
@@ -5293,9 +5327,7 @@ export class ShellBotBackendContribution {
5293
5327
  }
5294
5328
  return {
5295
5329
  state: optionalUnreadStateV1(existing.state),
5296
- preview: optionalSidebarMessagePreviewV1(
5297
- await transaction.get<unknown>(SIDEBAR_PREVIEW_KEY),
5298
- ),
5330
+ preview: await transaction.get<unknown>(SIDEBAR_PREVIEW_KEY),
5299
5331
  };
5300
5332
  }
5301
5333
  const current = optionalUnreadStateV1(
@@ -5319,9 +5351,7 @@ export class ShellBotBackendContribution {
5319
5351
  });
5320
5352
  return {
5321
5353
  state: next,
5322
- preview: optionalSidebarMessagePreviewV1(
5323
- await transaction.get<unknown>(SIDEBAR_PREVIEW_KEY),
5324
- ),
5354
+ preview: await transaction.get<unknown>(SIDEBAR_PREVIEW_KEY),
5325
5355
  };
5326
5356
  });
5327
5357
  const index = await this.authority.listRunIndex({
@@ -5335,7 +5365,10 @@ export class ShellBotBackendContribution {
5335
5365
  identity.botId,
5336
5366
  stored.state,
5337
5367
  index.map((entry) => entry.cursor),
5338
- stored.preview,
5368
+ // The open Bot is the one that gets marked read, so this receipt is
5369
+ // the sidebar row it renders from: it owes the same derived preview
5370
+ // the fan-out gives every other Bot.
5371
+ await this.sidebarPreview(stored.preview, index),
5339
5372
  ),
5340
5373
  };
5341
5374
  }
@@ -5443,6 +5476,15 @@ export class ShellBotBackendContribution {
5443
5476
  ): Promise<ClientRunListV1> {
5444
5477
  const query = decodeClientRunListQueryV1(input);
5445
5478
  await this.authority.recoverActiveRun();
5479
+ // The transcript is one conversation, not every Turn the Bot has ever
5480
+ // run. Absent means the conversation the Bot is on; naming an earlier one
5481
+ // reads it exactly as it was left. A Bot whose object has not learned its
5482
+ // identity yet has no conversation to filter by and shows what it has.
5483
+ const conversationId =
5484
+ query.conversationId ??
5485
+ (await this.authority.readConversationSessionId());
5486
+ const inConversation = (run: { sessionId: string }) =>
5487
+ conversationId === undefined || run.sessionId === conversationId;
5446
5488
  const activeRunId = query.before
5447
5489
  ? undefined
5448
5490
  : await this.authority.readActiveRunId();
@@ -5457,7 +5499,7 @@ export class ShellBotBackendContribution {
5457
5499
  // An automation firing occupies the object like any other run, and is
5458
5500
  // still not part of the conversation: the visible transcript never
5459
5501
  // shows one, running or settled.
5460
- if (active && isVisibleRunV1(active))
5502
+ if (active && isVisibleRunV1(active) && inConversation(active))
5461
5503
  selected.set(active.runId, {
5462
5504
  run: projectClientRunOrDegradedV1(active),
5463
5505
  });
@@ -5471,7 +5513,8 @@ export class ShellBotBackendContribution {
5471
5513
  continue;
5472
5514
  }
5473
5515
  const stored = await this.authority.readStoredRun(candidate.runId);
5474
- if (!stored || !isVisibleRunV1(stored)) continue;
5516
+ if (!stored || !isVisibleRunV1(stored) || !inConversation(stored))
5517
+ continue;
5475
5518
  const projected = projectClientRunOrDegradedV1(stored);
5476
5519
  const tentative = [
5477
5520
  ...selected.values(),
@@ -5527,6 +5570,36 @@ export class ShellBotBackendContribution {
5527
5570
  }
5528
5571
  return page;
5529
5572
  }
5573
+ /** The conversations this Bot has had, newest first. */
5574
+ async listConversations(): Promise<ClientConversationListV1> {
5575
+ return {
5576
+ schemaVersion: 1,
5577
+ conversations: (await this.authority.listConversations()).map(
5578
+ (conversation) => ({
5579
+ schemaVersion: 1 as const,
5580
+ conversationId: conversation.sessionId,
5581
+ ordinal: conversation.ordinal,
5582
+ startedAt: conversation.startedAt,
5583
+ ...(conversation.endedAt ? { endedAt: conversation.endedAt } : {}),
5584
+ }),
5585
+ ),
5586
+ };
5587
+ }
5588
+
5589
+ /**
5590
+ * Puts this conversation down and starts the next one.
5591
+ *
5592
+ * Memory is untouched: it is not conversation history, and the point of a
5593
+ * new conversation is to prove that it is not.
5594
+ */
5595
+ async startConversation(
5596
+ identity: BotIdentity,
5597
+ ): Promise<ClientConversationListV1> {
5598
+ await this.validateIdentity(identity);
5599
+ await this.authority.startConversation(identity);
5600
+ return this.listConversations();
5601
+ }
5602
+
5530
5603
  async lookupRun(input: unknown): Promise<ClientRunLookupV1> {
5531
5604
  const query = decodeClientRunLookupQueryV1(input);
5532
5605
  return projectClientRunLookupV1(await this.authority.readRun(query.runId));
@@ -26,6 +26,12 @@ import {
26
26
  type WebToolActivity,
27
27
  } from "../shared.js";
28
28
  import { ComposerDraftStore } from "./composer-draft.js";
29
+ import {
30
+ TURN_TEXT_MAX_CHARACTERS_V1,
31
+ turnTextCounterVisibleV1,
32
+ turnTextRemainingV1,
33
+ turnTextTooLongV1,
34
+ } from "./turn-limits.js";
29
35
  import SendPayloadView from "./SendPayloadView.vue";
30
36
  import AppletCanvas from "./AppletCanvas.vue";
31
37
  import PackageIframeHost from "./PackageIframeHost.vue";
@@ -305,12 +311,31 @@ const composerPlaceholder = computed(() => {
305
311
  if (!state.value.modelReady) return state.value.modelLabel;
306
312
  return botName.value ? `Message ${botName.value}` : "Message";
307
313
  });
314
+ /*
315
+ * The send route's size rule, enforced where the person can still do something
316
+ * about it. Past the limit the server answers 413 and nothing is sent, so the
317
+ * composer says how much is left before the button closes rather than letting
318
+ * a long message make the round trip to be refused.
319
+ */
320
+ const draftText = computed(() => draft.value.trim());
321
+ const draftTooLong = computed(() => turnTextTooLongV1(draftText.value));
322
+ const draftCounterVisible = computed(() =>
323
+ turnTextCounterVisibleV1(draftText.value),
324
+ );
325
+ const draftCounterLabel = computed(() => {
326
+ const remaining = turnTextRemainingV1(draftText.value);
327
+ const count = Math.abs(remaining).toLocaleString("en-US");
328
+ return remaining < 0
329
+ ? `${count} characters over the ${TURN_TEXT_MAX_CHARACTERS_V1.toLocaleString("en-US")} limit`
330
+ : `${count} characters left`;
331
+ });
308
332
  const canSend = computed(
309
333
  () =>
310
334
  state.value.connection === "ready" &&
311
335
  state.value.modelReady &&
312
336
  Boolean(state.value.activeBotId) &&
313
- draft.value.trim().length > 0,
337
+ draftText.value.length > 0 &&
338
+ !draftTooLong.value,
314
339
  );
315
340
  /**
316
341
  * Stop takes the button only while there is nothing to send. The moment the
@@ -937,6 +962,20 @@ function handleComposerKeydown(event: KeyboardEvent): void {
937
962
  <p v-if="message.notice" class="message-notice">
938
963
  {{ message.notice }}
939
964
  </p>
965
+ <!--
966
+ The way out of an ending the person cannot otherwise act on:
967
+ the client could not reach the Bot, their text is back in the
968
+ composer, and this sends it again.
969
+ -->
970
+ <button
971
+ v-if="message.retry === 'resend'"
972
+ type="button"
973
+ class="message-retry"
974
+ :disabled="!canSend"
975
+ @click="sendMessage()"
976
+ >
977
+ Retry
978
+ </button>
940
979
  <template v-for="tool in message.tools" :key="tool.id">
941
980
  <PackageIframeHost
942
981
  v-for="entry in iframeEntriesFor(tool)"
@@ -1188,7 +1227,33 @@ function handleComposerKeydown(event: KeyboardEvent): void {
1188
1227
  @click="refreshSkillPopover"
1189
1228
  @blur="closeSkillPopover"
1190
1229
  />
1230
+ <!--
1231
+ Silent until the budget is nearly spent, then it says how much is
1232
+ left — and, past the limit, how much has to go before the send
1233
+ button opens again.
1234
+ -->
1235
+ <p
1236
+ v-if="draftCounterVisible"
1237
+ class="composer-counter"
1238
+ :class="{ 'composer-counter-over': draftTooLong }"
1239
+ aria-live="polite"
1240
+ >
1241
+ {{ draftCounterLabel }}
1242
+ </p>
1191
1243
  </div>
1244
+ <!--
1245
+ Start a new conversation. Sits beside the composer because that is
1246
+ where you are when you decide the last one is finished. Disabled
1247
+ while a Turn is running: the Bot is still writing to it.
1248
+ -->
1249
+ <UiIconButton
1250
+ icon="plus"
1251
+ label="New conversation"
1252
+ variant="ghost"
1253
+ class="new-conversation-button"
1254
+ :disabled="isRunning"
1255
+ @click="web.startConversation()"
1256
+ />
1192
1257
  <UiIconButton
1193
1258
  v-if="showStop"
1194
1259
  class="stop-button"
@@ -26,6 +26,10 @@ const {
26
26
  shellClientPlugin,
27
27
  } = await import("./index.js");
28
28
  import type { FrockBotWebData } from "../shared.js";
29
+ import {
30
+ UNCERTAIN_ADMISSION_MAX_ATTEMPTS_V1,
31
+ UNREACHABLE_BOT_MESSAGE_V1,
32
+ } from "./uncertain-admission.js";
29
33
  import type { Ref } from "vue";
30
34
 
31
35
  const originalLocalStorage = Object.getOwnPropertyDescriptor(
@@ -2152,6 +2156,134 @@ describe("uncertain Turn admission", () => {
2152
2156
  });
2153
2157
  });
2154
2158
 
2159
+ test("refuses a send the server answered 4xx, keeping the draft and the thread clean", async () => {
2160
+ Object.defineProperty(globalThis, "window", {
2161
+ configurable: true,
2162
+ value: { location: { href: "https://app.example/?bot=primary" } },
2163
+ });
2164
+ let provided: Ref<FrockBotWebData> | undefined;
2165
+ let lookups = 0;
2166
+ const tooLong =
2167
+ "Your message is too long. Keep it under 32,000 characters.";
2168
+ await shellClientPlugin({
2169
+ transport: {
2170
+ // What the transport throws for a 413: the answer was read, so the
2171
+ // status rides on the error beside the sentence the person should see.
2172
+ turn: () =>
2173
+ Promise.reject(Object.assign(new Error(tooLong), { status: 413 })),
2174
+ lookupRun: () => {
2175
+ lookups += 1;
2176
+ return Promise.resolve(undefined);
2177
+ },
2178
+ fenceRunAdmission: () => Promise.resolve(undefined),
2179
+ },
2180
+ slot: () => () => {},
2181
+ inject: () => {
2182
+ throw new Error("unexpected client provider injection");
2183
+ },
2184
+ provide: (_key, value) => {
2185
+ provided = value as Ref<FrockBotWebData>;
2186
+ return () => {};
2187
+ },
2188
+ });
2189
+ if (!provided) throw new Error("shell data was not provided");
2190
+ provided.value.activeBotId = "primary";
2191
+ provided.value.composerContext = "primary";
2192
+
2193
+ const result = await provided.value.sendPrompt("x".repeat(120_000));
2194
+
2195
+ // A refusal, so the composer keeps the draft — and the thread shows
2196
+ // neither the message that was never sent nor a placeholder about it.
2197
+ expect(result).toEqual({ accepted: false, error: tooLong });
2198
+ expect(provided.value.error).toBe(tooLong);
2199
+ expect(provided.value.messages).toEqual([]);
2200
+ expect(provided.value.activeRun).toBeUndefined();
2201
+ expect(provided.value.activeRunId).toBeUndefined();
2202
+ expect(provided.value.runningRunId).toBeUndefined();
2203
+ // Nothing to reconcile: the Turn was refused, not lost.
2204
+ expect(lookups).toBe(0);
2205
+ });
2206
+
2207
+ test("settles an unreachable backend after the retry bound, under the message it reports on", async () => {
2208
+ Object.defineProperty(globalThis, "window", {
2209
+ configurable: true,
2210
+ value: { location: { href: "https://app.example/?bot=primary" } },
2211
+ });
2212
+ // The bound is spent by waiting, and this test is about how the wait ends
2213
+ // rather than about how long each one is.
2214
+ const originalSetTimeout = globalThis.setTimeout;
2215
+ Object.defineProperty(globalThis, "setTimeout", {
2216
+ configurable: true,
2217
+ writable: true,
2218
+ value: ((callback: () => void) => {
2219
+ queueMicrotask(callback);
2220
+ return 0 as unknown as ReturnType<typeof originalSetTimeout>;
2221
+ }) as unknown as typeof originalSetTimeout,
2222
+ });
2223
+ let provided: Ref<FrockBotWebData> | undefined;
2224
+ let lookups = 0;
2225
+ try {
2226
+ await shellClientPlugin({
2227
+ transport: {
2228
+ // No status: the answer never arrived, so admission is genuinely
2229
+ // unknown and reconciliation is right to start.
2230
+ turn: () => Promise.reject(new TypeError("Failed to fetch")),
2231
+ lookupRun: () => {
2232
+ lookups += 1;
2233
+ return Promise.reject(new TypeError("Failed to fetch"));
2234
+ },
2235
+ fenceRunAdmission: () =>
2236
+ Promise.reject(new TypeError("Failed to fetch")),
2237
+ },
2238
+ slot: () => () => {},
2239
+ inject: () => {
2240
+ throw new Error("unexpected client provider injection");
2241
+ },
2242
+ provide: (_key, value) => {
2243
+ provided = value as Ref<FrockBotWebData>;
2244
+ return () => {};
2245
+ },
2246
+ });
2247
+ if (!provided) throw new Error("shell data was not provided");
2248
+ provided.value.activeBotId = "primary";
2249
+ provided.value.composerContext = "primary";
2250
+
2251
+ const result = await provided.value.sendPrompt("are you there");
2252
+
2253
+ expect(lookups).toBe(UNCERTAIN_ADMISSION_MAX_ATTEMPTS_V1);
2254
+ expect(result).toEqual({
2255
+ accepted: false,
2256
+ error: UNREACHABLE_BOT_MESSAGE_V1,
2257
+ });
2258
+ const [user, placeholder] = provided.value.messages;
2259
+ expect(user).toMatchObject({ role: "user", text: "are you there" });
2260
+ expect(placeholder).toMatchObject({
2261
+ role: "assistant",
2262
+ text: UNREACHABLE_BOT_MESSAGE_V1,
2263
+ status: "error",
2264
+ retry: "resend",
2265
+ });
2266
+ // Strictly after the message it reports on, so the thread's ordering by
2267
+ // time cannot lift it above that message.
2268
+ expect((placeholder?.at ?? "") > (user?.at ?? "")).toBe(true);
2269
+ // Nothing is running any more, so no Stop stands for it, and the banner
2270
+ // stops saying the client is still checking. It does not repeat the
2271
+ // bubble's sentence either: the bubble is the report, and it is the one
2272
+ // carrying the Retry.
2273
+ expect(provided.value.error).toBeUndefined();
2274
+ expect(provided.value.settingsError).toBeUndefined();
2275
+ expect(provided.value.activeRun).toBeUndefined();
2276
+ expect(provided.value.activeRunId).toBeUndefined();
2277
+ expect(provided.value.runningRunId).toBeUndefined();
2278
+ } finally {
2279
+ Object.defineProperty(globalThis, "setTimeout", {
2280
+ configurable: true,
2281
+ writable: true,
2282
+ value: originalSetTimeout,
2283
+ });
2284
+ }
2285
+ });
2286
+
2155
2287
  test("detaches a rejected Turn without starting a stale observer after Bot switch", async () => {
2156
2288
  Object.defineProperty(globalThis, "window", {
2157
2289
  configurable: true,