@frockbot/plugin-shell 0.3.7 → 0.3.9

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.
@@ -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(
@@ -1627,6 +1631,79 @@ describe("active durable Turn projection", () => {
1627
1631
  });
1628
1632
  });
1629
1633
 
1634
+ test("a running Turn's partial text fills the bubble it will settle into", () => {
1635
+ const state: Pick<
1636
+ FrockBotWebData,
1637
+ "messages" | "activeRunId" | "activeRun"
1638
+ > = { messages: [] };
1639
+
1640
+ projectDurableRuns(
1641
+ state,
1642
+ [],
1643
+ [
1644
+ {
1645
+ runId: "run-9",
1646
+ input: "Explain",
1647
+ events: [],
1648
+ status: "running",
1649
+ partialText: "Because it",
1650
+ },
1651
+ ],
1652
+ );
1653
+ expect(state.messages[1]).toMatchObject({
1654
+ text: "Because it",
1655
+ status: "streaming",
1656
+ });
1657
+
1658
+ // One bubble: the settled answer replaces the partial one in place.
1659
+ projectDurableRuns(
1660
+ state,
1661
+ [],
1662
+ [
1663
+ {
1664
+ runId: "run-9",
1665
+ input: "Explain",
1666
+ events: [],
1667
+ status: "completed",
1668
+ responseText: "Because it is.",
1669
+ },
1670
+ ],
1671
+ );
1672
+ expect(state.messages).toHaveLength(2);
1673
+ expect(state.messages[1]).toMatchObject({
1674
+ text: "Because it is.",
1675
+ status: "completed",
1676
+ });
1677
+ });
1678
+
1679
+ test("a Turn that has already delivered a send streams nothing beside it", () => {
1680
+ const state: Pick<
1681
+ FrockBotWebData,
1682
+ "messages" | "activeRunId" | "activeRun"
1683
+ > = { messages: [] };
1684
+
1685
+ projectDurableRuns(
1686
+ state,
1687
+ [],
1688
+ [
1689
+ {
1690
+ runId: "run-10",
1691
+ input: "Explain",
1692
+ events: [
1693
+ {
1694
+ type: "send/to-user",
1695
+ payload: { type: "text", text: "Here you go." },
1696
+ },
1697
+ ],
1698
+ status: "running",
1699
+ partialText: "private scratch space",
1700
+ },
1701
+ ],
1702
+ );
1703
+ expect(state.messages[1]).toMatchObject({ text: "", status: "streaming" });
1704
+ expect(state.messages[1]?.sends).toHaveLength(1);
1705
+ });
1706
+
1630
1707
  test("projects reconciliation-required recovery state", () => {
1631
1708
  const reconciliation: Pick<
1632
1709
  FrockBotWebData,
@@ -2152,6 +2229,134 @@ describe("uncertain Turn admission", () => {
2152
2229
  });
2153
2230
  });
2154
2231
 
