@frockbot/plugin-shell 0.3.7 → 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.7",
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.7",
51
- "@frockbot/application-foundation": "0.3.7",
52
- "@frockbot/catalog-core": "0.3.7",
53
- "@frockbot/client-core": "0.3.7",
54
- "@frockbot/client-ui": "0.3.7",
55
- "@frockbot/computer-core": "0.3.7",
56
- "@frockbot/computer-host-protocol": "0.3.7",
57
- "@frockbot/configuration-core": "0.3.7",
58
- "@frockbot/connection-core": "0.3.7",
59
- "@frockbot/kernel-agent-loop": "0.3.7",
60
- "@frockbot/kernel-composition": "0.3.7",
61
- "@frockbot/kernel-contracts": "0.3.7",
62
- "@frockbot/kernel-do": "0.3.7",
63
- "@frockbot/machine-protocol": "0.3.7",
64
- "@frockbot/plugin-applets": "0.3.7",
65
- "@frockbot/plugin-authoring": "0.3.7",
66
- "@frockbot/plugin-bot-template": "0.3.7",
67
- "@frockbot/plugin-computer": "0.3.7",
68
- "@frockbot/plugin-flock": "0.3.7",
69
- "@frockbot/plugin-image": "0.3.7",
70
- "@frockbot/plugin-machine-messages": "0.3.7",
71
- "@frockbot/plugin-mcp": "0.3.7",
72
- "@frockbot/plugin-memory": "0.3.7",
73
- "@frockbot/plugin-package-catalog": "0.3.7",
74
- "@frockbot/plugin-routines": "0.3.7",
75
- "@frockbot/plugin-skills": "0.3.7",
76
- "@frockbot/plugin-subagents": "0.3.7",
77
- "@frockbot/plugin-user-machine": "0.3.7",
78
- "@frockbot/protocol": "0.3.7",
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.7",
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",
package/src/backend.ts CHANGED
@@ -437,6 +437,7 @@ import {
437
437
  optionalSidebarMessagePreviewV1,
438
438
  optionalUnreadStateV1,
439
439
  projectBotUnreadViewV1,
440
+ sidebarMessagePreviewFromRunsV1,
440
441
  SIDEBAR_PREVIEW_KEY,
441
442
  unreadReceiptKeyV1,
442
443
  UNREAD_COUNT_CAP,
@@ -444,6 +445,8 @@ import {
444
445
  type BotUnreadCommandV1,
445
446
  type BotUnreadReceiptV1,
446
447
  type BotUnreadViewV1,
448
+ type SidebarMessagePreviewV1,
449
+ type SidebarPreviewRunV1,
447
450
  } from "./unread.js";
448
451
  import { defineBotBackendContribution } from "@frockbot/kernel-contracts/contributions";
449
452
 
@@ -5254,7 +5257,6 @@ export class ShellBotBackendContribution {
5254
5257
  ]),
5255
5258
  );
5256
5259
  const state = optionalUnreadStateV1(storedState);
5257
- const preview = optionalSidebarMessagePreviewV1(storedPreview);
5258
5260
  const index = await this.authority.listRunIndex({
5259
5261
  limit: UNREAD_COUNT_CAP + 1,
5260
5262
  });
@@ -5262,10 +5264,41 @@ export class ShellBotBackendContribution {
5262
5264
  identity.botId,
5263
5265
  state,
5264
5266
  index.map((entry) => entry.cursor),
5265
- preview,
5267
+ await this.sidebarPreview(storedPreview, index),
5266
5268
  );
5267
5269
  }
