@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/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",
|
|
@@ -354,26 +354,20 @@ describe("Bot recovery", () => {
|
|
|
354
354
|
text: "uncertain",
|
|
355
355
|
}),
|
|
356
356
|
).rejects.toThrow("response lost");
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
357
|
+
// Ollama keeps no addressable copy of a completion, so a failure raised
|
|
358
|
+
// before the first stream event is definitive rather than uncertain: the
|
|
359
|
+
// run settles as a failed Turn instead of parking on a retrieval this
|
|
360
|
+
// provider can never perform.
|
|
361
|
+
const uncertain = await storage.get<StoredRun>("run:ollama-run-uncertain");
|
|
362
|
+
expect(uncertain?.status).toBe("failed");
|
|
363
|
+
expect(uncertain?.failure).toContain("response lost");
|
|
364
|
+
expect(await storage.get("active-run")).toBeUndefined();
|
|
360
365
|
|
|
366
|
+
// The alarm has nothing left to recover: a settled run stays settled.
|
|
361
367
|
await host().alarm();
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
).
|
|
365
|
-
await expect(
|
|
366
|
-
host().reconcileRun(
|
|
367
|
-
{ userId: "user-1", botId: "primary" },
|
|
368
|
-
"ollama-run-uncertain",
|
|
369
|
-
),
|
|
370
|
-
).rejects.toThrow();
|
|
371
|
-
expect(
|
|
372
|
-
await storage.get<StoredRun>("run:ollama-run-uncertain"),
|
|
373
|
-
).toMatchObject({
|
|
374
|
-
status: "failed",
|
|
375
|
-
failure: expect.stringContaining("explicitly abandoned"),
|
|
376
|
-
});
|
|
368
|
+
const settled = await storage.get<StoredRun>("run:ollama-run-uncertain");
|
|
369
|
+
expect(settled?.status).toBe("failed");
|
|
370
|
+
expect(settled?.failure).toContain("response lost");
|
|
377
371
|
expect(await storage.get("active-run")).toBeUndefined();
|
|
378
372
|
});
|
|
379
373
|
|
package/src/backend.ts
CHANGED
|
@@ -403,6 +403,7 @@ import {
|
|
|
403
403
|
projectClientAnnouncementsV1,
|
|
404
404
|
projectClientTurnV1,
|
|
405
405
|
type ClientRunLookupV1,
|
|
406
|
+
type ClientConversationListV1,
|
|
406
407
|
type ClientRunListV1,
|
|
407
408
|
type ClientRunStopReceiptV1,
|
|
408
409
|
type ClientRunV1,
|
|
@@ -436,6 +437,7 @@ import {
|
|
|
436
437
|
optionalSidebarMessagePreviewV1,
|
|
437
438
|
optionalUnreadStateV1,
|
|
438
439
|
projectBotUnreadViewV1,
|
|
440
|
+
sidebarMessagePreviewFromRunsV1,
|
|
439
441
|
SIDEBAR_PREVIEW_KEY,
|
|
440
442
|
unreadReceiptKeyV1,
|
|
441
443
|
UNREAD_COUNT_CAP,
|
|
@@ -443,6 +445,8 @@ import {
|
|
|
443
445
|
type BotUnreadCommandV1,
|
|
444
446
|
type BotUnreadReceiptV1,
|
|
445
447
|
type BotUnreadViewV1,
|
|
448
|
+
type SidebarMessagePreviewV1,
|
|
449
|
+
type SidebarPreviewRunV1,
|
|
446
450
|
} from "./unread.js";
|
|
447
451
|
import { defineBotBackendContribution } from "@frockbot/kernel-contracts/contributions";
|
|
448
452
|
|
|
@@ -5253,7 +5257,6 @@ export class ShellBotBackendContribution {
|
|
|
5253
5257
|
]),
|
|
5254
5258
|
);
|
|
5255
5259
|
const state = optionalUnreadStateV1(storedState);
|
|
5256
|
-
const preview = optionalSidebarMessagePreviewV1(storedPreview);
|
|
5257
5260
|
const index = await this.authority.listRunIndex({
|
|
5258
5261
|
limit: UNREAD_COUNT_CAP + 1,
|
|
5259
5262
|
});
|
|
@@ -5261,10 +5264,41 @@ export class ShellBotBackendContribution {
|
|
|
5261
5264
|
identity.botId,
|
|
5262
5265
|
state,
|
|
5263
5266
|
index.map((entry) => entry.cursor),
|
|
5264
|
-
|
|
5267
|
+
await this.sidebarPreview(storedPreview, index),
|
|
5265
5268
|
);
|
|
5266
5269
|
}
|
|
5267
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
|
+
|
|
5268
5302
|
/**
|
|
5269
5303
|
* `bot/mark-read` and `bot/mark-unread`. Idempotent on the command id and
|
|
5270
5304
|
* monotonic in the cursor, so a replay or an out-of-order delivery can only
|
|
@@ -5293,9 +5327,7 @@ export class ShellBotBackendContribution {
|
|
|
5293
5327
|
}
|
|
5294
5328
|
return {
|
|
5295
5329
|
state: optionalUnreadStateV1(existing.state),
|
|
5296
|
-
preview:
|
|
5297
|
-
await transaction.get<unknown>(SIDEBAR_PREVIEW_KEY),
|
|
5298
|
-
),
|
|
5330
|
+
preview: await transaction.get<unknown>(SIDEBAR_PREVIEW_KEY),
|
|
5299
5331
|
};
|
|
5300
5332
|
}
|
|
5301
5333
|
const current = optionalUnreadStateV1(
|
|
@@ -5319,9 +5351,7 @@ export class ShellBotBackendContribution {
|
|
|
5319
5351
|
});
|
|
5320
5352
|
return {
|
|
5321
5353
|
state: next,
|
|
5322
|
-
preview:
|
|
5323
|
-
await transaction.get<unknown>(SIDEBAR_PREVIEW_KEY),
|
|
5324
|
-
),
|
|
5354
|
+
preview: await transaction.get<unknown>(SIDEBAR_PREVIEW_KEY),
|
|
5325
5355
|
};
|
|
5326
5356
|
});
|
|
5327
5357
|
const index = await this.authority.listRunIndex({
|
|
@@ -5335,7 +5365,10 @@ export class ShellBotBackendContribution {
|
|
|
5335
5365
|
identity.botId,
|
|
5336
5366
|
stored.state,
|
|
5337
5367
|
index.map((entry) => entry.cursor),
|
|
5338
|
-
|
|
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),
|
|
5339
5372
|
),
|
|
5340
5373
|
};
|
|
5341
5374
|
}
|
|
@@ -5443,6 +5476,15 @@ export class ShellBotBackendContribution {
|
|
|
5443
5476
|
): Promise<ClientRunListV1> {
|
|
5444
5477
|
const query = decodeClientRunListQueryV1(input);
|
|
5445
5478
|
await this.authority.recoverActiveRun();
|
|
5479
|
+
// The transcript is one conversation, not every Turn the Bot has ever
|
|
5480
|
+
// run. Absent means the conversation the Bot is on; naming an earlier one
|
|
5481
|
+
// reads it exactly as it was left. A Bot whose object has not learned its
|
|
5482
|
+
// identity yet has no conversation to filter by and shows what it has.
|
|
5483
|
+
const conversationId =
|
|
5484
|
+
query.conversationId ??
|
|
5485
|
+
(await this.authority.readConversationSessionId());
|
|
5486
|
+
const inConversation = (run: { sessionId: string }) =>
|
|
5487
|
+
conversationId === undefined || run.sessionId === conversationId;
|
|
5446
5488
|
const activeRunId = query.before
|
|
5447
5489
|
? undefined
|
|
5448
5490
|
: await this.authority.readActiveRunId();
|
|
@@ -5457,7 +5499,7 @@ export class ShellBotBackendContribution {
|
|
|
5457
5499
|
// An automation firing occupies the object like any other run, and is
|
|
5458
5500
|
// still not part of the conversation: the visible transcript never
|
|
5459
5501
|
// shows one, running or settled.
|
|
5460
|
-
if (active && isVisibleRunV1(active))
|
|
5502
|
+
if (active && isVisibleRunV1(active) && inConversation(active))
|
|
5461
5503
|
selected.set(active.runId, {
|
|
5462
5504
|
run: projectClientRunOrDegradedV1(active),
|
|
5463
5505
|
});
|
|
@@ -5471,7 +5513,8 @@ export class ShellBotBackendContribution {
|
|
|
5471
5513
|
continue;
|
|
5472
5514
|
}
|
|
5473
5515
|
const stored = await this.authority.readStoredRun(candidate.runId);
|
|
5474
|
-
if (!stored || !isVisibleRunV1(stored))
|
|
5516
|
+
if (!stored || !isVisibleRunV1(stored) || !inConversation(stored))
|
|
5517
|
+
continue;
|
|
5475
5518
|
const projected = projectClientRunOrDegradedV1(stored);
|
|
5476
5519
|
const tentative = [
|
|
5477
5520
|
...selected.values(),
|
|
@@ -5527,6 +5570,36 @@ export class ShellBotBackendContribution {
|
|
|
5527
5570
|
}
|
|
5528
5571
|
return page;
|
|
5529
5572
|
}
|
|
5573
|
+
/** The conversations this Bot has had, newest first. */
|
|
5574
|
+
async listConversations(): Promise<ClientConversationListV1> {
|
|
5575
|
+
return {
|
|
5576
|
+
schemaVersion: 1,
|
|
5577
|
+
conversations: (await this.authority.listConversations()).map(
|
|
5578
|
+
(conversation) => ({
|
|
5579
|
+
schemaVersion: 1 as const,
|
|
5580
|
+
conversationId: conversation.sessionId,
|
|
5581
|
+
ordinal: conversation.ordinal,
|
|
5582
|
+
startedAt: conversation.startedAt,
|
|
5583
|
+
...(conversation.endedAt ? { endedAt: conversation.endedAt } : {}),
|
|
5584
|
+
}),
|
|
5585
|
+
),
|
|
5586
|
+
};
|
|
5587
|
+
}
|
|
5588
|
+
|
|
5589
|
+
/**
|
|
5590
|
+
* Puts this conversation down and starts the next one.
|
|
5591
|
+
*
|
|
5592
|
+
* Memory is untouched: it is not conversation history, and the point of a
|
|
5593
|
+
* new conversation is to prove that it is not.
|
|
5594
|
+
*/
|
|
5595
|
+
async startConversation(
|
|
5596
|
+
identity: BotIdentity,
|
|
5597
|
+
): Promise<ClientConversationListV1> {
|
|
5598
|
+
await this.validateIdentity(identity);
|
|
5599
|
+
await this.authority.startConversation(identity);
|
|
5600
|
+
return this.listConversations();
|
|
5601
|
+
}
|
|
5602
|
+
|
|
5530
5603
|
async lookupRun(input: unknown): Promise<ClientRunLookupV1> {
|
|
5531
5604
|
const query = decodeClientRunLookupQueryV1(input);
|
|
5532
5605
|
return projectClientRunLookupV1(await this.authority.readRun(query.runId));
|
|
@@ -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,7 +1227,33 @@ 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>
|
|
1244
|
+
<!--
|
|
1245
|
+
Start a new conversation. Sits beside the composer because that is
|
|
1246
|
+
where you are when you decide the last one is finished. Disabled
|
|
1247
|
+
while a Turn is running: the Bot is still writing to it.
|
|
1248
|
+
-->
|
|
1249
|
+
<UiIconButton
|
|
1250
|
+
icon="plus"
|
|
1251
|
+
label="New conversation"
|
|
1252
|
+
variant="ghost"
|
|
1253
|
+
class="new-conversation-button"
|
|
1254
|
+
:disabled="isRunning"
|
|
1255
|
+
@click="web.startConversation()"
|
|
1256
|
+
/>
|
|
1192
1257
|
<UiIconButton
|
|
1193
1258
|
v-if="showStop"
|
|
1194
1259
|
class="stop-button"
|
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,
|