2232
+ test("refuses a send the server answered 4xx, keeping the draft and the thread clean", async () => {
2233
+ Object.defineProperty(globalThis, "window", {
2234
+ configurable: true,
2235
+ value: { location: { href: "https://app.example/?bot=primary" } },
2236
+ });
2237
+ let provided: Ref<FrockBotWebData> | undefined;
2238
+ let lookups = 0;
2239
+ const tooLong =
2240
+ "Your message is too long. Keep it under 32,000 characters.";
2241
+ await shellClientPlugin({
2242
+ transport: {
2243
+ // What the transport throws for a 413: the answer was read, so the
2244
+ // status rides on the error beside the sentence the person should see.
2245
+ turn: () =>
2246
+ Promise.reject(Object.assign(new Error(tooLong), { status: 413 })),
2247
+ lookupRun: () => {
2248
+ lookups += 1;
2249
+ return Promise.resolve(undefined);
2250
+ },
2251
+ fenceRunAdmission: () => Promise.resolve(undefined),
2252
+ },
2253
+ slot: () => () => {},
2254
+ inject: () => {
2255
+ throw new Error("unexpected client provider injection");
2256
+ },
2257
+ provide: (_key, value) => {
2258
+ provided = value as Ref<FrockBotWebData>;
2259
+ return () => {};
2260
+ },
2261
+ });
2262
+ if (!provided) throw new Error("shell data was not provided");
2263
+ provided.value.activeBotId = "primary";
2264
+ provided.value.composerContext = "primary";
2265
+
2266
+ const result = await provided.value.sendPrompt("x".repeat(120_000));
2267
+
2268
+ // A refusal, so the composer keeps the draft — and the thread shows
2269
+ // neither the message that was never sent nor a placeholder about it.
2270
+ expect(result).toEqual({ accepted: false, error: tooLong });
2271
+ expect(provided.value.error).toBe(tooLong);
2272
+ expect(provided.value.messages).toEqual([]);
2273
+ expect(provided.value.activeRun).toBeUndefined();
2274
+ expect(provided.value.activeRunId).toBeUndefined();
2275
+ expect(provided.value.runningRunId).toBeUndefined();
2276
+ // Nothing to reconcile: the Turn was refused, not lost.
2277
+ expect(lookups).toBe(0);
2278
+ });
2279
+
2280
+ test("settles an unreachable backend after the retry bound, under the message it reports on", async () => {
2281
+ Object.defineProperty(globalThis, "window", {
2282
+ configurable: true,
2283
+ value: { location: { href: "https://app.example/?bot=primary" } },
2284
+ });
2285
+ // The bound is spent by waiting, and this test is about how the wait ends
2286
+ // rather than about how long each one is.
2287
+ const originalSetTimeout = globalThis.setTimeout;
2288
+ Object.defineProperty(globalThis, "setTimeout", {
2289
+ configurable: true,
2290
+ writable: true,
2291
+ value: ((callback: () => void) => {
2292
+ queueMicrotask(callback);
2293
+ return 0 as unknown as ReturnType<typeof originalSetTimeout>;
2294
+ }) as unknown as typeof originalSetTimeout,
2295
+ });
2296
+ let provided: Ref<FrockBotWebData> | undefined;
2297
+ let lookups = 0;
2298
+ try {
2299
+ await shellClientPlugin({
2300
+ transport: {
2301
+ // No status: the answer never arrived, so admission is genuinely
2302
+ // unknown and reconciliation is right to start.
2303
+ turn: () => Promise.reject(new TypeError("Failed to fetch")),
2304
+ lookupRun: () => {
2305
+ lookups += 1;
2306
+ return Promise.reject(new TypeError("Failed to fetch"));
2307
+ },
2308
+ fenceRunAdmission: () =>
2309
+ Promise.reject(new TypeError("Failed to fetch")),
2310
+ },
2311
+ slot: () => () => {},
2312
+ inject: () => {
2313
+ throw new Error("unexpected client provider injection");
2314
+ },
2315
+ provide: (_key, value) => {
2316
+ provided = value as Ref<FrockBotWebData>;
2317
+ return () => {};
2318
+ },
2319
+ });
2320
+ if (!provided) throw new Error("shell data was not provided");
2321
+ provided.value.activeBotId = "primary";
2322
+ provided.value.composerContext = "primary";
2323
+
2324
+ const result = await provided.value.sendPrompt("are you there");
2325
+
2326
+ expect(lookups).toBe(UNCERTAIN_ADMISSION_MAX_ATTEMPTS_V1);
2327
+ expect(result).toEqual({
2328
+ accepted: false,
2329
+ error: UNREACHABLE_BOT_MESSAGE_V1,
2330
+ });
2331
+ const [user, placeholder] = provided.value.messages;
2332
+ expect(user).toMatchObject({ role: "user", text: "are you there" });
2333
+ expect(placeholder).toMatchObject({
2334
+ role: "assistant",
2335
+ text: UNREACHABLE_BOT_MESSAGE_V1,
2336
+ status: "error",
2337
+ retry: "resend",
2338
+ });
2339
+ // Strictly after the message it reports on, so the thread's ordering by
2340
+ // time cannot lift it above that message.
2341
+ expect((placeholder?.at ?? "") > (user?.at ?? "")).toBe(true);
2342
+ // Nothing is running any more, so no Stop stands for it, and the banner
2343
+ // stops saying the client is still checking. It does not repeat the
2344
+ // bubble's sentence either: the bubble is the report, and it is the one
2345
+ // carrying the Retry.
2346
+ expect(provided.value.error).toBeUndefined();
2347
+ expect(provided.value.settingsError).toBeUndefined();
2348
+ expect(provided.value.activeRun).toBeUndefined();
2349
+ expect(provided.value.activeRunId).toBeUndefined();
2350
+ expect(provided.value.runningRunId).toBeUndefined();
2351
+ } finally {
2352
+ Object.defineProperty(globalThis, "setTimeout", {
2353
+ configurable: true,
2354
+ writable: true,
2355
+ value: originalSetTimeout,
2356
+ });
2357
+ }
2358
+ });
2359
+
2155
2360
  test("detaches a rejected Turn without starting a stale observer after Bot switch", async () => {
2156
2361
  Object.defineProperty(globalThis, "window", {
2157
2362
  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,
@@ -277,10 +283,16 @@ function turnRefusalCopyV1(reason: ClientTurnRefusalReasonV1): string {
277
283
  * model's own assistant text is scratch space and the thread does not draw it
278
284
  * (issue 153): drawing both is how a one-word reply arrived twice, once as the
279
285
  * model's text and once as the bubble that was actually delivered.
286
+ *
287
+ * A running Turn has no `responseText` yet — that is written only at
288
+ * settlement — so it draws the words it has written so far. They occupy the
289
+ * same bubble the settled answer will, and the same send gate applies to
290
+ * both: a Turn that has already delivered a bubble streams nothing into a
291
+ * second one.
280
292
  */
281
293
  function visibleAssistantText(run: ClientRun, fallback = ""): string {
282
294
  if (sendsFrom(run.events).length > 0) return "";
283
- return run.responseText ?? fallback;
295
+ return run.responseText ?? run.partialText ?? fallback;
284
296
  }
285
297
 
286
298
  function isTerminalRun(run: ClientRun): boolean {
@@ -976,7 +988,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
976
988
  botId: string,
977
989
  runId: string,
978
990
  signal: AbortSignal,
979
- ): Promise<"admitted" | "not-admitted" | "detached"> {
991
+ ): Promise<"admitted" | "not-admitted" | "detached" | "unreachable"> {
980
992
  web.value.activeRun = {
981
993
  runId,
982
994
  status: "running",
@@ -986,7 +998,6 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
986
998
  if (!ctx.transport.lookupRun || !ctx.transport.fenceRunAdmission) {
987
999
  return "detached";
988
1000
  }
989
- let delayMs = 250;
990
1001
  let reconciliationError: string | undefined;
991
1002
  const clearReconciliationError = () => {
992
1003
  if (web.value.settingsError === reconciliationError) {
@@ -994,7 +1005,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
994
1005
  }
995
1006
  reconciliationError = undefined;
996
1007
  };
997
- while (!signal.aborted) {
1008
+ for (let attempt = 1; !signal.aborted; attempt += 1) {
998
1009
  try {
999
1010
  const observed = await observeWhileAttached(
1000
1011
  ctx.transport.lookupRun(botId, runId),
@@ -1024,8 +1035,16 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1024
1035
  } Retrying…`;
1025
1036
  web.value.settingsError = reconciliationError;
1026
1037
  }
1038
+ const delayMs = uncertainAdmissionDelayMsV1(attempt);
1039
+ // The bound is spent. Asking again would only keep a placeholder
1040
+ // spinning over a backend this tab cannot reach, so the caller settles
1041
+ // the Turn and says so in the thread.
1042
+ if (delayMs === undefined) {
1043
+ clearReconciliationError();
1044
+ web.value.activeRun = undefined;
1045
+ return "unreachable";
1046
+ }
1027
1047
  await waitForRunLookup(delayMs, signal);
1028
- delayMs = Math.min(delayMs * 2, 5_000);
1029
1048
  }
1030
1049
  return "detached";
1031
1050
  }
@@ -2534,14 +2553,39 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2534
2553
  web.value.error = refusal;
2535
2554
  return { accepted: false, error: refusal };
2536
2555
  }
2556
+ // Every other 4xx is a refusal too, and the answer already says why —
2557
+ // a message over the size limit is answered 413 with the sentence the
2558
+ // person needs. Only 5xx and a lost connection leave admission in
2559
+ // doubt, so anything else here is settled: no optimistic bubbles, no
2560
+ // "checking" placeholder, no reconciliation, and the composer gets the
2561
+ // draft back rather than the thread pretending it was sent.
2562
+ if (isCertainSendRefusalV1(error)) {
2563
+ removeMessages(web.value.messages, pendingRunId);
2564
+ const refusal =
2565
+ error instanceof Error && error.message
2566
+ ? error.message
2567
+ : "That message didn't go through. Try sending it again.";
2568
+ web.value.error = refusal;
2569
+ return { accepted: false, error: refusal };
2570
+ }
2537
2571
  const aborted =
2538
2572
  error instanceof DOMException && error.name === "AbortError";
2573
+ // Strictly after the message it reports on. The thread orders by time,
2574
+ // and the durable projection gives the user's line the run's later
2575
+ // `admittedAt`, so a placeholder carrying the moment the send began
2576
+ // sorted above the message it belongs to.
2577
+ const placeholderAt = momentAfterV1(
2578
+ web.value.messages.find(
2579
+ (message) =>
2580
+ message.runId === pendingRunId && message.role === "user",
2581
+ )?.at ?? optimisticAt,
2582
+ );
2539
2583
  replaceMessage(web.value.messages, pendingRunId, {
2540
2584
  id: `${pendingRunId}:assistant`,
2541
2585
  runId: pendingRunId,
2542
2586
  role: "assistant",
2543
2587
  text: "Checking whether your message went through…",
2544
- at: optimisticAt,
2588
+ at: placeholderAt,
2545
2589
  status: "interrupted",
2546
2590
  tools: [],
2547
2591
  sends: [],
@@ -2554,7 +2598,8 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2554
2598
  }
2555
2599
  const observer = new AbortController();
2556
2600
  admissionObserver = observer;
2557
- let disposition: "admitted" | "not-admitted" | "detached";
2601
+ let disposition:
2602
+ "admitted" | "not-admitted" | "detached" | "unreachable";
2558
2603
  try {
2559
2604
  disposition = await reconcileUncertainAdmission(
2560
2605
  botId,
@@ -2564,13 +2609,40 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2564
2609
  } finally {
2565
2610
  if (admissionObserver === observer) admissionObserver = undefined;
2566
2611
  }
2612
+ // The reconciliation ran out of attempts: this tab cannot reach the
2613
+ // backend at all. That is the app's own failure and it says so, in
2614
+ // place of the placeholder, with the Retry the person would otherwise
2615
+ // have to improvise — and with the Turn no longer running, so Stop
2616
+ // stops standing for a Turn nobody is executing.
2617
+ if (disposition === "unreachable") {
2618
+ replaceMessage(web.value.messages, pendingRunId, {
2619
+ id: `${pendingRunId}:assistant`,
2620
+ runId: pendingRunId,
2621
+ role: "assistant",
2622
+ text: UNREACHABLE_BOT_MESSAGE_V1,
2623
+ at: placeholderAt,
2624
+ status: "error",
2625
+ retry: "resend",
2626
+ tools: [],
2627
+ sends: [],
2628
+ });
2629
+ // The bubble is the report, and it is the one carrying the Retry.
2630
+ // Saying the same sentence again in the banner above it is what the
2631
+ // thread already looked like when it was broken — the same string
2632
+ // three times over — so the banner is cleared rather than set.
2633
+ web.value.error = undefined;
2634
+ web.value.activeRun = undefined;
2635
+ web.value.activeRunId = undefined;
2636
+ web.value.runningRunId = undefined;
2637
+ return { accepted: false, error: UNREACHABLE_BOT_MESSAGE_V1 };
2638
+ }
2567
2639
  if (disposition === "not-admitted") {
2568
2640
  replaceMessage(web.value.messages, pendingRunId, {
2569
2641
  id: `${pendingRunId}:assistant`,
2570
2642
  runId: pendingRunId,
2571
2643
  role: "assistant",
2572
2644
  text: "Your message didn't go through. Try sending it again.",
2573
- at: optimisticAt,
2645
+ at: placeholderAt,
2574
2646
  status: "error",
2575
2647
  tools: [],
2576
2648
  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
+ }