5268
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
+
5269
5302
  /**
5270
5303
  * `bot/mark-read` and `bot/mark-unread`. Idempotent on the command id and
5271
5304
  * monotonic in the cursor, so a replay or an out-of-order delivery can only
@@ -5294,9 +5327,7 @@ export class ShellBotBackendContribution {
5294
5327
  }
5295
5328
  return {
5296
5329
  state: optionalUnreadStateV1(existing.state),
5297
- preview: optionalSidebarMessagePreviewV1(
5298
- await transaction.get<unknown>(SIDEBAR_PREVIEW_KEY),
5299
- ),
5330
+ preview: await transaction.get<unknown>(SIDEBAR_PREVIEW_KEY),
5300
5331
  };
5301
5332
  }
5302
5333
  const current = optionalUnreadStateV1(
@@ -5320,9 +5351,7 @@ export class ShellBotBackendContribution {
5320
5351
  });
5321
5352
  return {
5322
5353
  state: next,
5323
- preview: optionalSidebarMessagePreviewV1(
5324
- await transaction.get<unknown>(SIDEBAR_PREVIEW_KEY),
5325
- ),
5354
+ preview: await transaction.get<unknown>(SIDEBAR_PREVIEW_KEY),
5326
5355
  };
5327
5356
  });
5328
5357
  const index = await this.authority.listRunIndex({
@@ -5336,7 +5365,10 @@ export class ShellBotBackendContribution {
5336
5365
  identity.botId,
5337
5366
  stored.state,
5338
5367
  index.map((entry) => entry.cursor),
5339
- 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),
5340
5372
  ),
5341
5373
  };
5342
5374
  }
@@ -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,6 +1227,19 @@ 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>
1192
1244
  <!--
1193
1245
  Start a new conversation. Sits beside the composer because that is
@@ -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,
@@ -62,6 +62,12 @@ import {
62
62
  decodeApprovalDecisionReceiptV1,
63
63
  decodeApprovalListViewV1,
64
64
  } from "../approvals.js";
65
+ import {
66
+ isCertainSendRefusalV1,
67
+ momentAfterV1,
68
+ uncertainAdmissionDelayMsV1,
69
+ UNREACHABLE_BOT_MESSAGE_V1,
70
+ } from "./uncertain-admission.js";
65
71
  import {
66
72
  decodeTaskListViewV1,
67
73
  decodeTaskViewV1,
@@ -976,7 +982,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
976
982
  botId: string,
977
983
  runId: string,
978
984
  signal: AbortSignal,
979
- ): Promise<"admitted" | "not-admitted" | "detached"> {
985
+ ): Promise<"admitted" | "not-admitted" | "detached" | "unreachable"> {
980
986
  web.value.activeRun = {
981
987
  runId,
982
988
  status: "running",
@@ -986,7 +992,6 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
986
992
  if (!ctx.transport.lookupRun || !ctx.transport.fenceRunAdmission) {
987
993
  return "detached";
988
994
  }
989
- let delayMs = 250;
990
995
  let reconciliationError: string | undefined;
991
996
  const clearReconciliationError = () => {
992
997
  if (web.value.settingsError === reconciliationError) {
@@ -994,7 +999,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
994
999
  }
995
1000
  reconciliationError = undefined;
996
1001
  };
997
- while (!signal.aborted) {
1002
+ for (let attempt = 1; !signal.aborted; attempt += 1) {
998
1003
  try {
999
1004
  const observed = await observeWhileAttached(
1000
1005
  ctx.transport.lookupRun(botId, runId),
@@ -1024,8 +1029,16 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1024
1029
  } Retrying…`;
1025
1030
  web.value.settingsError = reconciliationError;
1026
1031
  }
1032
+ const delayMs = uncertainAdmissionDelayMsV1(attempt);
1033
+ // The bound is spent. Asking again would only keep a placeholder
1034
+ // spinning over a backend this tab cannot reach, so the caller settles
1035
+ // the Turn and says so in the thread.
1036
+ if (delayMs === undefined) {
1037
+ clearReconciliationError();
1038
+ web.value.activeRun = undefined;
1039
+ return "unreachable";
1040
+ }
1027
1041
  await waitForRunLookup(delayMs, signal);
1028
- delayMs = Math.min(delayMs * 2, 5_000);
1029
1042
  }
1030
1043
  return "detached";
1031
1044
  }
@@ -2534,14 +2547,39 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2534
2547
  web.value.error = refusal;
2535
2548
  return { accepted: false, error: refusal };
2536
2549
  }
2550
+ // Every other 4xx is a refusal too, and the answer already says why —
2551
+ // a message over the size limit is answered 413 with the sentence the
2552
+ // person needs. Only 5xx and a lost connection leave admission in
2553
+ // doubt, so anything else here is settled: no optimistic bubbles, no
2554
+ // "checking" placeholder, no reconciliation, and the composer gets the
2555
+ // draft back rather than the thread pretending it was sent.
2556
+ if (isCertainSendRefusalV1(error)) {
2557
+ removeMessages(web.value.messages, pendingRunId);
2558
+ const refusal =
2559
+ error instanceof Error && error.message
2560
+ ? error.message
2561
+ : "That message didn't go through. Try sending it again.";
2562
+ web.value.error = refusal;
2563
+ return { accepted: false, error: refusal };
2564
+ }
2537
2565
  const aborted =
2538
2566
  error instanceof DOMException && error.name === "AbortError";
2567
+ // Strictly after the message it reports on. The thread orders by time,
2568
+ // and the durable projection gives the user's line the run's later
2569
+ // `admittedAt`, so a placeholder carrying the moment the send began
2570
+ // sorted above the message it belongs to.
2571
+ const placeholderAt = momentAfterV1(
2572
+ web.value.messages.find(
2573
+ (message) =>
2574
+ message.runId === pendingRunId && message.role === "user",
2575
+ )?.at ?? optimisticAt,
2576
+ );
2539
2577
  replaceMessage(web.value.messages, pendingRunId, {
2540
2578
  id: `${pendingRunId}:assistant`,
2541
2579
  runId: pendingRunId,
2542
2580
  role: "assistant",
2543
2581
  text: "Checking whether your message went through…",
2544
- at: optimisticAt,
2582
+ at: placeholderAt,
2545
2583
  status: "interrupted",
2546
2584
  tools: [],
2547
2585
  sends: [],
@@ -2554,7 +2592,8 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2554
2592
  }
2555
2593
  const observer = new AbortController();
2556
2594
  admissionObserver = observer;
2557
- let disposition: "admitted" | "not-admitted" | "detached";
2595
+ let disposition:
2596
+ "admitted" | "not-admitted" | "detached" | "unreachable";
2558
2597
  try {
2559
2598
  disposition = await reconcileUncertainAdmission(
2560
2599
  botId,
@@ -2564,13 +2603,40 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2564
2603
  } finally {
2565
2604
  if (admissionObserver === observer) admissionObserver = undefined;
2566
2605
  }
2606
+ // The reconciliation ran out of attempts: this tab cannot reach the
2607
+ // backend at all. That is the app's own failure and it says so, in
2608
+ // place of the placeholder, with the Retry the person would otherwise
2609
+ // have to improvise — and with the Turn no longer running, so Stop
2610
+ // stops standing for a Turn nobody is executing.
2611
+ if (disposition === "unreachable") {
2612
+ replaceMessage(web.value.messages, pendingRunId, {
2613
+ id: `${pendingRunId}:assistant`,
2614
+ runId: pendingRunId,
2615
+ role: "assistant",
2616
+ text: UNREACHABLE_BOT_MESSAGE_V1,
2617
+ at: placeholderAt,
2618
+ status: "error",
2619
+ retry: "resend",
2620
+ tools: [],
2621
+ sends: [],
2622
+ });
2623
+ // The bubble is the report, and it is the one carrying the Retry.
2624
+ // Saying the same sentence again in the banner above it is what the
2625
+ // thread already looked like when it was broken — the same string
2626
+ // three times over — so the banner is cleared rather than set.
2627
+ web.value.error = undefined;
2628
+ web.value.activeRun = undefined;
2629
+ web.value.activeRunId = undefined;
2630
+ web.value.runningRunId = undefined;
2631
+ return { accepted: false, error: UNREACHABLE_BOT_MESSAGE_V1 };
2632
+ }
2567
2633
  if (disposition === "not-admitted") {
2568
2634
  replaceMessage(web.value.messages, pendingRunId, {
2569
2635
  id: `${pendingRunId}:assistant`,
2570
2636
  runId: pendingRunId,
2571
2637
  role: "assistant",
2572
2638
  text: "Your message didn't go through. Try sending it again.",
2573
- at: optimisticAt,
2639
+ at: placeholderAt,
2574
2640
  status: "error",
2575
2641
  tools: [],
2576
2642
  sends: [],
@@ -301,6 +301,28 @@
301
301
  font-size: var(--frock-text-xs);
302
302
  }
303
303
 
304
+ /*
305
+ * The way back from an ending the thread cannot recover on its own: the client
306
+ * gave up reaching the Bot, and the draft it handed back is one click from
307
+ * being sent again.
308
+ */
309
+ .message-retry {
310
+ align-self: flex-start;
311
+ margin-top: 4px;
312
+ padding: 4px 10px;
313
+ border: 1px solid var(--frock-danger-border);
314
+ border-radius: var(--frock-radius-control);
315
+ color: var(--frock-danger-text);
316
+ background: transparent;
317
+ font-size: var(--frock-text-xs);
318
+ cursor: pointer;
319
+ }
320
+
321
+ .message-retry:disabled {
322
+ opacity: 0.6;
323
+ cursor: default;
324
+ }
325
+
304
326
  /*
305
327
  * An assistant Turn is its avatar and, beside it, one column holding
306
328
  * everything the Turn produced. The row has exactly two children: bubbles,
@@ -720,6 +742,18 @@
720
742
  gap: 6px;
721
743
  }
722
744
 
745
+ /* Only ever on screen near the limit, so it sits under the text it counts. */
746
+ .composer-counter {
747
+ margin: 0;
748
+ align-self: flex-end;
749
+ color: var(--frock-text-muted);
750
+ font-size: var(--frock-text-xs);
751
+ }
752
+
753
+ .composer-counter-over {
754
+ color: var(--frock-danger-text);
755
+ }
756
+
723
757
  .skill-chips {
724
758
  display: flex;
725
759
  margin: 0;
@@ -0,0 +1,41 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ TURN_TEXT_COUNTER_FROM_V1,
4
+ TURN_TEXT_MAX_CHARACTERS_V1,
5
+ turnTextCounterVisibleV1,
6
+ turnTextRemainingV1,
7
+ turnTextTooLongV1,
8
+ } from "./turn-limits.js";
9
+
10
+ describe("the composer's copy of the send limit", () => {
11
+ test("mirrors the number the send route refuses on", () => {
12
+ // The gateway's `TURN_TEXT_MAX_CHARACTERS_V1`. Pinned here because the two
13
+ // are only equal by intent: the client cannot import the Worker's module.
14
+ expect(TURN_TEXT_MAX_CHARACTERS_V1).toBe(32_000);
15
+ });
16
+
17
+ test("is quiet until the budget is nearly spent", () => {
18
+ expect(turnTextCounterVisibleV1("a short message")).toBe(false);
19
+ expect(
20
+ turnTextCounterVisibleV1("x".repeat(TURN_TEXT_COUNTER_FROM_V1)),
21
+ ).toBe(true);
22
+ });
23
+
24
+ test("counts down, then counts the overflow", () => {
25
+ expect(turnTextRemainingV1("x".repeat(TURN_TEXT_MAX_CHARACTERS_V1))).toBe(
26
+ 0,
27
+ );
28
+ expect(
29
+ turnTextRemainingV1("x".repeat(TURN_TEXT_MAX_CHARACTERS_V1 + 5)),
30
+ ).toBe(-5);
31
+ });
32
+
33
+ test("refuses only past the limit, never at it", () => {
34
+ expect(turnTextTooLongV1("x".repeat(TURN_TEXT_MAX_CHARACTERS_V1))).toBe(
35
+ false,
36
+ );
37
+ expect(turnTextTooLongV1("x".repeat(TURN_TEXT_MAX_CHARACTERS_V1 + 1))).toBe(
38
+ true,
39
+ );
40
+ });
41
+ });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The composer's copy of the send route's size rule.
3
+ *
4
+ * The gateway refuses an oversized Turn with 413 before it ever reaches a Bot
5
+ * (`TURN_TEXT_MAX_CHARACTERS_V1` in the Cloudflare application's
6
+ * `request-body.ts`). A refusal the person could have seen coming is a bad
7
+ * refusal, so the composer enforces the same number: it counts down as the
8
+ * limit comes into reach and refuses to send past it, and the server's rule
9
+ * stays the authority for anything that reaches it another way.
10
+ *
11
+ * One constant rather than a literal at each use, because a limit written
12
+ * twice is a limit that drifts.
13
+ */
14
+ export const TURN_TEXT_MAX_CHARACTERS_V1 = 32_000;
15
+
16
+ /**
17
+ * Where the counter appears.
18
+ *
19
+ * A character count beside a half-written sentence is noise; it is only news
20
+ * as the budget runs out. The last tenth is where a person can still act on
21
+ * it — trim a paragraph, split the message — before the send button closes.
22
+ */
23
+ export const TURN_TEXT_COUNTER_FROM_V1 = Math.floor(
24
+ TURN_TEXT_MAX_CHARACTERS_V1 * 0.9,
25
+ );
26
+
27
+ /** How much of the budget is left; negative once the draft is over it. */
28
+ export function turnTextRemainingV1(text: string): number {
29
+ return TURN_TEXT_MAX_CHARACTERS_V1 - text.length;
30
+ }
31
+
32
+ /** True once the draft is longer than the send route would accept. */
33
+ export function turnTextTooLongV1(text: string): boolean {
34
+ return turnTextRemainingV1(text) < 0;
35
+ }
36
+
37
+ /** True once the count is worth showing. */
38
+ export function turnTextCounterVisibleV1(text: string): boolean {
39
+ return text.length >= TURN_TEXT_COUNTER_FROM_V1;
40
+ }
@@ -0,0 +1,69 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ isCertainSendRefusalV1,
4
+ momentAfterV1,
5
+ uncertainAdmissionDelayMsV1,
6
+ UNCERTAIN_ADMISSION_MAX_ATTEMPTS_V1,
7
+ UNREACHABLE_BOT_MESSAGE_V1,
8
+ } from "./uncertain-admission.js";
9
+
10
+ describe("telling a refusal from a doubt", () => {
11
+ test("a 4xx is certain and a 5xx is not", () => {
12
+ const refused = Object.assign(new Error("Your message is too long."), {
13
+ status: 413,
14
+ });
15
+ const failed = Object.assign(new Error("Agent request failed"), {
16
+ status: 500,
17
+ });
18
+ expect(isCertainSendRefusalV1(refused)).toBe(true);
19
+ expect(isCertainSendRefusalV1(failed)).toBe(false);
20
+ });
21
+
22
+ test("an error with no status is a doubt", () => {
23
+ // What a dropped connection throws: no answer was read, so nothing about
24
+ // the send is settled.
25
+ expect(isCertainSendRefusalV1(new TypeError("Failed to fetch"))).toBe(
26
+ false,
27
+ );
28
+ expect(isCertainSendRefusalV1(undefined)).toBe(false);
29
+ expect(isCertainSendRefusalV1({ status: "413" })).toBe(false);
30
+ });
31
+ });
32
+
33
+ describe("the admission retry bound", () => {
34
+ test("backs off and then stops asking", () => {
35
+ const delays = Array.from(
36
+ { length: UNCERTAIN_ADMISSION_MAX_ATTEMPTS_V1 },
37
+ (_unused, index) => uncertainAdmissionDelayMsV1(index + 1),
38
+ );
39
+ // Five waits between six attempts, doubling; the sixth attempt is the last
40
+ // one, so it is followed by no wait at all.
41
+ expect(delays).toEqual([250, 500, 1_000, 2_000, 4_000, undefined]);
42
+ });
43
+
44
+ test("never waits past the ceiling", () => {
45
+ expect(uncertainAdmissionDelayMsV1(1)).toBe(250);
46
+ for (let attempt = 1; attempt <= 20; attempt += 1) {
47
+ const delay = uncertainAdmissionDelayMsV1(attempt);
48
+ if (delay === undefined) continue;
49
+ expect(delay).toBeLessThanOrEqual(5_000);
50
+ }
51
+ });
52
+
53
+ test("the terminal copy names the app's own failure", () => {
54
+ expect(UNREACHABLE_BOT_MESSAGE_V1).toContain("Couldn't reach the Bot");
55
+ });
56
+ });
57
+
58
+ describe("placing a line after the one it reports on", () => {
59
+ test("is strictly later, and stays sortable as a string", () => {
60
+ const at = "2026-09-01T00:01:00.000Z";
61
+ const after = momentAfterV1(at);
62
+ expect(after > at).toBe(true);
63
+ expect(after).toBe("2026-09-01T00:01:00.001Z");
64
+ });
65
+
66
+ test("leaves a timestamp it cannot read alone", () => {
67
+ expect(momentAfterV1("not a time")).toBe("not a time");
68
+ });
69
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * What a send means when the answer was not a Turn.
3
+ *
4
+ * There are two of those and they are not alike. A 4xx is the server having
5
+ * read the request and decided against it: the Turn does not exist, will not
6
+ * exist, and the answer says why. Everything else — a 5xx, a socket that
7
+ * closed, a browser that lost the network — leaves admission genuinely
8
+ * unknown, and only that case is worth reconciling.
9
+ *
10
+ * Telling them apart is the whole point. Treating a 413 as unknown drew the
11
+ * person's oversized draft into the thread as though it had been sent, then
12
+ * polled for a run that was never admitted.
13
+ */
14
+
15
+ /**
16
+ * True when the transport's error carries a 4xx: a refusal, not a doubt.
17
+ *
18
+ * Duck-typed on `status` rather than on an error class, because the transport
19
+ * is an interface with more than one implementation and a plain `Error` with a
20
+ * status is all any of them can be relied on to throw.
21
+ */
22
+ export function isCertainSendRefusalV1(error: unknown): boolean {
23
+ if (typeof error !== "object" || error === null) return false;
24
+ if (!("status" in error)) return false;
25
+ const status = error.status;
26
+ return typeof status === "number" && status >= 400 && status < 500;
27
+ }
28
+
29
+ /**
30
+ * How many times admission reconciliation asks before it stops asking.
31
+ *
32
+ * A bound rather than an ever-retrying loop: a backend the tab cannot reach
33
+ * does not become reachable by being asked a thousand times, and the person
34
+ * watching a placeholder deserves an answer inside a few seconds. Six attempts
35
+ * over the schedule below spans roughly eight seconds, which covers a Worker
36
+ * cold start and a brief network blip without outliving anybody's patience.
37
+ */
38
+ export const UNCERTAIN_ADMISSION_MAX_ATTEMPTS_V1 = 6;
39
+
40
+ const FIRST_DELAY_MS_V1 = 250;
41
+ const MAX_DELAY_MS_V1 = 5_000;
42
+
43
+ /**
44
+ * How long to wait before attempt `attempt + 1`, or `undefined` once the bound
45
+ * is spent and the client should settle instead of asking again.
46
+ */
47
+ export function uncertainAdmissionDelayMsV1(
48
+ attempt: number,
49
+ ): number | undefined {
50
+ if (attempt >= UNCERTAIN_ADMISSION_MAX_ATTEMPTS_V1) return undefined;
51
+ return Math.min(FIRST_DELAY_MS_V1 * 2 ** (attempt - 1), MAX_DELAY_MS_V1);
52
+ }
53
+
54
+ /**
55
+ * What the thread says when the client gave up reaching the backend.
56
+ *
57
+ * Naming the app's own failure, because nothing else in the product did: every
58
+ * other line blames the message or the Bot, and a person whose wifi dropped
59
+ * was told their message "didn't go through" as though the Bot had refused it.
60
+ */
61
+ export const UNREACHABLE_BOT_MESSAGE_V1 =
62
+ "Couldn't reach the Bot. Check your connection and try again.";
63
+
64
+ /**
65
+ * A timestamp one millisecond after `at`.
66
+ *
67
+ * The thread sorts by time, so a line that belongs under another needs a time
68
+ * of its own: the placeholder used to carry the moment the send *began*, which
69
+ * put it above the message it was reporting on the instant the durable
70
+ * projection gave that message its later `admittedAt`.
71
+ */
72
+ export function momentAfterV1(at: string): string {
73
+ const moment = new Date(at).getTime();
74
+ // An unparsable timestamp is not worth inventing an order for; the thread's
75
+ // insertion order still holds the line where it was put.
76
+ if (!Number.isFinite(moment)) return at;
77
+ return new Date(moment + 1).toISOString();
78
+ }
@@ -3,6 +3,7 @@ import {
3
3
  BOT_DEBUG_RUN_LIMIT_V1,
4
4
  boundDebugEventsV1,
5
5
  decodeBotDebugQueryV1,
6
+ isBotDebugQueryRefusalV1,
6
7
  } from "./debug-protocol.js";
7
8
 
8
9
  describe("debug query", () => {
@@ -36,13 +37,48 @@ describe("debug query", () => {
36
37
  ).toThrow("debug query has invalid fields");
37
38
  });
38
39
 
39
- test("rejects a limit past the page bound", () => {
40
+ test("rejects a limit past the page bound, in words that name the range", () => {
40
41
  expect(() =>
41
42
  decodeBotDebugQueryV1({
42
43
  schemaVersion: 1,
43
44
  limit: BOT_DEBUG_RUN_LIMIT_V1 + 1,
44
45
  }),
45
- ).toThrow("debug query limit is invalid");
46
+ ).toThrow(
47
+ `debug query limit must be a whole number from 1 to ${BOT_DEBUG_RUN_LIMIT_V1}`,
48
+ );
49
+ });
50
+
51
+ // The refusal rides on the error's name, which is all a Durable Object RPC
52
+ // preserves: a bad query has to arrive at the gateway as a 400 and not as
53
+ // an uncaught failure in the Bot's isolate.
54
+ test("refuses a bad query as a refusal, not as an ordinary failure", () => {
55
+ for (const input of [
56
+ { schemaVersion: 1, limit: 0 },
57
+ { schemaVersion: 1, limit: BOT_DEBUG_RUN_LIMIT_V1 + 1 },
58
+ { schemaVersion: 1, limit: 1.5 },
59
+ { schemaVersion: 1, limit: Number.NaN },
60
+ { schemaVersion: 1, sql: "select 1" },
61
+ { schemaVersion: 2 },
62
+ "not a query",
63
+ ]) {
64
+ let refusal: unknown;
65
+ try {
66
+ decodeBotDebugQueryV1(input);
67
+ } catch (error) {
68
+ refusal = error;
69
+ }
70
+ expect(isBotDebugQueryRefusalV1(refusal)).toBe(true);
71
+ }
72
+ });
73
+
74
+ test("accepts both ends of the allowed range", () => {
75
+ expect(decodeBotDebugQueryV1({ schemaVersion: 1, limit: 1 }).limit).toBe(1);
76
+ expect(
77
+ decodeBotDebugQueryV1({
78
+ schemaVersion: 1,
79
+ limit: BOT_DEBUG_RUN_LIMIT_V1,
80
+ }).limit,
81
+ ).toBe(BOT_DEBUG_RUN_LIMIT_V1);
46
82
  });
47
83
 
48
84
  test("rejects a wrong schema version", () => {
@@ -90,21 +90,50 @@ export interface BotDebugSnapshotV1 {
90
90
  nextCursor?: string;
91
91
  }
92
92
 
93
+ /**
94
+ * A debug query the caller got wrong: an unknown field, a `limit` past the cap.
95
+ * The request is what is bad, not the Bot, so the surface owes a 400 rather
96
+ * than an uncaught failure in the isolate. The name is what carries that
97
+ * across the Durable Object RPC boundary — which keeps an error's `name` and
98
+ * `message` and drops everything else — exactly as `BotTurnRefusedError` does
99
+ * for a refused admission.
100
+ */
101
+ export class BotDebugQueryRefusedErrorV1 extends Error {
102
+ constructor(message: string) {
103
+ super(message);
104
+ this.name = "BotDebugQueryRefusedErrorV1";
105
+ }
106
+ }
107
+
108
+ /** Whether an error — including one that has crossed RPC — is that refusal. */
109
+ export function isBotDebugQueryRefusalV1(error: unknown): boolean {
110
+ return (
111
+ typeof error === "object" &&
112
+ error !== null &&
113
+ "name" in error &&
114
+ String((error as { name: unknown }).name) === "BotDebugQueryRefusedErrorV1"
115
+ );
116
+ }
117
+
93
118
  function isRecord(value: unknown): value is Record<string, unknown> {
94
119
  return typeof value === "object" && value !== null && !Array.isArray(value);
95
120
  }
96
121
 
97
122
  function boundedString(value: unknown, maximum: number, field: string): string {
98
123
  if (typeof value !== "string" || value.length < 1 || value.length > maximum) {
99
- throw new Error(`debug query ${field} is invalid`);
124
+ throw new BotDebugQueryRefusedErrorV1(`debug query ${field} is invalid`);
100
125
  }
101
126
  return value;
102
127
  }
103
128
 
104
129
  export function decodeBotDebugQueryV1(input: unknown): BotDebugQueryV1 {
105
- if (!isRecord(input)) throw new Error("debug query is invalid");
130
+ if (!isRecord(input)) {
131
+ throw new BotDebugQueryRefusedErrorV1("debug query is invalid");
132
+ }
106
133
  if (input.schemaVersion !== 1) {
107
- throw new Error("debug query schemaVersion is invalid");
134
+ throw new BotDebugQueryRefusedErrorV1(
135
+ "debug query schemaVersion is invalid",
136
+ );
108
137
  }
109
138
  const allowed = new Set([
110
139
  "schemaVersion",
@@ -114,7 +143,7 @@ export function decodeBotDebugQueryV1(input: unknown): BotDebugQueryV1 {
114
143
  "events",
115
144
  ]);
116
145
  if (!Object.keys(input).every((key) => allowed.has(key))) {
117
- throw new Error("debug query has invalid fields");
146
+ throw new BotDebugQueryRefusedErrorV1("debug query has invalid fields");
118
147
  }
119
148
  const query: BotDebugQueryV1 = { schemaVersion: 1 };
120
149
  if (input.runId !== undefined) {
@@ -129,13 +158,17 @@ export function decodeBotDebugQueryV1(input: unknown): BotDebugQueryV1 {
129
158
  (input.limit as number) < 1 ||
130
159
  (input.limit as number) > BOT_DEBUG_RUN_LIMIT_V1
131
160
  ) {
132
- throw new Error("debug query limit is invalid");
161
+ throw new BotDebugQueryRefusedErrorV1(
162
+ `debug query limit must be a whole number from 1 to ${BOT_DEBUG_RUN_LIMIT_V1}`,
163
+ );
133
164
  }
134
165
  query.limit = input.limit as number;
135
166
  }
136
167
  if (input.events !== undefined) {
137
168
  if (typeof input.events !== "boolean") {
138
- throw new Error("debug query events is invalid");
169
+ throw new BotDebugQueryRefusedErrorV1(
170
+ "debug query events must be true or false",
171
+ );
139
172
  }
140
173
  query.events = input.events;
141
174
  }
@@ -24,6 +24,7 @@ import {
24
24
  projectClientRunV1,
25
25
  projectClientRunOrDegradedV1,
26
26
  projectClientTurnV1,
27
+ UNRECORDED_TOOL_RESULT_TEXT_V1,
27
28
  } from "./run-protocol.js";
28
29
 
29
30
  const timestamp = "2026-08-29T00:00:00.000Z";
@@ -1061,14 +1062,59 @@ describe("client run protocol v1", () => {
1061
1062
  expect(() => projectClientRunListV1([storedRun([result])])).toThrow(
1062
1063
  'tool result has no matching occurrence "tool:1:1:0"',
1063
1064
  );
1064
- expect(() => projectClientRunListV1([storedRun([call])])).toThrow(
1065
- 'terminal run has no result for tool call "tool-1"',
1066
- );
1067
1065
  expect(() =>
1068
1066
  projectClientRunListV1([storedRun([call, call, result])]),
1069
1067
  ).toThrow('tool occurrence "tool:1:1:0" has duplicate intent');
1070
1068
  });
1071
1069
 
1070
+ // A READ never throws on a record that is already durable. A settled Turn
1071
+ // whose tool call was never settled used to fail the whole transcript
1072
+ // endpoint — a 500 on every later request — so one malformed row bricked the
1073
+ // conversation for ever. It degrades to a row saying nothing was recorded.
1074
+ test("degrades a settled Turn's unsettled tool call instead of throwing", () => {
1075
+ const call = toolEvents(1)[0]!;
1076
+
1077
+ const projected = projectClientRunListV1([storedRun([call])]).runs[0];
1078
+
1079
+ expect(projected?.events).toEqual([
1080
+ { type: "tool/call", call: { id: "tool-1", name: "lookup" } },
1081
+ {
1082
+ type: "tool/result",
1083
+ callId: "tool-1",
1084
+ content: UNRECORDED_TOOL_RESULT_TEXT_V1,
1085
+ isError: true,
1086
+ },
1087
+ ]);
1088
+ // And the degraded row survives the wire decode, which used to refuse it
1089
+ // for the same reason the projection did.
1090
+ expect(
1091
+ decodeClientRunListV1({
1092
+ schemaVersion: 1,
1093
+ runs: [projected],
1094
+ page: { truncated: false },
1095
+ })[0]?.events,
1096
+ ).toHaveLength(2);
1097
+ });
1098
+
1099
+ test("accepts a settled Turn on the wire whose call carries no result", () => {
1100
+ const projected = projectClientRunListV1([storedRun([])]).runs[0]!;
1101
+
1102
+ expect(
1103
+ decodeClientRunListV1({
1104
+ schemaVersion: 1,
1105
+ runs: [
1106
+ {
1107
+ ...projected,
1108
+ events: [
1109
+ { type: "tool/call", call: { id: "tool-1", name: "lookup" } },
1110
+ ],
1111
+ },
1112
+ ],
1113
+ page: { truncated: false },
1114
+ })[0]?.events,
1115
+ ).toEqual([{ type: "tool/call", call: { id: "tool-1", name: "lookup" } }]);
1116
+ });
1117
+
1072
1118
  test("retains pending calls only for nonterminal runs", () => {
1073
1119
  const call = toolEvents(1)[0]!;
1074
1120
  const projected = projectClientRunListV1([storedRun([call], "running")])
@@ -500,6 +500,14 @@ interface ProjectionUnitV1 {
500
500
  droppable: boolean;
501
501
  }
502
502
 
503
+ /**
504
+ * What a settled Turn's tool call shows when the durable record holds no
505
+ * result for it. Same register as the rest of the transcript copy: it tells
506
+ * the person what is missing rather than naming an occurrence id.
507
+ */
508
+ export const UNRECORDED_TOOL_RESULT_TEXT_V1 =
509
+ "No result was recorded for this tool call.";
510
+
503
511
  function dynamicToolCallInput(
504
512
  value: unknown,
505
513
  ): ClientDynamicToolCallInputV1 | undefined {
@@ -658,12 +666,23 @@ function projectionUnits(
658
666
  }
659
667
  }
660
668
  if (isTerminalRunStatus(status)) {
661
- const orphaned = units.find((unit) => !unit.droppable);
662
- if (orphaned) {
663
- const call = orphaned.events[0] as ClientToolCallV1;
664
- throw new Error(
665
- `terminal run has no result for tool call "${call.call.id}"`,
666
- );
669
+ for (const unit of units) {
670
+ if (unit.droppable) continue;
671
+ // A settled Turn owes every tool call a result, and `Session`'s
672
+ // interruption repairs now write one. Records already durable from
673
+ // before that do not have it, and a READ must never throw on them: one
674
+ // malformed row used to brick the whole transcript endpoint for ever.
675
+ // The row degrades instead, and says exactly what is missing — not
676
+ // through `projectClientRunOrDegradedV1`, which throws the whole Turn
677
+ // away for an unreadable record. Everything else here is readable.
678
+ const call = unit.events[0] as ClientToolCallV1;
679
+ unit.events.push({
680
+ type: "tool/result",
681
+ callId: call.call.id,
682
+ content: UNRECORDED_TOOL_RESULT_TEXT_V1,
683
+ isError: true,
684
+ });
685
+ unit.droppable = true;
667
686
  }
668
687
  }
669
688
  return units;
@@ -1137,10 +1156,12 @@ function decodeEvent(value: unknown): ClientRunEventV1 {
1137
1156
  throw new Error("run event.type is invalid");
1138
1157
  }
1139
1158
 
1140
- function decodeEvents(
1141
- values: unknown[],
1142
- runStatus: ClientRunStatusV1,
1143
- ): ClientTurnEvent[] {
1159
+ /**
1160
+ * The event walk a wire run is decoded through. It no longer takes the run's
1161
+ * status: a settled Turn's tool call with no result is a row the projection
1162
+ * has already degraded, not a message to refuse.
1163
+ */
1164
+ function decodeEvents(values: unknown[]): ClientTurnEvent[] {
1144
1165
  const events = values.map(decodeEvent);
1145
1166
  let index = 0;
1146
1167
  if (events[0]?.type === "run/events-truncated") index = 1;
@@ -1178,9 +1199,10 @@ function decodeEvents(
1178
1199
  index += 2;
1179
1200
  continue;
1180
1201
  }
1181
- if (isTerminalRunStatus(runStatus)) {
1182
- throw new Error(`terminal run has no result for tool call "${id}"`);
1183
- }
1202
+ // A settled Turn whose call has no result is a degraded row, not a bad
1203
+ // wire message: the projection above already renders it as "no result
1204
+ // recorded", and refusing it here would put the whole transcript behind
1205
+ // one durable record nobody can now repair.
1184
1206
  index += 1;
1185
1207
  }
1186
1208
  return events;
@@ -1330,7 +1352,7 @@ function decodeRun(value: unknown): ClientRun {
1330
1352
  admittedAt,
1331
1353
  input: wireString(run, "input", MAX_INPUT_BYTES, "run"),
1332
1354
  status: runStatus,
1333
- events: decodeEvents(run.events, runStatus),
1355
+ events: decodeEvents(run.events),
1334
1356
  ...(stopRequestedAt ? { stopRequestedAt } : {}),
1335
1357
  ...(run.queued === true ? { queued: true as const } : {}),
1336
1358
  ...(outcome?.type === "completed" ? { responseText: outcome.text } : {}),
@@ -1454,7 +1476,7 @@ export function decodeClientTurnV1(input: unknown): ClientTurnResponse {
1454
1476
  return {
1455
1477
  runId,
1456
1478
  text: wireString(turn, "text", MAX_OUTCOME_BYTES, "turn"),
1457
- events: decodeEvents(turn.events, "completed"),
1479
+ events: decodeEvents(turn.events),
1458
1480
  ...(notification ? { notification } : {}),
1459
1481
  };
1460
1482
  }
package/src/shared.ts CHANGED
@@ -107,6 +107,13 @@ export interface WebChatMessage {
107
107
  * Bot had already said, which it keeps (ADR 0024).
108
108
  */
109
109
  notice?: string;
110
+ /**
111
+ * The line offers to send the draft again. Set only where the client gave
112
+ * up on its own — it could not reach the backend — because that is the one
113
+ * ending the person cannot act on from the thread otherwise: their text is
114
+ * back in the composer, and this is the button that sends it.
115
+ */
116
+ retry?: "resend";
110
117
  tools: WebToolActivity[];
111
118
  /** The typed payloads this Turn sent to the user, oldest first. */
112
119
  sends: WebSendPayload[];
@@ -13,6 +13,7 @@ import {
13
13
  optionalUnreadStateV1,
14
14
  projectBotUnreadViewV1,
15
15
  sidebarMessagePreviewForTurnV1,
16
+ sidebarMessagePreviewFromRunsV1,
16
17
  UNREAD_COUNT_CAP,
17
18
  type UnreadStateV1,
18
19
  } from "./unread.js";
@@ -263,6 +264,73 @@ describe("the unread projection", () => {
263
264
  });
264
265
  });
265
266
 
267
+ // The record is written at settlement, so a Bot whose Turns settled before
268
+ // that projection existed has a full transcript and no preview — and its
269
+ // sidebar row said "No messages yet" over six messages. A read derives it.
270
+ describe("the sidebar preview derived from stored runs", () => {
271
+ const run = (over: Record<string, unknown> = {}) => ({
272
+ acceptedAt: "2026-08-31T00:02:00.000Z",
273
+ input: "What is the plan?",
274
+ responseText: "Here is the plan.",
275
+ status: "completed",
276
+ events: [{ timestamp: "2026-08-31T00:02:05.000Z" }],
277
+ ...over,
278
+ });
279
+
280
+ test("takes the newest settled chat Turn's reply, stamped when it settled", () => {
281
+ expect(sidebarMessagePreviewFromRunsV1([run()])).toEqual({
282
+ schemaVersion: 1,
283
+ text: "Here is the plan.",
284
+ at: "2026-08-31T00:02:05.000Z",
285
+ role: "assistant",
286
+ });
287
+ });
288
+
289
+ test("walks past a running Turn and an automation to the newest chat reply", () => {
290
+ expect(
291
+ sidebarMessagePreviewFromRunsV1([
292
+ run({ status: "running", responseText: undefined }),
293
+ run({
294
+ admission: { turnType: "automation" },
295
+ responseText: "Routine ran.",
296
+ }),
297
+ run({ responseText: "The older answer." }),
298
+ ]),
299
+ ).toMatchObject({ text: "The older answer.", role: "assistant" });
300
+ });
301
+
302
+ test("falls back to the User's own words when the reply was empty", () => {
303
+ expect(
304
+ sidebarMessagePreviewFromRunsV1([run({ responseText: "" })]),
305
+ ).toEqual({
306
+ schemaVersion: 1,
307
+ text: "What is the plan?",
308
+ at: "2026-08-31T00:02:00.000Z",
309
+ role: "user",
310
+ });
311
+ });
312
+
313
+ test("a Bot with no settled chat Turn still has no preview", () => {
314
+ expect(sidebarMessagePreviewFromRunsV1([])).toBeUndefined();
315
+ expect(
316
+ sidebarMessagePreviewFromRunsV1([
317
+ run({ status: "running", responseText: undefined, input: "" }),
318
+ ]),
319
+ ).toBeUndefined();
320
+ });
321
+
322
+ // A read of durable data never throws: a run whose stamps are unreadable
323
+ // costs the row, not the whole sidebar.
324
+ test("skips a run whose stored timestamps cannot be read", () => {
325
+ expect(
326
+ sidebarMessagePreviewFromRunsV1([
327
+ run({ acceptedAt: "not a time", events: [] }),
328
+ run({ responseText: "The readable one." }),
329
+ ]),
330
+ ).toMatchObject({ text: "The readable one." });
331
+ });
332
+ });
333
+
266
334
  describe("the unread command", () => {
267
335
  test("decodes each type and refuses a mismatched cursor", () => {
268
336
  expect(
package/src/unread.ts CHANGED
@@ -260,6 +260,59 @@ export function sidebarMessagePreviewForTurnV1(
260
260
  });
261
261
  }
262
262
 
263
+ /** The little a settled run has to expose for a preview to be derived from it. */
264
+ export interface SidebarPreviewRunV1 {
265
+ acceptedAt: string;
266
+ input: string;
267
+ responseText?: string;
268
+ status: string;
269
+ /** Only the timestamps are read: the newest one is when the Turn settled. */
270
+ events?: readonly { timestamp?: string }[];
271
+ /** Absent means the Turn was admitted as `chat`, as everywhere else. */
272
+ admission?: { turnType?: string };
273
+ }
274
+
275
+ /**
276
+ * The preview for a Bot that has a transcript but no preview record.
277
+ *
278
+ * The record is written at settlement, so every Turn that settled before that
279
+ * projection existed left one behind — and the row then claimed "No messages
280
+ * yet" over a full conversation. A read cannot write the record it is missing,
281
+ * so it derives the same line from the runs that are already durable. Runs
282
+ * arrive newest-first and the walk stops at the first settled chat Turn with
283
+ * text, which is exactly the line the settlement would have stored.
284
+ */
285
+ export function sidebarMessagePreviewFromRunsV1(
286
+ runs: readonly SidebarPreviewRunV1[],
287
+ ): SidebarMessagePreviewV1 | undefined {
288
+ for (const run of runs) {
289
+ if (run.status === "running") continue;
290
+ // An automation Turn reaches the User through its own inbox entry and
291
+ // never became this row at settlement either.
292
+ if ((run.admission?.turnType ?? "chat") !== "chat") continue;
293
+ const settledAt = settlementTimestampV1(run);
294
+ try {
295
+ const preview = sidebarMessagePreviewForTurnV1(run, settledAt);
296
+ if (preview) return preview;
297
+ } catch {
298
+ // A run whose stored timestamps cannot be read is not worth the row —
299
+ // and a read of durable data never throws over one bad record.
300
+ continue;
301
+ }
302
+ }
303
+ return undefined;
304
+ }
305
+
306
+ /** When a stored run settled: its newest event's stamp, else its admission. */
307
+ function settlementTimestampV1(run: SidebarPreviewRunV1): string {
308
+ for (let index = (run.events?.length ?? 0) - 1; index >= 0; index -= 1) {
309
+ const timestamp = run.events?.[index]?.timestamp;
310
+ if (typeof timestamp === "string" && Number.isFinite(Date.parse(timestamp)))
311
+ return timestamp;
312
+ }
313
+ return run.acceptedAt;
314
+ }
315
+
263
316
  /**
264
317
  * Records a settled chat Turn. Monotonic: a cursor that is not newer than the
265
318
  * one already recorded leaves the record byte-for-byte unchanged, so a