@frockbot/plugin-shell 0.3.7 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +31 -31
- package/src/backend.ts +41 -9
- package/src/client/FrockBotApp.vue +53 -1
- package/src/client/index.test.ts +132 -0
- package/src/client/index.ts +73 -7
- 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 +49 -3
- package/src/run-protocol.ts +37 -15
- package/src/shared.ts +7 -0
- package/src/unread.test.ts +68 -0
- package/src/unread.ts +53 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-shell",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -47,41 +47,41 @@
|
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@cordisjs/plugin-webui": "0.8.2",
|
|
50
|
-
"@frockbot/agent-runtime": "0.3.
|
|
51
|
-
"@frockbot/application-foundation": "0.3.
|
|
52
|
-
"@frockbot/catalog-core": "0.3.
|
|
53
|
-
"@frockbot/client-core": "0.3.
|
|
54
|
-
"@frockbot/client-ui": "0.3.
|
|
55
|
-
"@frockbot/computer-core": "0.3.
|
|
56
|
-
"@frockbot/computer-host-protocol": "0.3.
|
|
57
|
-
"@frockbot/configuration-core": "0.3.
|
|
58
|
-
"@frockbot/connection-core": "0.3.
|
|
59
|
-
"@frockbot/kernel-agent-loop": "0.3.
|
|
60
|
-
"@frockbot/kernel-composition": "0.3.
|
|
61
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
62
|
-
"@frockbot/kernel-do": "0.3.
|
|
63
|
-
"@frockbot/machine-protocol": "0.3.
|
|
64
|
-
"@frockbot/plugin-applets": "0.3.
|
|
65
|
-
"@frockbot/plugin-authoring": "0.3.
|
|
66
|
-
"@frockbot/plugin-bot-template": "0.3.
|
|
67
|
-
"@frockbot/plugin-computer": "0.3.
|
|
68
|
-
"@frockbot/plugin-flock": "0.3.
|
|
69
|
-
"@frockbot/plugin-image": "0.3.
|
|
70
|
-
"@frockbot/plugin-machine-messages": "0.3.
|
|
71
|
-
"@frockbot/plugin-mcp": "0.3.
|
|
72
|
-
"@frockbot/plugin-memory": "0.3.
|
|
73
|
-
"@frockbot/plugin-package-catalog": "0.3.
|
|
74
|
-
"@frockbot/plugin-routines": "0.3.
|
|
75
|
-
"@frockbot/plugin-skills": "0.3.
|
|
76
|
-
"@frockbot/plugin-subagents": "0.3.
|
|
77
|
-
"@frockbot/plugin-user-machine": "0.3.
|
|
78
|
-
"@frockbot/protocol": "0.3.
|
|
50
|
+
"@frockbot/agent-runtime": "0.3.8",
|
|
51
|
+
"@frockbot/application-foundation": "0.3.8",
|
|
52
|
+
"@frockbot/catalog-core": "0.3.8",
|
|
53
|
+
"@frockbot/client-core": "0.3.8",
|
|
54
|
+
"@frockbot/client-ui": "0.3.8",
|
|
55
|
+
"@frockbot/computer-core": "0.3.8",
|
|
56
|
+
"@frockbot/computer-host-protocol": "0.3.8",
|
|
57
|
+
"@frockbot/configuration-core": "0.3.8",
|
|
58
|
+
"@frockbot/connection-core": "0.3.8",
|
|
59
|
+
"@frockbot/kernel-agent-loop": "0.3.8",
|
|
60
|
+
"@frockbot/kernel-composition": "0.3.8",
|
|
61
|
+
"@frockbot/kernel-contracts": "0.3.8",
|
|
62
|
+
"@frockbot/kernel-do": "0.3.8",
|
|
63
|
+
"@frockbot/machine-protocol": "0.3.8",
|
|
64
|
+
"@frockbot/plugin-applets": "0.3.8",
|
|
65
|
+
"@frockbot/plugin-authoring": "0.3.8",
|
|
66
|
+
"@frockbot/plugin-bot-template": "0.3.8",
|
|
67
|
+
"@frockbot/plugin-computer": "0.3.8",
|
|
68
|
+
"@frockbot/plugin-flock": "0.3.8",
|
|
69
|
+
"@frockbot/plugin-image": "0.3.8",
|
|
70
|
+
"@frockbot/plugin-machine-messages": "0.3.8",
|
|
71
|
+
"@frockbot/plugin-mcp": "0.3.8",
|
|
72
|
+
"@frockbot/plugin-memory": "0.3.8",
|
|
73
|
+
"@frockbot/plugin-package-catalog": "0.3.8",
|
|
74
|
+
"@frockbot/plugin-routines": "0.3.8",
|
|
75
|
+
"@frockbot/plugin-skills": "0.3.8",
|
|
76
|
+
"@frockbot/plugin-subagents": "0.3.8",
|
|
77
|
+
"@frockbot/plugin-user-machine": "0.3.8",
|
|
78
|
+
"@frockbot/protocol": "0.3.8",
|
|
79
79
|
"cordis": "4.0.0-rc.8",
|
|
80
80
|
"vue": "3.5.41"
|
|
81
81
|
},
|
|
82
82
|
"devDependencies": {
|
|
83
83
|
"@cloudflare/workers-types": "latest",
|
|
84
|
-
"@frockbot/plugin-tools": "0.3.
|
|
84
|
+
"@frockbot/plugin-tools": "0.3.8",
|
|
85
85
|
"@types/bun": "1.3.6",
|
|
86
86
|
"@types/node": "26.2.0",
|
|
87
87
|
"@vitejs/plugin-vue": "6.0.8",
|
package/src/backend.ts
CHANGED
|
@@ -437,6 +437,7 @@ import {
|
|
|
437
437
|
optionalSidebarMessagePreviewV1,
|
|
438
438
|
optionalUnreadStateV1,
|
|
439
439
|
projectBotUnreadViewV1,
|
|
440
|
+
sidebarMessagePreviewFromRunsV1,
|
|
440
441
|
SIDEBAR_PREVIEW_KEY,
|
|
441
442
|
unreadReceiptKeyV1,
|
|
442
443
|
UNREAD_COUNT_CAP,
|
|
@@ -444,6 +445,8 @@ import {
|
|
|
444
445
|
type BotUnreadCommandV1,
|
|
445
446
|
type BotUnreadReceiptV1,
|
|
446
447
|
type BotUnreadViewV1,
|
|
448
|
+
type SidebarMessagePreviewV1,
|
|
449
|
+
type SidebarPreviewRunV1,
|
|
447
450
|
} from "./unread.js";
|
|
448
451
|
import { defineBotBackendContribution } from "@frockbot/kernel-contracts/contributions";
|
|
449
452
|
|
|
@@ -5254,7 +5257,6 @@ export class ShellBotBackendContribution {
|
|
|
5254
5257
|
]),
|
|
5255
5258
|
);
|
|
5256
5259
|
const state = optionalUnreadStateV1(storedState);
|
|
5257
|
-
const preview = optionalSidebarMessagePreviewV1(storedPreview);
|
|
5258
5260
|
const index = await this.authority.listRunIndex({
|
|
5259
5261
|
limit: UNREAD_COUNT_CAP + 1,
|
|
5260
5262
|
});
|
|
@@ -5262,10 +5264,41 @@ export class ShellBotBackendContribution {
|
|
|
5262
5264
|
identity.botId,
|
|
5263
5265
|
state,
|
|
5264
5266
|
index.map((entry) => entry.cursor),
|
|
5265
|
-
|
|
5267
|
+
await this.sidebarPreview(storedPreview, index),
|
|
5266
5268
|
);
|
|
5267
5269
|
}
|
|
5268
5270
|
|
|
5271
|
+
/**
|
|
5272
|
+
* How many stored runs a read will open to recover a missing preview. The
|
|
5273
|
+
* newest settled chat Turn is almost always the first entry; the bound is
|
|
5274
|
+
* what keeps a Bot whose recent Turns are all automations from turning one
|
|
5275
|
+
* sidebar read into a scan of its whole history.
|
|
5276
|
+
*/
|
|
5277
|
+
private static readonly SIDEBAR_PREVIEW_BACKFILL_RUNS_V1 = 5;
|
|
5278
|
+
|
|
5279
|
+
/**
|
|
5280
|
+
* The preview record, or the same line derived from the runs when there is
|
|
5281
|
+
* none. A Bot whose Turns settled before the preview projection existed has
|
|
5282
|
+
* a full transcript and no record, and the row read "No messages yet" over
|
|
5283
|
+
* it. A read never writes what it derives: the next settlement stores it.
|
|
5284
|
+
*/
|
|
5285
|
+
private async sidebarPreview(
|
|
5286
|
+
storedPreview: unknown,
|
|
5287
|
+
index: readonly { runId: string }[],
|
|
5288
|
+
): Promise<SidebarMessagePreviewV1 | undefined> {
|
|
5289
|
+
const stored = optionalSidebarMessagePreviewV1(storedPreview);
|
|
5290
|
+
if (stored) return stored;
|
|
5291
|
+
const runs: SidebarPreviewRunV1[] = [];
|
|
5292
|
+
for (const entry of index.slice(
|
|
5293
|
+
0,
|
|
5294
|
+
ShellBotBackendContribution.SIDEBAR_PREVIEW_BACKFILL_RUNS_V1,
|
|
5295
|
+
)) {
|
|
5296
|
+
const run = await this.authority.readRun(entry.runId);
|
|
5297
|
+
if (run) runs.push(run);
|
|
5298
|
+
}
|
|
5299
|
+
return sidebarMessagePreviewFromRunsV1(runs);
|
|
5300
|
+
}
|
|
5301
|
+
|
|
5269
5302
|
/**
|
|
5270
5303
|
* `bot/mark-read` and `bot/mark-unread`. Idempotent on the command id and
|
|
5271
5304
|
* monotonic in the cursor, so a replay or an out-of-order delivery can only
|
|
@@ -5294,9 +5327,7 @@ export class ShellBotBackendContribution {
|
|
|
5294
5327
|
}
|
|
5295
5328
|
return {
|
|
5296
5329
|
state: optionalUnreadStateV1(existing.state),
|
|
5297
|
-
preview:
|
|
5298
|
-
await transaction.get<unknown>(SIDEBAR_PREVIEW_KEY),
|
|
5299
|
-
),
|
|
5330
|
+
preview: await transaction.get<unknown>(SIDEBAR_PREVIEW_KEY),
|
|
5300
5331
|
};
|
|
5301
5332
|
}
|
|
5302
5333
|
const current = optionalUnreadStateV1(
|
|
@@ -5320,9 +5351,7 @@ export class ShellBotBackendContribution {
|
|
|
5320
5351
|
});
|
|
5321
5352
|
return {
|
|
5322
5353
|
state: next,
|
|
5323
|
-
preview:
|
|
5324
|
-
await transaction.get<unknown>(SIDEBAR_PREVIEW_KEY),
|
|
5325
|
-
),
|
|
5354
|
+
preview: await transaction.get<unknown>(SIDEBAR_PREVIEW_KEY),
|
|
5326
5355
|
};
|
|
5327
5356
|
});
|
|
5328
5357
|
const index = await this.authority.listRunIndex({
|
|
@@ -5336,7 +5365,10 @@ export class ShellBotBackendContribution {
|
|
|
5336
5365
|
identity.botId,
|
|
5337
5366
|
stored.state,
|
|
5338
5367
|
index.map((entry) => entry.cursor),
|
|
5339
|
-
|
|
5368
|
+
// The open Bot is the one that gets marked read, so this receipt is
|
|
5369
|
+
// the sidebar row it renders from: it owes the same derived preview
|
|
5370
|
+
// the fan-out gives every other Bot.
|
|
5371
|
+
await this.sidebarPreview(stored.preview, index),
|
|
5340
5372
|
),
|
|
5341
5373
|
};
|
|
5342
5374
|
}
|
|
@@ -26,6 +26,12 @@ import {
|
|
|
26
26
|
type WebToolActivity,
|
|
27
27
|
} from "../shared.js";
|
|
28
28
|
import { ComposerDraftStore } from "./composer-draft.js";
|
|
29
|
+
import {
|
|
30
|
+
TURN_TEXT_MAX_CHARACTERS_V1,
|
|
31
|
+
turnTextCounterVisibleV1,
|
|
32
|
+
turnTextRemainingV1,
|
|
33
|
+
turnTextTooLongV1,
|
|
34
|
+
} from "./turn-limits.js";
|
|
29
35
|
import SendPayloadView from "./SendPayloadView.vue";
|
|
30
36
|
import AppletCanvas from "./AppletCanvas.vue";
|
|
31
37
|
import PackageIframeHost from "./PackageIframeHost.vue";
|
|
@@ -305,12 +311,31 @@ const composerPlaceholder = computed(() => {
|
|
|
305
311
|
if (!state.value.modelReady) return state.value.modelLabel;
|
|
306
312
|
return botName.value ? `Message ${botName.value}` : "Message";
|
|
307
313
|
});
|
|
314
|
+
/*
|
|
315
|
+
* The send route's size rule, enforced where the person can still do something
|
|
316
|
+
* about it. Past the limit the server answers 413 and nothing is sent, so the
|
|
317
|
+
* composer says how much is left before the button closes rather than letting
|
|
318
|
+
* a long message make the round trip to be refused.
|
|
319
|
+
*/
|
|
320
|
+
const draftText = computed(() => draft.value.trim());
|
|
321
|
+
const draftTooLong = computed(() => turnTextTooLongV1(draftText.value));
|
|
322
|
+
const draftCounterVisible = computed(() =>
|
|
323
|
+
turnTextCounterVisibleV1(draftText.value),
|
|
324
|
+
);
|
|
325
|
+
const draftCounterLabel = computed(() => {
|
|
326
|
+
const remaining = turnTextRemainingV1(draftText.value);
|
|
327
|
+
const count = Math.abs(remaining).toLocaleString("en-US");
|
|
328
|
+
return remaining < 0
|
|
329
|
+
? `${count} characters over the ${TURN_TEXT_MAX_CHARACTERS_V1.toLocaleString("en-US")} limit`
|
|
330
|
+
: `${count} characters left`;
|
|
331
|
+
});
|
|
308
332
|
const canSend = computed(
|
|
309
333
|
() =>
|
|
310
334
|
state.value.connection === "ready" &&
|
|
311
335
|
state.value.modelReady &&
|
|
312
336
|
Boolean(state.value.activeBotId) &&
|
|
313
|
-
|
|
337
|
+
draftText.value.length > 0 &&
|
|
338
|
+
!draftTooLong.value,
|
|
314
339
|
);
|
|
315
340
|
/**
|
|
316
341
|
* Stop takes the button only while there is nothing to send. The moment the
|
|
@@ -937,6 +962,20 @@ function handleComposerKeydown(event: KeyboardEvent): void {
|
|
|
937
962
|
<p v-if="message.notice" class="message-notice">
|
|
938
963
|
{{ message.notice }}
|
|
939
964
|
</p>
|
|
965
|
+
<!--
|
|
966
|
+
The way out of an ending the person cannot otherwise act on:
|
|
967
|
+
the client could not reach the Bot, their text is back in the
|
|
968
|
+
composer, and this sends it again.
|
|
969
|
+
-->
|
|
970
|
+
<button
|
|
971
|
+
v-if="message.retry === 'resend'"
|
|
972
|
+
type="button"
|
|
973
|
+
class="message-retry"
|
|
974
|
+
:disabled="!canSend"
|
|
975
|
+
@click="sendMessage()"
|
|
976
|
+
>
|
|
977
|
+
Retry
|
|
978
|
+
</button>
|
|
940
979
|
<template v-for="tool in message.tools" :key="tool.id">
|
|
941
980
|
<PackageIframeHost
|
|
942
981
|
v-for="entry in iframeEntriesFor(tool)"
|
|
@@ -1188,6 +1227,19 @@ function handleComposerKeydown(event: KeyboardEvent): void {
|
|
|
1188
1227
|
@click="refreshSkillPopover"
|
|
1189
1228
|
@blur="closeSkillPopover"
|
|
1190
1229
|
/>
|
|
1230
|
+
<!--
|
|
1231
|
+
Silent until the budget is nearly spent, then it says how much is
|
|
1232
|
+
left — and, past the limit, how much has to go before the send
|
|
1233
|
+
button opens again.
|
|
1234
|
+
-->
|
|
1235
|
+
<p
|
|
1236
|
+
v-if="draftCounterVisible"
|
|
1237
|
+
class="composer-counter"
|
|
1238
|
+
:class="{ 'composer-counter-over': draftTooLong }"
|
|
1239
|
+
aria-live="polite"
|
|
1240
|
+
>
|
|
1241
|
+
{{ draftCounterLabel }}
|
|
1242
|
+
</p>
|
|
1191
1243
|
</div>
|
|
1192
1244
|
<!--
|
|
1193
1245
|
Start a new conversation. Sits beside the composer because that is
|
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(
|
|
@@ -2152,6 +2156,134 @@ describe("uncertain Turn admission", () => {
|
|
|
2152
2156
|
});
|
|
2153
2157
|
});
|
|
2154
2158
|
|
|
2159
|
+
test("refuses a send the server answered 4xx, keeping the draft and the thread clean", async () => {
|
|
2160
|
+
Object.defineProperty(globalThis, "window", {
|
|
2161
|
+
configurable: true,
|
|
2162
|
+
value: { location: { href: "https://app.example/?bot=primary" } },
|
|
2163
|
+
});
|
|
2164
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
2165
|
+
let lookups = 0;
|
|
2166
|
+
const tooLong =
|
|
2167
|
+
"Your message is too long. Keep it under 32,000 characters.";
|
|
2168
|
+
await shellClientPlugin({
|
|
2169
|
+
transport: {
|
|
2170
|
+
// What the transport throws for a 413: the answer was read, so the
|
|
2171
|
+
// status rides on the error beside the sentence the person should see.
|
|
2172
|
+
turn: () =>
|
|
2173
|
+
Promise.reject(Object.assign(new Error(tooLong), { status: 413 })),
|
|
2174
|
+
lookupRun: () => {
|
|
2175
|
+
lookups += 1;
|
|
2176
|
+
return Promise.resolve(undefined);
|
|
2177
|
+
},
|
|
2178
|
+
fenceRunAdmission: () => Promise.resolve(undefined),
|
|
2179
|
+
},
|
|
2180
|
+
slot: () => () => {},
|
|
2181
|
+
inject: () => {
|
|
2182
|
+
throw new Error("unexpected client provider injection");
|
|
2183
|
+
},
|
|
2184
|
+
provide: (_key, value) => {
|
|
2185
|
+
provided = value as Ref<FrockBotWebData>;
|
|
2186
|
+
return () => {};
|
|
2187
|
+
},
|
|
2188
|
+
});
|
|
2189
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
2190
|
+
provided.value.activeBotId = "primary";
|
|
2191
|
+
provided.value.composerContext = "primary";
|
|
2192
|
+
|
|
2193
|
+
const result = await provided.value.sendPrompt("x".repeat(120_000));
|
|
2194
|
+
|
|
2195
|
+
// A refusal, so the composer keeps the draft — and the thread shows
|
|
2196
|
+
// neither the message that was never sent nor a placeholder about it.
|
|
2197
|
+
expect(result).toEqual({ accepted: false, error: tooLong });
|
|
2198
|
+
expect(provided.value.error).toBe(tooLong);
|
|
2199
|
+
expect(provided.value.messages).toEqual([]);
|
|
2200
|
+
expect(provided.value.activeRun).toBeUndefined();
|
|
2201
|
+
expect(provided.value.activeRunId).toBeUndefined();
|
|
2202
|
+
expect(provided.value.runningRunId).toBeUndefined();
|
|
2203
|
+
// Nothing to reconcile: the Turn was refused, not lost.
|
|
2204
|
+
expect(lookups).toBe(0);
|
|
2205
|
+
});
|
|
2206
|
+
|
|
2207
|
+
test("settles an unreachable backend after the retry bound, under the message it reports on", async () => {
|
|
2208
|
+
Object.defineProperty(globalThis, "window", {
|
|
2209
|
+
configurable: true,
|
|
2210
|
+
value: { location: { href: "https://app.example/?bot=primary" } },
|
|
2211
|
+
});
|
|
2212
|
+
// The bound is spent by waiting, and this test is about how the wait ends
|
|
2213
|
+
// rather than about how long each one is.
|
|
2214
|
+
const originalSetTimeout = globalThis.setTimeout;
|
|
2215
|
+
Object.defineProperty(globalThis, "setTimeout", {
|
|
2216
|
+
configurable: true,
|
|
2217
|
+
writable: true,
|
|
2218
|
+
value: ((callback: () => void) => {
|
|
2219
|
+
queueMicrotask(callback);
|
|
2220
|
+
return 0 as unknown as ReturnType<typeof originalSetTimeout>;
|
|
2221
|
+
}) as unknown as typeof originalSetTimeout,
|
|
2222
|
+
});
|
|
2223
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
2224
|
+
let lookups = 0;
|
|
2225
|
+
try {
|
|
2226
|
+
await shellClientPlugin({
|
|
2227
|
+
transport: {
|
|
2228
|
+
// No status: the answer never arrived, so admission is genuinely
|
|
2229
|
+
// unknown and reconciliation is right to start.
|
|
2230
|
+
turn: () => Promise.reject(new TypeError("Failed to fetch")),
|
|
2231
|
+
lookupRun: () => {
|
|
2232
|
+
lookups += 1;
|
|
2233
|
+
return Promise.reject(new TypeError("Failed to fetch"));
|
|
2234
|
+
},
|
|
2235
|
+
fenceRunAdmission: () =>
|
|
2236
|
+
Promise.reject(new TypeError("Failed to fetch")),
|
|
2237
|
+
},
|
|
2238
|
+
slot: () => () => {},
|
|
2239
|
+
inject: () => {
|
|
2240
|
+
throw new Error("unexpected client provider injection");
|
|
2241
|
+
},
|
|
2242
|
+
provide: (_key, value) => {
|
|
2243
|
+
provided = value as Ref<FrockBotWebData>;
|
|
2244
|
+
return () => {};
|
|
2245
|
+
},
|
|
2246
|
+
});
|
|
2247
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
2248
|
+
provided.value.activeBotId = "primary";
|
|
2249
|
+
provided.value.composerContext = "primary";
|
|
2250
|
+
|
|
2251
|
+
const result = await provided.value.sendPrompt("are you there");
|
|
2252
|
+
|
|
2253
|
+
expect(lookups).toBe(UNCERTAIN_ADMISSION_MAX_ATTEMPTS_V1);
|
|
2254
|
+
expect(result).toEqual({
|
|
2255
|
+
accepted: false,
|
|
2256
|
+
error: UNREACHABLE_BOT_MESSAGE_V1,
|
|
2257
|
+
});
|
|
2258
|
+
const [user, placeholder] = provided.value.messages;
|
|
2259
|
+
expect(user).toMatchObject({ role: "user", text: "are you there" });
|
|
2260
|
+
expect(placeholder).toMatchObject({
|
|
2261
|
+
role: "assistant",
|
|
2262
|
+
text: UNREACHABLE_BOT_MESSAGE_V1,
|
|
2263
|
+
status: "error",
|
|
2264
|
+
retry: "resend",
|
|
2265
|
+
});
|
|
2266
|
+
// Strictly after the message it reports on, so the thread's ordering by
|
|
2267
|
+
// time cannot lift it above that message.
|
|
2268
|
+
expect((placeholder?.at ?? "") > (user?.at ?? "")).toBe(true);
|
|
2269
|
+
// Nothing is running any more, so no Stop stands for it, and the banner
|
|
2270
|
+
// stops saying the client is still checking. It does not repeat the
|
|
2271
|
+
// bubble's sentence either: the bubble is the report, and it is the one
|
|
2272
|
+
// carrying the Retry.
|
|
2273
|
+
expect(provided.value.error).toBeUndefined();
|
|
2274
|
+
expect(provided.value.settingsError).toBeUndefined();
|
|
2275
|
+
expect(provided.value.activeRun).toBeUndefined();
|
|
2276
|
+
expect(provided.value.activeRunId).toBeUndefined();
|
|
2277
|
+
expect(provided.value.runningRunId).toBeUndefined();
|
|
2278
|
+
} finally {
|
|
2279
|
+
Object.defineProperty(globalThis, "setTimeout", {
|
|
2280
|
+
configurable: true,
|
|
2281
|
+
writable: true,
|
|
2282
|
+
value: originalSetTimeout,
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2285
|
+
});
|
|
2286
|
+
|
|
2155
2287
|
test("detaches a rejected Turn without starting a stale observer after Bot switch", async () => {
|
|
2156
2288
|
Object.defineProperty(globalThis, "window", {
|
|
2157
2289
|
configurable: true,
|
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,
|
|
@@ -976,7 +982,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
976
982
|
botId: string,
|
|
977
983
|
runId: string,
|
|
978
984
|
signal: AbortSignal,
|
|
979
|
-
): Promise<"admitted" | "not-admitted" | "detached"> {
|
|
985
|
+
): Promise<"admitted" | "not-admitted" | "detached" | "unreachable"> {
|
|
980
986
|
web.value.activeRun = {
|
|
981
987
|
runId,
|
|
982
988
|
status: "running",
|
|
@@ -986,7 +992,6 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
986
992
|
if (!ctx.transport.lookupRun || !ctx.transport.fenceRunAdmission) {
|
|
987
993
|
return "detached";
|
|
988
994
|
}
|
|
989
|
-
let delayMs = 250;
|
|
990
995
|
let reconciliationError: string | undefined;
|
|
991
996
|
const clearReconciliationError = () => {
|
|
992
997
|
if (web.value.settingsError === reconciliationError) {
|
|
@@ -994,7 +999,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
994
999
|
}
|
|
995
1000
|
reconciliationError = undefined;
|
|
996
1001
|
};
|
|
997
|
-
|
|
1002
|
+
for (let attempt = 1; !signal.aborted; attempt += 1) {
|
|
998
1003
|
try {
|
|
999
1004
|
const observed = await observeWhileAttached(
|
|
1000
1005
|
ctx.transport.lookupRun(botId, runId),
|
|
@@ -1024,8 +1029,16 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1024
1029
|
} Retrying…`;
|
|
1025
1030
|
web.value.settingsError = reconciliationError;
|
|
1026
1031
|
}
|
|
1032
|
+
const delayMs = uncertainAdmissionDelayMsV1(attempt);
|
|
1033
|
+
// The bound is spent. Asking again would only keep a placeholder
|
|
1034
|
+
// spinning over a backend this tab cannot reach, so the caller settles
|
|
1035
|
+
// the Turn and says so in the thread.
|
|
1036
|
+
if (delayMs === undefined) {
|
|
1037
|
+
clearReconciliationError();
|
|
1038
|
+
web.value.activeRun = undefined;
|
|
1039
|
+
return "unreachable";
|
|
1040
|
+
}
|
|
1027
1041
|
await waitForRunLookup(delayMs, signal);
|
|
1028
|
-
delayMs = Math.min(delayMs * 2, 5_000);
|
|
1029
1042
|
}
|
|
1030
1043
|
return "detached";
|
|
1031
1044
|
}
|
|
@@ -2534,14 +2547,39 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2534
2547
|
web.value.error = refusal;
|
|
2535
2548
|
return { accepted: false, error: refusal };
|
|
2536
2549
|
}
|
|
2550
|
+
// Every other 4xx is a refusal too, and the answer already says why —
|
|
2551
|
+
// a message over the size limit is answered 413 with the sentence the
|
|
2552
|
+
// person needs. Only 5xx and a lost connection leave admission in
|
|
2553
|
+
// doubt, so anything else here is settled: no optimistic bubbles, no
|
|
2554
|
+
// "checking" placeholder, no reconciliation, and the composer gets the
|
|
2555
|
+
// draft back rather than the thread pretending it was sent.
|
|
2556
|
+
if (isCertainSendRefusalV1(error)) {
|
|
2557
|
+
removeMessages(web.value.messages, pendingRunId);
|
|
2558
|
+
const refusal =
|
|
2559
|
+
error instanceof Error && error.message
|
|
2560
|
+
? error.message
|
|
2561
|
+
: "That message didn't go through. Try sending it again.";
|
|
2562
|
+
web.value.error = refusal;
|
|
2563
|
+
return { accepted: false, error: refusal };
|
|
2564
|
+
}
|
|
2537
2565
|
const aborted =
|
|
2538
2566
|
error instanceof DOMException && error.name === "AbortError";
|
|
2567
|
+
// Strictly after the message it reports on. The thread orders by time,
|
|
2568
|
+
// and the durable projection gives the user's line the run's later
|
|
2569
|
+
// `admittedAt`, so a placeholder carrying the moment the send began
|
|
2570
|
+
// sorted above the message it belongs to.
|
|
2571
|
+
const placeholderAt = momentAfterV1(
|
|
2572
|
+
web.value.messages.find(
|
|
2573
|
+
(message) =>
|
|
2574
|
+
message.runId === pendingRunId && message.role === "user",
|
|
2575
|
+
)?.at ?? optimisticAt,
|
|
2576
|
+
);
|
|
2539
2577
|
replaceMessage(web.value.messages, pendingRunId, {
|
|
2540
2578
|
id: `${pendingRunId}:assistant`,
|
|
2541
2579
|
runId: pendingRunId,
|
|
2542
2580
|
role: "assistant",
|
|
2543
2581
|
text: "Checking whether your message went through…",
|
|
2544
|
-
at:
|
|
2582
|
+
at: placeholderAt,
|
|
2545
2583
|
status: "interrupted",
|
|
2546
2584
|
tools: [],
|
|
2547
2585
|
sends: [],
|
|
@@ -2554,7 +2592,8 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2554
2592
|
}
|
|
2555
2593
|
const observer = new AbortController();
|
|
2556
2594
|
admissionObserver = observer;
|
|
2557
|
-
let disposition:
|
|
2595
|
+
let disposition:
|
|
2596
|
+
"admitted" | "not-admitted" | "detached" | "unreachable";
|
|
2558
2597
|
try {
|
|
2559
2598
|
disposition = await reconcileUncertainAdmission(
|
|
2560
2599
|
botId,
|
|
@@ -2564,13 +2603,40 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2564
2603
|
} finally {
|
|
2565
2604
|
if (admissionObserver === observer) admissionObserver = undefined;
|
|
2566
2605
|
}
|
|
2606
|
+
// The reconciliation ran out of attempts: this tab cannot reach the
|
|
2607
|
+
// backend at all. That is the app's own failure and it says so, in
|
|
2608
|
+
// place of the placeholder, with the Retry the person would otherwise
|
|
2609
|
+
// have to improvise — and with the Turn no longer running, so Stop
|
|
2610
|
+
// stops standing for a Turn nobody is executing.
|
|
2611
|
+
if (disposition === "unreachable") {
|
|
2612
|
+
replaceMessage(web.value.messages, pendingRunId, {
|
|
2613
|
+
id: `${pendingRunId}:assistant`,
|
|
2614
|
+
runId: pendingRunId,
|
|
2615
|
+
role: "assistant",
|
|
2616
|
+
text: UNREACHABLE_BOT_MESSAGE_V1,
|
|
2617
|
+
at: placeholderAt,
|
|
2618
|
+
status: "error",
|
|
2619
|
+
retry: "resend",
|
|
2620
|
+
tools: [],
|
|
2621
|
+
sends: [],
|
|
2622
|
+
});
|
|
2623
|
+
// The bubble is the report, and it is the one carrying the Retry.
|
|
2624
|
+
// Saying the same sentence again in the banner above it is what the
|
|
2625
|
+
// thread already looked like when it was broken — the same string
|
|
2626
|
+
// three times over — so the banner is cleared rather than set.
|
|
2627
|
+
web.value.error = undefined;
|
|
2628
|
+
web.value.activeRun = undefined;
|
|
2629
|
+
web.value.activeRunId = undefined;
|
|
2630
|
+
web.value.runningRunId = undefined;
|
|
2631
|
+
return { accepted: false, error: UNREACHABLE_BOT_MESSAGE_V1 };
|
|
2632
|
+
}
|
|
2567
2633
|
if (disposition === "not-admitted") {
|
|
2568
2634
|
replaceMessage(web.value.messages, pendingRunId, {
|
|
2569
2635
|
id: `${pendingRunId}:assistant`,
|
|
2570
2636
|
runId: pendingRunId,
|
|
2571
2637
|
role: "assistant",
|
|
2572
2638
|
text: "Your message didn't go through. Try sending it again.",
|
|
2573
|
-
at:
|
|
2639
|
+
at: placeholderAt,
|
|
2574
2640
|
status: "error",
|
|
2575
2641
|
tools: [],
|
|
2576
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
|
+
}
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
BOT_DEBUG_RUN_LIMIT_V1,
|
|
4
4
|
boundDebugEventsV1,
|
|
5
5
|
decodeBotDebugQueryV1,
|
|
6
|
+
isBotDebugQueryRefusalV1,
|
|
6
7
|
} from "./debug-protocol.js";
|
|
7
8
|
|
|
8
9
|
describe("debug query", () => {
|
|
@@ -36,13 +37,48 @@ describe("debug query", () => {
|
|
|
36
37
|
).toThrow("debug query has invalid fields");
|
|
37
38
|
});
|
|
38
39
|
|
|
39
|
-
test("rejects a limit past the page bound", () => {
|
|
40
|
+
test("rejects a limit past the page bound, in words that name the range", () => {
|
|
40
41
|
expect(() =>
|
|
41
42
|
decodeBotDebugQueryV1({
|
|
42
43
|
schemaVersion: 1,
|
|
43
44
|
limit: BOT_DEBUG_RUN_LIMIT_V1 + 1,
|
|
44
45
|
}),
|
|
45
|
-
).toThrow(
|
|
46
|
+
).toThrow(
|
|
47
|
+
`debug query limit must be a whole number from 1 to ${BOT_DEBUG_RUN_LIMIT_V1}`,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// The refusal rides on the error's name, which is all a Durable Object RPC
|
|
52
|
+
// preserves: a bad query has to arrive at the gateway as a 400 and not as
|
|
53
|
+
// an uncaught failure in the Bot's isolate.
|
|
54
|
+
test("refuses a bad query as a refusal, not as an ordinary failure", () => {
|
|
55
|
+
for (const input of [
|
|
56
|
+
{ schemaVersion: 1, limit: 0 },
|
|
57
|
+
{ schemaVersion: 1, limit: BOT_DEBUG_RUN_LIMIT_V1 + 1 },
|
|
58
|
+
{ schemaVersion: 1, limit: 1.5 },
|
|
59
|
+
{ schemaVersion: 1, limit: Number.NaN },
|
|
60
|
+
{ schemaVersion: 1, sql: "select 1" },
|
|
61
|
+
{ schemaVersion: 2 },
|
|
62
|
+
"not a query",
|
|
63
|
+
]) {
|
|
64
|
+
let refusal: unknown;
|
|
65
|
+
try {
|
|
66
|
+
decodeBotDebugQueryV1(input);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
refusal = error;
|
|
69
|
+
}
|
|
70
|
+
expect(isBotDebugQueryRefusalV1(refusal)).toBe(true);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("accepts both ends of the allowed range", () => {
|
|
75
|
+
expect(decodeBotDebugQueryV1({ schemaVersion: 1, limit: 1 }).limit).toBe(1);
|
|
76
|
+
expect(
|
|
77
|
+
decodeBotDebugQueryV1({
|
|
78
|
+
schemaVersion: 1,
|
|
79
|
+
limit: BOT_DEBUG_RUN_LIMIT_V1,
|
|
80
|
+
}).limit,
|
|
81
|
+
).toBe(BOT_DEBUG_RUN_LIMIT_V1);
|
|
46
82
|
});
|
|
47
83
|
|
|
48
84
|
test("rejects a wrong schema version", () => {
|
package/src/debug-protocol.ts
CHANGED
|
@@ -90,21 +90,50 @@ export interface BotDebugSnapshotV1 {
|
|
|
90
90
|
nextCursor?: string;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* A debug query the caller got wrong: an unknown field, a `limit` past the cap.
|
|
95
|
+
* The request is what is bad, not the Bot, so the surface owes a 400 rather
|
|
96
|
+
* than an uncaught failure in the isolate. The name is what carries that
|
|
97
|
+
* across the Durable Object RPC boundary — which keeps an error's `name` and
|
|
98
|
+
* `message` and drops everything else — exactly as `BotTurnRefusedError` does
|
|
99
|
+
* for a refused admission.
|
|
100
|
+
*/
|
|
101
|
+
export class BotDebugQueryRefusedErrorV1 extends Error {
|
|
102
|
+
constructor(message: string) {
|
|
103
|
+
super(message);
|
|
104
|
+
this.name = "BotDebugQueryRefusedErrorV1";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Whether an error — including one that has crossed RPC — is that refusal. */
|
|
109
|
+
export function isBotDebugQueryRefusalV1(error: unknown): boolean {
|
|
110
|
+
return (
|
|
111
|
+
typeof error === "object" &&
|
|
112
|
+
error !== null &&
|
|
113
|
+
"name" in error &&
|
|
114
|
+
String((error as { name: unknown }).name) === "BotDebugQueryRefusedErrorV1"
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
93
118
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
94
119
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
95
120
|
}
|
|
96
121
|
|
|
97
122
|
function boundedString(value: unknown, maximum: number, field: string): string {
|
|
98
123
|
if (typeof value !== "string" || value.length < 1 || value.length > maximum) {
|
|
99
|
-
throw new
|
|
124
|
+
throw new BotDebugQueryRefusedErrorV1(`debug query ${field} is invalid`);
|
|
100
125
|
}
|
|
101
126
|
return value;
|
|
102
127
|
}
|
|
103
128
|
|
|
104
129
|
export function decodeBotDebugQueryV1(input: unknown): BotDebugQueryV1 {
|
|
105
|
-
if (!isRecord(input))
|
|
130
|
+
if (!isRecord(input)) {
|
|
131
|
+
throw new BotDebugQueryRefusedErrorV1("debug query is invalid");
|
|
132
|
+
}
|
|
106
133
|
if (input.schemaVersion !== 1) {
|
|
107
|
-
throw new
|
|
134
|
+
throw new BotDebugQueryRefusedErrorV1(
|
|
135
|
+
"debug query schemaVersion is invalid",
|
|
136
|
+
);
|
|
108
137
|
}
|
|
109
138
|
const allowed = new Set([
|
|
110
139
|
"schemaVersion",
|
|
@@ -114,7 +143,7 @@ export function decodeBotDebugQueryV1(input: unknown): BotDebugQueryV1 {
|
|
|
114
143
|
"events",
|
|
115
144
|
]);
|
|
116
145
|
if (!Object.keys(input).every((key) => allowed.has(key))) {
|
|
117
|
-
throw new
|
|
146
|
+
throw new BotDebugQueryRefusedErrorV1("debug query has invalid fields");
|
|
118
147
|
}
|
|
119
148
|
const query: BotDebugQueryV1 = { schemaVersion: 1 };
|
|
120
149
|
if (input.runId !== undefined) {
|
|
@@ -129,13 +158,17 @@ export function decodeBotDebugQueryV1(input: unknown): BotDebugQueryV1 {
|
|
|
129
158
|
(input.limit as number) < 1 ||
|
|
130
159
|
(input.limit as number) > BOT_DEBUG_RUN_LIMIT_V1
|
|
131
160
|
) {
|
|
132
|
-
throw new
|
|
161
|
+
throw new BotDebugQueryRefusedErrorV1(
|
|
162
|
+
`debug query limit must be a whole number from 1 to ${BOT_DEBUG_RUN_LIMIT_V1}`,
|
|
163
|
+
);
|
|
133
164
|
}
|
|
134
165
|
query.limit = input.limit as number;
|
|
135
166
|
}
|
|
136
167
|
if (input.events !== undefined) {
|
|
137
168
|
if (typeof input.events !== "boolean") {
|
|
138
|
-
throw new
|
|
169
|
+
throw new BotDebugQueryRefusedErrorV1(
|
|
170
|
+
"debug query events must be true or false",
|
|
171
|
+
);
|
|
139
172
|
}
|
|
140
173
|
query.events = input.events;
|
|
141
174
|
}
|
package/src/run-protocol.test.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
projectClientRunV1,
|
|
25
25
|
projectClientRunOrDegradedV1,
|
|
26
26
|
projectClientTurnV1,
|
|
27
|
+
UNRECORDED_TOOL_RESULT_TEXT_V1,
|
|
27
28
|
} from "./run-protocol.js";
|
|
28
29
|
|
|
29
30
|
const timestamp = "2026-08-29T00:00:00.000Z";
|
|
@@ -1061,14 +1062,59 @@ describe("client run protocol v1", () => {
|
|
|
1061
1062
|
expect(() => projectClientRunListV1([storedRun([result])])).toThrow(
|
|
1062
1063
|
'tool result has no matching occurrence "tool:1:1:0"',
|
|
1063
1064
|
);
|
|
1064
|
-
expect(() => projectClientRunListV1([storedRun([call])])).toThrow(
|
|
1065
|
-
'terminal run has no result for tool call "tool-1"',
|
|
1066
|
-
);
|
|
1067
1065
|
expect(() =>
|
|
1068
1066
|
projectClientRunListV1([storedRun([call, call, result])]),
|
|
1069
1067
|
).toThrow('tool occurrence "tool:1:1:0" has duplicate intent');
|
|
1070
1068
|
});
|
|
1071
1069
|
|
|
1070
|
+
// A READ never throws on a record that is already durable. A settled Turn
|
|
1071
|
+
// whose tool call was never settled used to fail the whole transcript
|
|
1072
|
+
// endpoint — a 500 on every later request — so one malformed row bricked the
|
|
1073
|
+
// conversation for ever. It degrades to a row saying nothing was recorded.
|
|
1074
|
+
test("degrades a settled Turn's unsettled tool call instead of throwing", () => {
|
|
1075
|
+
const call = toolEvents(1)[0]!;
|
|
1076
|
+
|
|
1077
|
+
const projected = projectClientRunListV1([storedRun([call])]).runs[0];
|
|
1078
|
+
|
|
1079
|
+
expect(projected?.events).toEqual([
|
|
1080
|
+
{ type: "tool/call", call: { id: "tool-1", name: "lookup" } },
|
|
1081
|
+
{
|
|
1082
|
+
type: "tool/result",
|
|
1083
|
+
callId: "tool-1",
|
|
1084
|
+
content: UNRECORDED_TOOL_RESULT_TEXT_V1,
|
|
1085
|
+
isError: true,
|
|
1086
|
+
},
|
|
1087
|
+
]);
|
|
1088
|
+
// And the degraded row survives the wire decode, which used to refuse it
|
|
1089
|
+
// for the same reason the projection did.
|
|
1090
|
+
expect(
|
|
1091
|
+
decodeClientRunListV1({
|
|
1092
|
+
schemaVersion: 1,
|
|
1093
|
+
runs: [projected],
|
|
1094
|
+
page: { truncated: false },
|
|
1095
|
+
})[0]?.events,
|
|
1096
|
+
).toHaveLength(2);
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
test("accepts a settled Turn on the wire whose call carries no result", () => {
|
|
1100
|
+
const projected = projectClientRunListV1([storedRun([])]).runs[0]!;
|
|
1101
|
+
|
|
1102
|
+
expect(
|
|
1103
|
+
decodeClientRunListV1({
|
|
1104
|
+
schemaVersion: 1,
|
|
1105
|
+
runs: [
|
|
1106
|
+
{
|
|
1107
|
+
...projected,
|
|
1108
|
+
events: [
|
|
1109
|
+
{ type: "tool/call", call: { id: "tool-1", name: "lookup" } },
|
|
1110
|
+
],
|
|
1111
|
+
},
|
|
1112
|
+
],
|
|
1113
|
+
page: { truncated: false },
|
|
1114
|
+
})[0]?.events,
|
|
1115
|
+
).toEqual([{ type: "tool/call", call: { id: "tool-1", name: "lookup" } }]);
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1072
1118
|
test("retains pending calls only for nonterminal runs", () => {
|
|
1073
1119
|
const call = toolEvents(1)[0]!;
|
|
1074
1120
|
const projected = projectClientRunListV1([storedRun([call], "running")])
|
package/src/run-protocol.ts
CHANGED
|
@@ -500,6 +500,14 @@ interface ProjectionUnitV1 {
|
|
|
500
500
|
droppable: boolean;
|
|
501
501
|
}
|
|
502
502
|
|
|
503
|
+
/**
|
|
504
|
+
* What a settled Turn's tool call shows when the durable record holds no
|
|
505
|
+
* result for it. Same register as the rest of the transcript copy: it tells
|
|
506
|
+
* the person what is missing rather than naming an occurrence id.
|
|
507
|
+
*/
|
|
508
|
+
export const UNRECORDED_TOOL_RESULT_TEXT_V1 =
|
|
509
|
+
"No result was recorded for this tool call.";
|
|
510
|
+
|
|
503
511
|
function dynamicToolCallInput(
|
|
504
512
|
value: unknown,
|
|
505
513
|
): ClientDynamicToolCallInputV1 | undefined {
|
|
@@ -658,12 +666,23 @@ function projectionUnits(
|
|
|
658
666
|
}
|
|
659
667
|
}
|
|
660
668
|
if (isTerminalRunStatus(status)) {
|
|
661
|
-
const
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
669
|
+
for (const unit of units) {
|
|
670
|
+
if (unit.droppable) continue;
|
|
671
|
+
// A settled Turn owes every tool call a result, and `Session`'s
|
|
672
|
+
// interruption repairs now write one. Records already durable from
|
|
673
|
+
// before that do not have it, and a READ must never throw on them: one
|
|
674
|
+
// malformed row used to brick the whole transcript endpoint for ever.
|
|
675
|
+
// The row degrades instead, and says exactly what is missing — not
|
|
676
|
+
// through `projectClientRunOrDegradedV1`, which throws the whole Turn
|
|
677
|
+
// away for an unreadable record. Everything else here is readable.
|
|
678
|
+
const call = unit.events[0] as ClientToolCallV1;
|
|
679
|
+
unit.events.push({
|
|
680
|
+
type: "tool/result",
|
|
681
|
+
callId: call.call.id,
|
|
682
|
+
content: UNRECORDED_TOOL_RESULT_TEXT_V1,
|
|
683
|
+
isError: true,
|
|
684
|
+
});
|
|
685
|
+
unit.droppable = true;
|
|
667
686
|
}
|
|
668
687
|
}
|
|
669
688
|
return units;
|
|
@@ -1137,10 +1156,12 @@ function decodeEvent(value: unknown): ClientRunEventV1 {
|
|
|
1137
1156
|
throw new Error("run event.type is invalid");
|
|
1138
1157
|
}
|
|
1139
1158
|
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1159
|
+
/**
|
|
1160
|
+
* The event walk a wire run is decoded through. It no longer takes the run's
|
|
1161
|
+
* status: a settled Turn's tool call with no result is a row the projection
|
|
1162
|
+
* has already degraded, not a message to refuse.
|
|
1163
|
+
*/
|
|
1164
|
+
function decodeEvents(values: unknown[]): ClientTurnEvent[] {
|
|
1144
1165
|
const events = values.map(decodeEvent);
|
|
1145
1166
|
let index = 0;
|
|
1146
1167
|
if (events[0]?.type === "run/events-truncated") index = 1;
|
|
@@ -1178,9 +1199,10 @@ function decodeEvents(
|
|
|
1178
1199
|
index += 2;
|
|
1179
1200
|
continue;
|
|
1180
1201
|
}
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1202
|
+
// A settled Turn whose call has no result is a degraded row, not a bad
|
|
1203
|
+
// wire message: the projection above already renders it as "no result
|
|
1204
|
+
// recorded", and refusing it here would put the whole transcript behind
|
|
1205
|
+
// one durable record nobody can now repair.
|
|
1184
1206
|
index += 1;
|
|
1185
1207
|
}
|
|
1186
1208
|
return events;
|
|
@@ -1330,7 +1352,7 @@ function decodeRun(value: unknown): ClientRun {
|
|
|
1330
1352
|
admittedAt,
|
|
1331
1353
|
input: wireString(run, "input", MAX_INPUT_BYTES, "run"),
|
|
1332
1354
|
status: runStatus,
|
|
1333
|
-
events: decodeEvents(run.events
|
|
1355
|
+
events: decodeEvents(run.events),
|
|
1334
1356
|
...(stopRequestedAt ? { stopRequestedAt } : {}),
|
|
1335
1357
|
...(run.queued === true ? { queued: true as const } : {}),
|
|
1336
1358
|
...(outcome?.type === "completed" ? { responseText: outcome.text } : {}),
|
|
@@ -1454,7 +1476,7 @@ export function decodeClientTurnV1(input: unknown): ClientTurnResponse {
|
|
|
1454
1476
|
return {
|
|
1455
1477
|
runId,
|
|
1456
1478
|
text: wireString(turn, "text", MAX_OUTCOME_BYTES, "turn"),
|
|
1457
|
-
events: decodeEvents(turn.events
|
|
1479
|
+
events: decodeEvents(turn.events),
|
|
1458
1480
|
...(notification ? { notification } : {}),
|
|
1459
1481
|
};
|
|
1460
1482
|
}
|
package/src/shared.ts
CHANGED
|
@@ -107,6 +107,13 @@ export interface WebChatMessage {
|
|
|
107
107
|
* Bot had already said, which it keeps (ADR 0024).
|
|
108
108
|
*/
|
|
109
109
|
notice?: string;
|
|
110
|
+
/**
|
|
111
|
+
* The line offers to send the draft again. Set only where the client gave
|
|
112
|
+
* up on its own — it could not reach the backend — because that is the one
|
|
113
|
+
* ending the person cannot act on from the thread otherwise: their text is
|
|
114
|
+
* back in the composer, and this is the button that sends it.
|
|
115
|
+
*/
|
|
116
|
+
retry?: "resend";
|
|
110
117
|
tools: WebToolActivity[];
|
|
111
118
|
/** The typed payloads this Turn sent to the user, oldest first. */
|
|
112
119
|
sends: WebSendPayload[];
|
package/src/unread.test.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
optionalUnreadStateV1,
|
|
14
14
|
projectBotUnreadViewV1,
|
|
15
15
|
sidebarMessagePreviewForTurnV1,
|
|
16
|
+
sidebarMessagePreviewFromRunsV1,
|
|
16
17
|
UNREAD_COUNT_CAP,
|
|
17
18
|
type UnreadStateV1,
|
|
18
19
|
} from "./unread.js";
|
|
@@ -263,6 +264,73 @@ describe("the unread projection", () => {
|
|
|
263
264
|
});
|
|
264
265
|
});
|
|
265
266
|
|
|
267
|
+
// The record is written at settlement, so a Bot whose Turns settled before
|
|
268
|
+
// that projection existed has a full transcript and no preview — and its
|
|
269
|
+
// sidebar row said "No messages yet" over six messages. A read derives it.
|
|
270
|
+
describe("the sidebar preview derived from stored runs", () => {
|
|
271
|
+
const run = (over: Record<string, unknown> = {}) => ({
|
|
272
|
+
acceptedAt: "2026-08-31T00:02:00.000Z",
|
|
273
|
+
input: "What is the plan?",
|
|
274
|
+
responseText: "Here is the plan.",
|
|
275
|
+
status: "completed",
|
|
276
|
+
events: [{ timestamp: "2026-08-31T00:02:05.000Z" }],
|
|
277
|
+
...over,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("takes the newest settled chat Turn's reply, stamped when it settled", () => {
|
|
281
|
+
expect(sidebarMessagePreviewFromRunsV1([run()])).toEqual({
|
|
282
|
+
schemaVersion: 1,
|
|
283
|
+
text: "Here is the plan.",
|
|
284
|
+
at: "2026-08-31T00:02:05.000Z",
|
|
285
|
+
role: "assistant",
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("walks past a running Turn and an automation to the newest chat reply", () => {
|
|
290
|
+
expect(
|
|
291
|
+
sidebarMessagePreviewFromRunsV1([
|
|
292
|
+
run({ status: "running", responseText: undefined }),
|
|
293
|
+
run({
|
|
294
|
+
admission: { turnType: "automation" },
|
|
295
|
+
responseText: "Routine ran.",
|
|
296
|
+
}),
|
|
297
|
+
run({ responseText: "The older answer." }),
|
|
298
|
+
]),
|
|
299
|
+
).toMatchObject({ text: "The older answer.", role: "assistant" });
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("falls back to the User's own words when the reply was empty", () => {
|
|
303
|
+
expect(
|
|
304
|
+
sidebarMessagePreviewFromRunsV1([run({ responseText: "" })]),
|
|
305
|
+
).toEqual({
|
|
306
|
+
schemaVersion: 1,
|
|
307
|
+
text: "What is the plan?",
|
|
308
|
+
at: "2026-08-31T00:02:00.000Z",
|
|
309
|
+
role: "user",
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("a Bot with no settled chat Turn still has no preview", () => {
|
|
314
|
+
expect(sidebarMessagePreviewFromRunsV1([])).toBeUndefined();
|
|
315
|
+
expect(
|
|
316
|
+
sidebarMessagePreviewFromRunsV1([
|
|
317
|
+
run({ status: "running", responseText: undefined, input: "" }),
|
|
318
|
+
]),
|
|
319
|
+
).toBeUndefined();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// A read of durable data never throws: a run whose stamps are unreadable
|
|
323
|
+
// costs the row, not the whole sidebar.
|
|
324
|
+
test("skips a run whose stored timestamps cannot be read", () => {
|
|
325
|
+
expect(
|
|
326
|
+
sidebarMessagePreviewFromRunsV1([
|
|
327
|
+
run({ acceptedAt: "not a time", events: [] }),
|
|
328
|
+
run({ responseText: "The readable one." }),
|
|
329
|
+
]),
|
|
330
|
+
).toMatchObject({ text: "The readable one." });
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
266
334
|
describe("the unread command", () => {
|
|
267
335
|
test("decodes each type and refuses a mismatched cursor", () => {
|
|
268
336
|
expect(
|
package/src/unread.ts
CHANGED
|
@@ -260,6 +260,59 @@ export function sidebarMessagePreviewForTurnV1(
|
|
|
260
260
|
});
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
+
/** The little a settled run has to expose for a preview to be derived from it. */
|
|
264
|
+
export interface SidebarPreviewRunV1 {
|
|
265
|
+
acceptedAt: string;
|
|
266
|
+
input: string;
|
|
267
|
+
responseText?: string;
|
|
268
|
+
status: string;
|
|
269
|
+
/** Only the timestamps are read: the newest one is when the Turn settled. */
|
|
270
|
+
events?: readonly { timestamp?: string }[];
|
|
271
|
+
/** Absent means the Turn was admitted as `chat`, as everywhere else. */
|
|
272
|
+
admission?: { turnType?: string };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The preview for a Bot that has a transcript but no preview record.
|
|
277
|
+
*
|
|
278
|
+
* The record is written at settlement, so every Turn that settled before that
|
|
279
|
+
* projection existed left one behind — and the row then claimed "No messages
|
|
280
|
+
* yet" over a full conversation. A read cannot write the record it is missing,
|
|
281
|
+
* so it derives the same line from the runs that are already durable. Runs
|
|
282
|
+
* arrive newest-first and the walk stops at the first settled chat Turn with
|
|
283
|
+
* text, which is exactly the line the settlement would have stored.
|
|
284
|
+
*/
|
|
285
|
+
export function sidebarMessagePreviewFromRunsV1(
|
|
286
|
+
runs: readonly SidebarPreviewRunV1[],
|
|
287
|
+
): SidebarMessagePreviewV1 | undefined {
|
|
288
|
+
for (const run of runs) {
|
|
289
|
+
if (run.status === "running") continue;
|
|
290
|
+
// An automation Turn reaches the User through its own inbox entry and
|
|
291
|
+
// never became this row at settlement either.
|
|
292
|
+
if ((run.admission?.turnType ?? "chat") !== "chat") continue;
|
|
293
|
+
const settledAt = settlementTimestampV1(run);
|
|
294
|
+
try {
|
|
295
|
+
const preview = sidebarMessagePreviewForTurnV1(run, settledAt);
|
|
296
|
+
if (preview) return preview;
|
|
297
|
+
} catch {
|
|
298
|
+
// A run whose stored timestamps cannot be read is not worth the row —
|
|
299
|
+
// and a read of durable data never throws over one bad record.
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return undefined;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** When a stored run settled: its newest event's stamp, else its admission. */
|
|
307
|
+
function settlementTimestampV1(run: SidebarPreviewRunV1): string {
|
|
308
|
+
for (let index = (run.events?.length ?? 0) - 1; index >= 0; index -= 1) {
|
|
309
|
+
const timestamp = run.events?.[index]?.timestamp;
|
|
310
|
+
if (typeof timestamp === "string" && Number.isFinite(Date.parse(timestamp)))
|
|
311
|
+
return timestamp;
|
|
312
|
+
}
|
|
313
|
+
return run.acceptedAt;
|
|
314
|
+
}
|
|
315
|
+
|
|
263
316
|
/**
|
|
264
317
|
* Records a settled chat Turn. Monotonic: a cursor that is not newer than the
|
|
265
318
|
* one already recorded leaves the record byte-for-byte unchanged, so a
|