@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.
- package/package.json +31 -31
- package/src/backend-routines.ts +4 -0
- package/src/backend-subagent-blocking.test.ts +222 -0
- package/src/backend.ts +110 -19
- package/src/client/FrockBotApp.vue +53 -1
- package/src/client/index.test.ts +205 -0
- package/src/client/index.ts +80 -8
- 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/debug-protocol.test.ts +38 -2
- package/src/debug-protocol.ts +39 -6
- package/src/run-protocol.test.ts +130 -3
- package/src/run-protocol.ts +86 -18
- package/src/shared.ts +7 -0
- package/src/unread.test.ts +95 -0
- package/src/unread.ts +62 -1
package/src/client/index.test.ts
CHANGED
|
@@ -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,
|
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,
|
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
2645
|
+
at: placeholderAt,
|
|
2574
2646
|
status: "error",
|
|
2575
2647
|
tools: [],
|
|
2576
2648
|
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
|
+
}
|