@frockbot/plugin-shell 0.3.6 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +31 -31
- package/src/backend-recovery-integration.test.ts +12 -18
- package/src/backend.ts +84 -11
- package/src/client/FrockBotApp.vue +66 -1
- package/src/client/index.test.ts +132 -0
- package/src/client/index.ts +151 -21
- package/src/client/styles.css +34 -0
- package/src/client/turn-limits.test.ts +41 -0
- package/src/client/turn-limits.ts +40 -0
- package/src/client/uncertain-admission.test.ts +69 -0
- package/src/client/uncertain-admission.ts +78 -0
- package/src/connection-return.test.ts +55 -0
- package/src/debug-protocol.test.ts +38 -2
- package/src/debug-protocol.ts +39 -6
- package/src/history.test.ts +52 -0
- package/src/history.ts +79 -1
- package/src/run-protocol.test.ts +49 -3
- package/src/run-protocol.ts +134 -17
- package/src/shared.ts +72 -0
- package/src/unread.test.ts +68 -0
- package/src/unread.ts +53 -0
package/src/client/index.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
1085
|
-
return;
|
|
1115
|
+
if (!current()) return;
|
|
1086
1116
|
} catch (error) {
|
|
1087
|
-
if (
|
|
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 (
|
|
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 (
|
|
1103
|
-
return;
|
|
1130
|
+
if (!current()) return;
|
|
1104
1131
|
if (!ctx.transport.acknowledgeNotification) return;
|
|
1105
1132
|
for (const notification of notifications) {
|
|
1106
|
-
if (
|
|
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:
|
|
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:
|
|
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:
|
|
2639
|
+
at: placeholderAt,
|
|
2510
2640
|
status: "error",
|
|
2511
2641
|
tools: [],
|
|
2512
2642
|
sends: [],
|
package/src/client/styles.css
CHANGED
|
@@ -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
|
+
});
|