@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.
@@ -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,
@@ -70,6 +76,8 @@ import { defineComponent, h, ref, toRaw, watch, type Ref } from "vue";
70
76
  import {
71
77
  frockBotWebDataKey,
72
78
  type FrockBotWebData,
79
+ decodeConnectionReturnV1,
80
+ withoutConnectionReturnV1,
73
81
  type PluginCatalogItem,
74
82
  type SendPromptResult,
75
83
  type WebActiveRun,
@@ -862,6 +870,15 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
862
870
  let admissionObserver: AbortController | undefined;
863
871
  let runObserver: AbortController | undefined;
864
872
  let selectionGeneration = 0;
873
+ /*
874
+ * Which conversation the transcript is showing.
875
+ *
876
+ * A read that was already in flight when the User starts a new conversation
877
+ * answers with the conversation that just ended, and projecting it puts the
878
+ * old Turns back on a transcript the User has just been told is empty. The
879
+ * epoch is bumped at the boundary so those answers are dropped.
880
+ */
881
+ let conversationGeneration = 0;
865
882
  let userSettingsGeneration = 0;
866
883
  let pluginCatalogGeneration = 0;
867
884
  let packageCatalogGeneration = 0;
@@ -965,7 +982,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
965
982
  botId: string,
966
983
  runId: string,
967
984
  signal: AbortSignal,
968
- ): Promise<"admitted" | "not-admitted" | "detached"> {
985
+ ): Promise<"admitted" | "not-admitted" | "detached" | "unreachable"> {
969
986
  web.value.activeRun = {
970
987
  runId,
971
988
  status: "running",
@@ -975,7 +992,6 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
975
992
  if (!ctx.transport.lookupRun || !ctx.transport.fenceRunAdmission) {
976
993
  return "detached";
977
994
  }
978
- let delayMs = 250;
979
995
  let reconciliationError: string | undefined;
980
996
  const clearReconciliationError = () => {
981
997
  if (web.value.settingsError === reconciliationError) {
@@ -983,7 +999,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
983
999
  }
984
1000
  reconciliationError = undefined;
985
1001
  };
986
- while (!signal.aborted) {
1002
+ for (let attempt = 1; !signal.aborted; attempt += 1) {
987
1003
  try {
988
1004
  const observed = await observeWhileAttached(
989
1005
  ctx.transport.lookupRun(botId, runId),
@@ -1013,8 +1029,16 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1013
1029
  } Retrying…`;
1014
1030
  web.value.settingsError = reconciliationError;
1015
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
+ }
1016
1041
  await waitForRunLookup(delayMs, signal);
1017
- delayMs = Math.min(delayMs * 2, 5_000);
1018
1042
  }
1019
1043
  return "detached";
1020
1044
  }
@@ -1028,6 +1052,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1028
1052
  if (!ctx.transport.lookupRun) return;
1029
1053
  let delayMs = 250;
1030
1054
  let observationError: string | undefined;
1055
+ const conversation = conversationGeneration;
1031
1056
  while (!signal.aborted) {
1032
1057
  try {
1033
1058
  const run = await observeWhileAttached(
@@ -1037,6 +1062,9 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1037
1062
  if (
1038
1063
  signal.aborted ||
1039
1064
  generation !== selectionGeneration ||
1065
+ // The Turn belongs to the conversation it was sent in, so a new one
1066
+ // ends the observation rather than drawing it on an empty thread.
1067
+ conversation !== conversationGeneration ||
1040
1068
  web.value.activeBotId !== botId
1041
1069
  ) {
1042
1070
  return;
@@ -1064,15 +1092,18 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1064
1092
  botId: string,
1065
1093
  generation = selectionGeneration,
1066
1094
  ): Promise<void> {
1095
+ const conversation = conversationGeneration;
1096
+ const current = () =>
1097
+ generation === selectionGeneration &&
1098
+ conversation === conversationGeneration &&
1099
+ web.value.activeBotId === botId;
1067
1100
  const runs = await (ctx.transport.listRuns?.(botId) ?? Promise.resolve([]));
1068
- if (generation !== selectionGeneration || web.value.activeBotId !== botId)
1069
- return;
1101
+ if (!current()) return;
1070
1102
  projectDurableRuns(web.value, [], runs);
1071
1103
  try {
1072
1104
  const announcements = await (ctx.transport.listAnnouncements?.(botId) ??
1073
1105
  Promise.resolve([]));
1074
- if (generation === selectionGeneration && web.value.activeBotId === botId)
1075
- projectAnnouncements(web.value.messages, announcements);
1106
+ if (current()) projectAnnouncements(web.value.messages, announcements);
1076
1107
  } catch {
1077
1108
  // Announcements are conversational history, never admission: a Session
1078
1109
  // that cannot read them still shows every Turn.
@@ -1081,17 +1112,14 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1081
1112
  try {
1082
1113
  notifications = await (ctx.transport.listNotifications?.(botId) ??
1083
1114
  Promise.resolve([]));
1084
- if (generation !== selectionGeneration || web.value.activeBotId !== botId)
1085
- return;
1115
+ if (!current()) return;
1086
1116
  } catch (error) {
1087
- if (generation !== selectionGeneration || web.value.activeBotId !== botId)
1088
- return;
1117
+ if (!current()) return;
1089
1118
  web.value.settingsError =
1090
1119
  error instanceof Error ? error.message : "Could not load notifications";
1091
1120
  return;
1092
1121
  }
1093
- if (generation !== selectionGeneration || web.value.activeBotId !== botId)
1094
- return;
1122
+ if (!current()) return;
1095
1123
  const projected = projectDurableRuns(web.value, notifications, runs);
1096
1124
  // A decision may have been recorded on another device since the last poll,
1097
1125
  // and an expiry is recorded by an alarm nobody clicked.
@@ -1099,12 +1127,10 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1099
1127
  // A background subagent settles after its Turn is over, so the chips in
1100
1128
  // the transcript learn what became of it here and not from the run.
1101
1129
  await web.value.loadTasks();
1102
- if (generation !== selectionGeneration || web.value.activeBotId !== botId)
1103
- return;
1130
+ if (!current()) return;
1104
1131
  if (!ctx.transport.acknowledgeNotification) return;
1105
1132
  for (const notification of notifications) {
1106
- if (generation !== selectionGeneration || web.value.activeBotId !== botId)
1107
- return;
1133
+ if (!current()) return;
1108
1134
  if (!projected.has(notification.notificationId)) {
1109
1135
  web.value.settingsError = "A completed Bot result is waiting to load";
1110
1136
  continue;
@@ -1237,8 +1263,24 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1237
1263
  ): Promise<void>;
1238
1264
  };
1239
1265
 
1266
+ // Read once, from the URL the authorization redirect landed on, and then
1267
+ // stripped so a reload does not report the same return again.
1268
+ const connectionReturn =
1269
+ typeof window === "undefined"
1270
+ ? undefined
1271
+ : decodeConnectionReturnV1(window.location.search);
1272
+ if (connectionReturn && typeof window !== "undefined") {
1273
+ const rest = withoutConnectionReturnV1(window.location.search);
1274
+ window.history?.replaceState?.(
1275
+ window.history.state,
1276
+ "",
1277
+ `${window.location.pathname}${rest}${window.location.hash}`,
1278
+ );
1279
+ }
1280
+
1240
1281
  const web: Ref<ShellWebData> = ref({
1241
1282
  connection: "ready",
1283
+ ...(connectionReturn ? { connectionReturn } : {}),
1242
1284
  modelLabel: "No model available — set one up in Models",
1243
1285
  modelReady: false,
1244
1286
  modelSource: "none",
@@ -1304,6 +1346,41 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1304
1346
  }
1305
1347
  await web.value.loadBotSettings();
1306
1348
  },
1349
+ /**
1350
+ * Puts this conversation down and starts the next one.
1351
+ *
1352
+ * What the Bot knows about you is Memory and stays; what it carries into
1353
+ * the next model request is the new conversation and nothing else. The
1354
+ * transcript clears because it is showing the conversation, and the one
1355
+ * just ended is still durable behind it.
1356
+ */
1357
+ async startConversation(): Promise<void> {
1358
+ const start = ctx.transport.startConversation;
1359
+ const botId = web.value.activeBotId;
1360
+ if (!start || !botId) return;
1361
+ const generation = selectionGeneration;
1362
+ try {
1363
+ await start(botId);
1364
+ } catch (error) {
1365
+ web.value.settingsError =
1366
+ error instanceof Error
1367
+ ? error.message
1368
+ : "Could not start a new conversation";
1369
+ return;
1370
+ }
1371
+ if (generation !== selectionGeneration || web.value.activeBotId !== botId)
1372
+ return;
1373
+ // Reads already in flight answer with the conversation that just ended;
1374
+ // the epoch drops them instead of letting them redraw it.
1375
+ conversationGeneration += 1;
1376
+ runObserver?.abort();
1377
+ runObserver = undefined;
1378
+ web.value.messages = [];
1379
+ web.value.activeRun = undefined;
1380
+ web.value.activeRunId = undefined;
1381
+ web.value.runningRunId = undefined;
1382
+ web.value.settingsError = undefined;
1383
+ },
1307
1384
  async loadSkillCatalog(): Promise<void> {
1308
1385
  // A missing transport method or an unreadable catalog is an empty
1309
1386
  // popover, never a visible error: a Skill list the User did not ask for
@@ -2470,14 +2547,39 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2470
2547
  web.value.error = refusal;
2471
2548
  return { accepted: false, error: refusal };
2472
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
+ }
2473
2565
  const aborted =
2474
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
+ );
2475
2577
  replaceMessage(web.value.messages, pendingRunId, {
2476
2578
  id: `${pendingRunId}:assistant`,
2477
2579
  runId: pendingRunId,
2478
2580
  role: "assistant",
2479
2581
  text: "Checking whether your message went through…",
2480
- at: optimisticAt,
2582
+ at: placeholderAt,
2481
2583
  status: "interrupted",
2482
2584
  tools: [],
2483
2585
  sends: [],
@@ -2490,7 +2592,8 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2490
2592
  }
2491
2593
  const observer = new AbortController();
2492
2594
  admissionObserver = observer;
2493
- let disposition: "admitted" | "not-admitted" | "detached";
2595
+ let disposition:
2596
+ "admitted" | "not-admitted" | "detached" | "unreachable";
2494
2597
  try {
2495
2598
  disposition = await reconcileUncertainAdmission(
2496
2599
  botId,
@@ -2500,13 +2603,40 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2500
2603
  } finally {
2501
2604
  if (admissionObserver === observer) admissionObserver = undefined;
2502
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
+ }
2503
2633
  if (disposition === "not-admitted") {
2504
2634
  replaceMessage(web.value.messages, pendingRunId, {
2505
2635
  id: `${pendingRunId}:assistant`,
2506
2636
  runId: pendingRunId,
2507
2637
  role: "assistant",
2508
2638
  text: "Your message didn't go through. Try sending it again.",
2509
- at: optimisticAt,
2639
+ at: placeholderAt,
2510
2640
  status: "error",
2511
2641
  tools: [],
2512
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
+ }
@@ -0,0 +1,55 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ decodeConnectionReturnV1,
4
+ withoutConnectionReturnV1,
5
+ } from "./shared.js";
6
+
7
+ describe("authorization return parameter", () => {
8
+ test("reads the status the callback redirected with", () => {
9
+ expect(decodeConnectionReturnV1("?connection=composio-ready")).toEqual({
10
+ packageId: "composio",
11
+ status: "ready",
12
+ });
13
+ expect(decodeConnectionReturnV1("?connection=composio-pending")).toEqual({
14
+ packageId: "composio",
15
+ status: "pending",
16
+ });
17
+ });
18
+
19
+ test("carries the reason a failed grant came back with", () => {
20
+ expect(
21
+ decodeConnectionReturnV1(
22
+ "?connection=composio-failed&connection_reason=state%20has%20expired",
23
+ ),
24
+ ).toEqual({
25
+ packageId: "composio",
26
+ status: "failed",
27
+ reason: "state has expired",
28
+ });
29
+ });
30
+
31
+ test("ignores a query string that carries no return", () => {
32
+ expect(decodeConnectionReturnV1("")).toBeUndefined();
33
+ expect(decodeConnectionReturnV1("?as_user=someone")).toBeUndefined();
34
+ });
35
+
36
+ test("refuses a malformed or unknown return", () => {
37
+ expect(decodeConnectionReturnV1("?connection=composio")).toBeUndefined();
38
+ expect(
39
+ decodeConnectionReturnV1("?connection=composio-elsewhere"),
40
+ ).toBeUndefined();
41
+ expect(decodeConnectionReturnV1("?connection=-ready")).toBeUndefined();
42
+ expect(
43
+ decodeConnectionReturnV1("?connection=Not%20A%20Package-ready"),
44
+ ).toBeUndefined();
45
+ });
46
+
47
+ test("strips the return parameters and keeps the rest", () => {
48
+ expect(
49
+ withoutConnectionReturnV1(
50
+ "?as_user=someone&connection=composio-failed&connection_reason=nope",
51
+ ),
52
+ ).toBe("?as_user=someone");
53
+ expect(withoutConnectionReturnV1("?connection=composio-ready")).toBe("");
54
+ });
55
+ });