@frockbot/plugin-shell 0.3.6 → 0.3.7
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 +43 -2
- package/src/client/FrockBotApp.vue +13 -0
- package/src/client/index.ts +78 -14
- package/src/connection-return.test.ts +55 -0
- package/src/history.test.ts +52 -0
- package/src/history.ts +79 -1
- package/src/run-protocol.ts +97 -2
- package/src/shared.ts +65 -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.7",
|
|
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.7",
|
|
51
|
+
"@frockbot/application-foundation": "0.3.7",
|
|
52
|
+
"@frockbot/catalog-core": "0.3.7",
|
|
53
|
+
"@frockbot/client-core": "0.3.7",
|
|
54
|
+
"@frockbot/client-ui": "0.3.7",
|
|
55
|
+
"@frockbot/computer-core": "0.3.7",
|
|
56
|
+
"@frockbot/computer-host-protocol": "0.3.7",
|
|
57
|
+
"@frockbot/configuration-core": "0.3.7",
|
|
58
|
+
"@frockbot/connection-core": "0.3.7",
|
|
59
|
+
"@frockbot/kernel-agent-loop": "0.3.7",
|
|
60
|
+
"@frockbot/kernel-composition": "0.3.7",
|
|
61
|
+
"@frockbot/kernel-contracts": "0.3.7",
|
|
62
|
+
"@frockbot/kernel-do": "0.3.7",
|
|
63
|
+
"@frockbot/machine-protocol": "0.3.7",
|
|
64
|
+
"@frockbot/plugin-applets": "0.3.7",
|
|
65
|
+
"@frockbot/plugin-authoring": "0.3.7",
|
|
66
|
+
"@frockbot/plugin-bot-template": "0.3.7",
|
|
67
|
+
"@frockbot/plugin-computer": "0.3.7",
|
|
68
|
+
"@frockbot/plugin-flock": "0.3.7",
|
|
69
|
+
"@frockbot/plugin-image": "0.3.7",
|
|
70
|
+
"@frockbot/plugin-machine-messages": "0.3.7",
|
|
71
|
+
"@frockbot/plugin-mcp": "0.3.7",
|
|
72
|
+
"@frockbot/plugin-memory": "0.3.7",
|
|
73
|
+
"@frockbot/plugin-package-catalog": "0.3.7",
|
|
74
|
+
"@frockbot/plugin-routines": "0.3.7",
|
|
75
|
+
"@frockbot/plugin-skills": "0.3.7",
|
|
76
|
+
"@frockbot/plugin-subagents": "0.3.7",
|
|
77
|
+
"@frockbot/plugin-user-machine": "0.3.7",
|
|
78
|
+
"@frockbot/protocol": "0.3.7",
|
|
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.7",
|
|
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,
|
|
@@ -5443,6 +5444,15 @@ export class ShellBotBackendContribution {
|
|
|
5443
5444
|
): Promise<ClientRunListV1> {
|
|
5444
5445
|
const query = decodeClientRunListQueryV1(input);
|
|
5445
5446
|
await this.authority.recoverActiveRun();
|
|
5447
|
+
// The transcript is one conversation, not every Turn the Bot has ever
|
|
5448
|
+
// run. Absent means the conversation the Bot is on; naming an earlier one
|
|
5449
|
+
// reads it exactly as it was left. A Bot whose object has not learned its
|
|
5450
|
+
// identity yet has no conversation to filter by and shows what it has.
|
|
5451
|
+
const conversationId =
|
|
5452
|
+
query.conversationId ??
|
|
5453
|
+
(await this.authority.readConversationSessionId());
|
|
5454
|
+
const inConversation = (run: { sessionId: string }) =>
|
|
5455
|
+
conversationId === undefined || run.sessionId === conversationId;
|
|
5446
5456
|
const activeRunId = query.before
|
|
5447
5457
|
? undefined
|
|
5448
5458
|
: await this.authority.readActiveRunId();
|
|
@@ -5457,7 +5467,7 @@ export class ShellBotBackendContribution {
|
|
|
5457
5467
|
// An automation firing occupies the object like any other run, and is
|
|
5458
5468
|
// still not part of the conversation: the visible transcript never
|
|
5459
5469
|
// shows one, running or settled.
|
|
5460
|
-
if (active && isVisibleRunV1(active))
|
|
5470
|
+
if (active && isVisibleRunV1(active) && inConversation(active))
|
|
5461
5471
|
selected.set(active.runId, {
|
|
5462
5472
|
run: projectClientRunOrDegradedV1(active),
|
|
5463
5473
|
});
|
|
@@ -5471,7 +5481,8 @@ export class ShellBotBackendContribution {
|
|
|
5471
5481
|
continue;
|
|
5472
5482
|
}
|
|
5473
5483
|
const stored = await this.authority.readStoredRun(candidate.runId);
|
|
5474
|
-
if (!stored || !isVisibleRunV1(stored))
|
|
5484
|
+
if (!stored || !isVisibleRunV1(stored) || !inConversation(stored))
|
|
5485
|
+
continue;
|
|
5475
5486
|
const projected = projectClientRunOrDegradedV1(stored);
|
|
5476
5487
|
const tentative = [
|
|
5477
5488
|
...selected.values(),
|
|
@@ -5527,6 +5538,36 @@ export class ShellBotBackendContribution {
|
|
|
5527
5538
|
}
|
|
5528
5539
|
return page;
|
|
5529
5540
|
}
|
|
5541
|
+
/** The conversations this Bot has had, newest first. */
|
|
5542
|
+
async listConversations(): Promise<ClientConversationListV1> {
|
|
5543
|
+
return {
|
|
5544
|
+
schemaVersion: 1,
|
|
5545
|
+
conversations: (await this.authority.listConversations()).map(
|
|
5546
|
+
(conversation) => ({
|
|
5547
|
+
schemaVersion: 1 as const,
|
|
5548
|
+
conversationId: conversation.sessionId,
|
|
5549
|
+
ordinal: conversation.ordinal,
|
|
5550
|
+
startedAt: conversation.startedAt,
|
|
5551
|
+
...(conversation.endedAt ? { endedAt: conversation.endedAt } : {}),
|
|
5552
|
+
}),
|
|
5553
|
+
),
|
|
5554
|
+
};
|
|
5555
|
+
}
|
|
5556
|
+
|
|
5557
|
+
/**
|
|
5558
|
+
* Puts this conversation down and starts the next one.
|
|
5559
|
+
*
|
|
5560
|
+
* Memory is untouched: it is not conversation history, and the point of a
|
|
5561
|
+
* new conversation is to prove that it is not.
|
|
5562
|
+
*/
|
|
5563
|
+
async startConversation(
|
|
5564
|
+
identity: BotIdentity,
|
|
5565
|
+
): Promise<ClientConversationListV1> {
|
|
5566
|
+
await this.validateIdentity(identity);
|
|
5567
|
+
await this.authority.startConversation(identity);
|
|
5568
|
+
return this.listConversations();
|
|
5569
|
+
}
|
|
5570
|
+
|
|
5530
5571
|
async lookupRun(input: unknown): Promise<ClientRunLookupV1> {
|
|
5531
5572
|
const query = decodeClientRunLookupQueryV1(input);
|
|
5532
5573
|
return projectClientRunLookupV1(await this.authority.readRun(query.runId));
|
|
@@ -1189,6 +1189,19 @@ function handleComposerKeydown(event: KeyboardEvent): void {
|
|
|
1189
1189
|
@blur="closeSkillPopover"
|
|
1190
1190
|
/>
|
|
1191
1191
|
</div>
|
|
1192
|
+
<!--
|
|
1193
|
+
Start a new conversation. Sits beside the composer because that is
|
|
1194
|
+
where you are when you decide the last one is finished. Disabled
|
|
1195
|
+
while a Turn is running: the Bot is still writing to it.
|
|
1196
|
+
-->
|
|
1197
|
+
<UiIconButton
|
|
1198
|
+
icon="plus"
|
|
1199
|
+
label="New conversation"
|
|
1200
|
+
variant="ghost"
|
|
1201
|
+
class="new-conversation-button"
|
|
1202
|
+
:disabled="isRunning"
|
|
1203
|
+
@click="web.startConversation()"
|
|
1204
|
+
/>
|
|
1192
1205
|
<UiIconButton
|
|
1193
1206
|
v-if="showStop"
|
|
1194
1207
|
class="stop-button"
|
package/src/client/index.ts
CHANGED
|
@@ -70,6 +70,8 @@ import { defineComponent, h, ref, toRaw, watch, type Ref } from "vue";
|
|
|
70
70
|
import {
|
|
71
71
|
frockBotWebDataKey,
|
|
72
72
|
type FrockBotWebData,
|
|
73
|
+
decodeConnectionReturnV1,
|
|
74
|
+
withoutConnectionReturnV1,
|
|
73
75
|
type PluginCatalogItem,
|
|
74
76
|
type SendPromptResult,
|
|
75
77
|
type WebActiveRun,
|
|
@@ -862,6 +864,15 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
862
864
|
let admissionObserver: AbortController | undefined;
|
|
863
865
|
let runObserver: AbortController | undefined;
|
|
864
866
|
let selectionGeneration = 0;
|
|
867
|
+
/*
|
|
868
|
+
* Which conversation the transcript is showing.
|
|
869
|
+
*
|
|
870
|
+
* A read that was already in flight when the User starts a new conversation
|
|
871
|
+
* answers with the conversation that just ended, and projecting it puts the
|
|
872
|
+
* old Turns back on a transcript the User has just been told is empty. The
|
|
873
|
+
* epoch is bumped at the boundary so those answers are dropped.
|
|
874
|
+
*/
|
|
875
|
+
let conversationGeneration = 0;
|
|
865
876
|
let userSettingsGeneration = 0;
|
|
866
877
|
let pluginCatalogGeneration = 0;
|
|
867
878
|
let packageCatalogGeneration = 0;
|
|
@@ -1028,6 +1039,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1028
1039
|
if (!ctx.transport.lookupRun) return;
|
|
1029
1040
|
let delayMs = 250;
|
|
1030
1041
|
let observationError: string | undefined;
|
|
1042
|
+
const conversation = conversationGeneration;
|
|
1031
1043
|
while (!signal.aborted) {
|
|
1032
1044
|
try {
|
|
1033
1045
|
const run = await observeWhileAttached(
|
|
@@ -1037,6 +1049,9 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1037
1049
|
if (
|
|
1038
1050
|
signal.aborted ||
|
|
1039
1051
|
generation !== selectionGeneration ||
|
|
1052
|
+
// The Turn belongs to the conversation it was sent in, so a new one
|
|
1053
|
+
// ends the observation rather than drawing it on an empty thread.
|
|
1054
|
+
conversation !== conversationGeneration ||
|
|
1040
1055
|
web.value.activeBotId !== botId
|
|
1041
1056
|
) {
|
|
1042
1057
|
return;
|
|
@@ -1064,15 +1079,18 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1064
1079
|
botId: string,
|
|
1065
1080
|
generation = selectionGeneration,
|
|
1066
1081
|
): Promise<void> {
|
|
1082
|
+
const conversation = conversationGeneration;
|
|
1083
|
+
const current = () =>
|
|
1084
|
+
generation === selectionGeneration &&
|
|
1085
|
+
conversation === conversationGeneration &&
|
|
1086
|
+
web.value.activeBotId === botId;
|
|
1067
1087
|
const runs = await (ctx.transport.listRuns?.(botId) ?? Promise.resolve([]));
|
|
1068
|
-
if (
|
|
1069
|
-
return;
|
|
1088
|
+
if (!current()) return;
|
|
1070
1089
|
projectDurableRuns(web.value, [], runs);
|
|
1071
1090
|
try {
|
|
1072
1091
|
const announcements = await (ctx.transport.listAnnouncements?.(botId) ??
|
|
1073
1092
|
Promise.resolve([]));
|
|
1074
|
-
if (
|
|
1075
|
-
projectAnnouncements(web.value.messages, announcements);
|
|
1093
|
+
if (current()) projectAnnouncements(web.value.messages, announcements);
|
|
1076
1094
|
} catch {
|
|
1077
1095
|
// Announcements are conversational history, never admission: a Session
|
|
1078
1096
|
// that cannot read them still shows every Turn.
|
|
@@ -1081,17 +1099,14 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1081
1099
|
try {
|
|
1082
1100
|
notifications = await (ctx.transport.listNotifications?.(botId) ??
|
|
1083
1101
|
Promise.resolve([]));
|
|
1084
|
-
if (
|
|
1085
|
-
return;
|
|
1102
|
+
if (!current()) return;
|
|
1086
1103
|
} catch (error) {
|
|
1087
|
-
if (
|
|
1088
|
-
return;
|
|
1104
|
+
if (!current()) return;
|
|
1089
1105
|
web.value.settingsError =
|
|
1090
1106
|
error instanceof Error ? error.message : "Could not load notifications";
|
|
1091
1107
|
return;
|
|
1092
1108
|
}
|
|
1093
|
-
if (
|
|
1094
|
-
return;
|
|
1109
|
+
if (!current()) return;
|
|
1095
1110
|
const projected = projectDurableRuns(web.value, notifications, runs);
|
|
1096
1111
|
// A decision may have been recorded on another device since the last poll,
|
|
1097
1112
|
// and an expiry is recorded by an alarm nobody clicked.
|
|
@@ -1099,12 +1114,10 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1099
1114
|
// A background subagent settles after its Turn is over, so the chips in
|
|
1100
1115
|
// the transcript learn what became of it here and not from the run.
|
|
1101
1116
|
await web.value.loadTasks();
|
|
1102
|
-
if (
|
|
1103
|
-
return;
|
|
1117
|
+
if (!current()) return;
|
|
1104
1118
|
if (!ctx.transport.acknowledgeNotification) return;
|
|
1105
1119
|
for (const notification of notifications) {
|
|
1106
|
-
if (
|
|
1107
|
-
return;
|
|
1120
|
+
if (!current()) return;
|
|
1108
1121
|
if (!projected.has(notification.notificationId)) {
|
|
1109
1122
|
web.value.settingsError = "A completed Bot result is waiting to load";
|
|
1110
1123
|
continue;
|
|
@@ -1237,8 +1250,24 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1237
1250
|
): Promise<void>;
|
|
1238
1251
|
};
|
|
1239
1252
|
|
|
1253
|
+
// Read once, from the URL the authorization redirect landed on, and then
|
|
1254
|
+
// stripped so a reload does not report the same return again.
|
|
1255
|
+
const connectionReturn =
|
|
1256
|
+
typeof window === "undefined"
|
|
1257
|
+
? undefined
|
|
1258
|
+
: decodeConnectionReturnV1(window.location.search);
|
|
1259
|
+
if (connectionReturn && typeof window !== "undefined") {
|
|
1260
|
+
const rest = withoutConnectionReturnV1(window.location.search);
|
|
1261
|
+
window.history?.replaceState?.(
|
|
1262
|
+
window.history.state,
|
|
1263
|
+
"",
|
|
1264
|
+
`${window.location.pathname}${rest}${window.location.hash}`,
|
|
1265
|
+
);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1240
1268
|
const web: Ref<ShellWebData> = ref({
|
|
1241
1269
|
connection: "ready",
|
|
1270
|
+
...(connectionReturn ? { connectionReturn } : {}),
|
|
1242
1271
|
modelLabel: "No model available — set one up in Models",
|
|
1243
1272
|
modelReady: false,
|
|
1244
1273
|
modelSource: "none",
|
|
@@ -1304,6 +1333,41 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1304
1333
|
}
|
|
1305
1334
|
await web.value.loadBotSettings();
|
|
1306
1335
|
},
|
|
1336
|
+
/**
|
|
1337
|
+
* Puts this conversation down and starts the next one.
|
|
1338
|
+
*
|
|
1339
|
+
* What the Bot knows about you is Memory and stays; what it carries into
|
|
1340
|
+
* the next model request is the new conversation and nothing else. The
|
|
1341
|
+
* transcript clears because it is showing the conversation, and the one
|
|
1342
|
+
* just ended is still durable behind it.
|
|
1343
|
+
*/
|
|
1344
|
+
async startConversation(): Promise<void> {
|
|
1345
|
+
const start = ctx.transport.startConversation;
|
|
1346
|
+
const botId = web.value.activeBotId;
|
|
1347
|
+
if (!start || !botId) return;
|
|
1348
|
+
const generation = selectionGeneration;
|
|
1349
|
+
try {
|
|
1350
|
+
await start(botId);
|
|
1351
|
+
} catch (error) {
|
|
1352
|
+
web.value.settingsError =
|
|
1353
|
+
error instanceof Error
|
|
1354
|
+
? error.message
|
|
1355
|
+
: "Could not start a new conversation";
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
if (generation !== selectionGeneration || web.value.activeBotId !== botId)
|
|
1359
|
+
return;
|
|
1360
|
+
// Reads already in flight answer with the conversation that just ended;
|
|
1361
|
+
// the epoch drops them instead of letting them redraw it.
|
|
1362
|
+
conversationGeneration += 1;
|
|
1363
|
+
runObserver?.abort();
|
|
1364
|
+
runObserver = undefined;
|
|
1365
|
+
web.value.messages = [];
|
|
1366
|
+
web.value.activeRun = undefined;
|
|
1367
|
+
web.value.activeRunId = undefined;
|
|
1368
|
+
web.value.runningRunId = undefined;
|
|
1369
|
+
web.value.settingsError = undefined;
|
|
1370
|
+
},
|
|
1307
1371
|
async loadSkillCatalog(): Promise<void> {
|
|
1308
1372
|
// A missing transport method or an unreadable catalog is an empty
|
|
1309
1373
|
// popover, never a visible error: a Skill list the User did not ask for
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
decodeConnectionReturnV1,
|
|
4
|
+
withoutConnectionReturnV1,
|
|
5
|
+
} from "./shared.js";
|
|
6
|
+
|
|
7
|
+
describe("authorization return parameter", () => {
|
|
8
|
+
test("reads the status the callback redirected with", () => {
|
|
9
|
+
expect(decodeConnectionReturnV1("?connection=composio-ready")).toEqual({
|
|
10
|
+
packageId: "composio",
|
|
11
|
+
status: "ready",
|
|
12
|
+
});
|
|
13
|
+
expect(decodeConnectionReturnV1("?connection=composio-pending")).toEqual({
|
|
14
|
+
packageId: "composio",
|
|
15
|
+
status: "pending",
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("carries the reason a failed grant came back with", () => {
|
|
20
|
+
expect(
|
|
21
|
+
decodeConnectionReturnV1(
|
|
22
|
+
"?connection=composio-failed&connection_reason=state%20has%20expired",
|
|
23
|
+
),
|
|
24
|
+
).toEqual({
|
|
25
|
+
packageId: "composio",
|
|
26
|
+
status: "failed",
|
|
27
|
+
reason: "state has expired",
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("ignores a query string that carries no return", () => {
|
|
32
|
+
expect(decodeConnectionReturnV1("")).toBeUndefined();
|
|
33
|
+
expect(decodeConnectionReturnV1("?as_user=someone")).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("refuses a malformed or unknown return", () => {
|
|
37
|
+
expect(decodeConnectionReturnV1("?connection=composio")).toBeUndefined();
|
|
38
|
+
expect(
|
|
39
|
+
decodeConnectionReturnV1("?connection=composio-elsewhere"),
|
|
40
|
+
).toBeUndefined();
|
|
41
|
+
expect(decodeConnectionReturnV1("?connection=-ready")).toBeUndefined();
|
|
42
|
+
expect(
|
|
43
|
+
decodeConnectionReturnV1("?connection=Not%20A%20Package-ready"),
|
|
44
|
+
).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("strips the return parameters and keeps the rest", () => {
|
|
48
|
+
expect(
|
|
49
|
+
withoutConnectionReturnV1(
|
|
50
|
+
"?as_user=someone&connection=composio-failed&connection_reason=nope",
|
|
51
|
+
),
|
|
52
|
+
).toBe("?as_user=someone");
|
|
53
|
+
expect(withoutConnectionReturnV1("?connection=composio-ready")).toBe("");
|
|
54
|
+
});
|
|
55
|
+
});
|
package/src/history.test.ts
CHANGED
|
@@ -78,6 +78,58 @@ function scoped(events: SessionEvent[]): LlmMessage[] {
|
|
|
78
78
|
});
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
function scopedWithBudget(events: SessionEvent[], budget: number) {
|
|
82
|
+
return turnScopedMessagesV1({
|
|
83
|
+
events,
|
|
84
|
+
messages: derive(events),
|
|
85
|
+
pointer: automationParentPointerV1,
|
|
86
|
+
sessionId: "bot:scout",
|
|
87
|
+
budget,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe("one request carries a bounded amount of history", () => {
|
|
92
|
+
test("keeps the current Turn whole and drops the oldest, with a notice", () => {
|
|
93
|
+
const events = log([
|
|
94
|
+
...turn(1, "chat", "the oldest thing", "first reply"),
|
|
95
|
+
...turn(2, "chat", "the middle thing", "second reply"),
|
|
96
|
+
...turn(3, "chat", "the newest thing", ""),
|
|
97
|
+
]);
|
|
98
|
+
// Room for the current Turn and one older one, not for all three.
|
|
99
|
+
const messages = scopedWithBudget(events, 200);
|
|
100
|
+
|
|
101
|
+
const contents = messages.map((message) => message.content);
|
|
102
|
+
expect(contents).toContain("the newest thing");
|
|
103
|
+
expect(contents.join(" ")).not.toContain("the oldest thing");
|
|
104
|
+
expect(contents[0]).toContain("not included here");
|
|
105
|
+
expect(contents[0]).toContain("1 Turn");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("carries everything when it fits, and says nothing about omission", () => {
|
|
109
|
+
const events = log([
|
|
110
|
+
...turn(1, "chat", "morning", "hello"),
|
|
111
|
+
...turn(2, "chat", "anything new?", ""),
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
expect(
|
|
115
|
+
scopedWithBudget(events, 100_000).map((message) => message.content),
|
|
116
|
+
).toEqual(["morning", "hello", "anything new?", ""]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("keeps the current Turn even when it alone exceeds the budget", () => {
|
|
120
|
+
const events = log([
|
|
121
|
+
...turn(1, "chat", "old", "older"),
|
|
122
|
+
...turn(2, "chat", "x".repeat(500), ""),
|
|
123
|
+
]);
|
|
124
|
+
const messages = scopedWithBudget(events, 50);
|
|
125
|
+
|
|
126
|
+
// A Turn is never split: dropping the user message and keeping the reply
|
|
127
|
+
// would be a malformed request, so the current Turn survives whole.
|
|
128
|
+
expect(messages.at(-2)!.content).toBe("x".repeat(500));
|
|
129
|
+
expect(messages[0]!.content).toContain("not included here");
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
81
133
|
describe("turn-scoped prompt history", () => {
|
|
82
134
|
test("a chat Turn sees only the Turns admitted as chat", () => {
|
|
83
135
|
const events = log([
|
package/src/history.ts
CHANGED
|
@@ -68,6 +68,76 @@ export interface TurnScopedMessagesInputV1 {
|
|
|
68
68
|
/** The parent-transcript pointer, used only on a non-chat Turn. */
|
|
69
69
|
pointer(input: { sessionId: string; chatTurns: number }): string;
|
|
70
70
|
sessionId: string;
|
|
71
|
+
/**
|
|
72
|
+
* How many characters of history one request may carry. The current Turn is
|
|
73
|
+
* always whole; older Turns fill what is left. Absent means the default.
|
|
74
|
+
*/
|
|
75
|
+
budget?: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* How much conversation one model request carries.
|
|
80
|
+
*
|
|
81
|
+
* A number in characters, not tokens: this is a Package policy bound whose job
|
|
82
|
+
* is to stop a request growing without limit, and it does not need to agree
|
|
83
|
+
* with any provider's tokenizer to do that. Roughly 150k characters is well
|
|
84
|
+
* inside every model FrockBot resolves today while being far more history than
|
|
85
|
+
* any conversation needs.
|
|
86
|
+
*/
|
|
87
|
+
export const CHAT_HISTORY_BUDGET_CHARS_V1 = 150_000;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The line that stands where the dropped Turns were.
|
|
91
|
+
*
|
|
92
|
+
* It is said plainly, because a model that cannot see the beginning of a
|
|
93
|
+
* conversation and is not told so will confidently answer as though it had.
|
|
94
|
+
*/
|
|
95
|
+
export function omittedHistoryNoticeV1(turns: number): string {
|
|
96
|
+
return `Earlier in this conversation there ${turns === 1 ? "was 1 Turn" : `were ${turns} Turns`} that are not included here. They are not summarised: if you need something from them, say so or search your memory rather than guessing.`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function messageChars(message: LlmMessage): number {
|
|
100
|
+
return JSON.stringify(message).length;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Narrows history to a character budget, oldest Turns first.
|
|
105
|
+
*
|
|
106
|
+
* Eviction is by whole Turn on purpose. A tool result whose call has been
|
|
107
|
+
* dropped is a malformed request to every provider, and a Turn is the
|
|
108
|
+
* smallest unit that always holds both.
|
|
109
|
+
*/
|
|
110
|
+
function budgetedMessagesV1(
|
|
111
|
+
messages: readonly LlmMessage[],
|
|
112
|
+
turns: readonly number[],
|
|
113
|
+
current: number,
|
|
114
|
+
budget: number,
|
|
115
|
+
): LlmMessage[] {
|
|
116
|
+
const total = messages.reduce(
|
|
117
|
+
(sum, message) => sum + messageChars(message),
|
|
118
|
+
0,
|
|
119
|
+
);
|
|
120
|
+
if (total <= budget) return [...messages];
|
|
121
|
+
const spendByTurn = new Map<number, number>();
|
|
122
|
+
for (const [index, message] of messages.entries()) {
|
|
123
|
+
const turn = turns[index]!;
|
|
124
|
+
spendByTurn.set(turn, (spendByTurn.get(turn) ?? 0) + messageChars(message));
|
|
125
|
+
}
|
|
126
|
+
const ordered = [...spendByTurn.keys()].sort((left, right) => right - left);
|
|
127
|
+
const kept = new Set<number>([current]);
|
|
128
|
+
let spent = spendByTurn.get(current) ?? 0;
|
|
129
|
+
for (const turn of ordered) {
|
|
130
|
+
if (turn === current) continue;
|
|
131
|
+
const cost = spendByTurn.get(turn) ?? 0;
|
|
132
|
+
if (spent + cost > budget) break;
|
|
133
|
+
kept.add(turn);
|
|
134
|
+
spent += cost;
|
|
135
|
+
}
|
|
136
|
+
const dropped = ordered.filter((turn) => !kept.has(turn)).length;
|
|
137
|
+
const narrowed = messages.filter((_, index) => kept.has(turns[index]!));
|
|
138
|
+
return dropped === 0
|
|
139
|
+
? narrowed
|
|
140
|
+
: [{ role: "user", content: omittedHistoryNoticeV1(dropped) }, ...narrowed];
|
|
71
141
|
}
|
|
72
142
|
|
|
73
143
|
/**
|
|
@@ -92,7 +162,15 @@ export function turnScopedMessagesV1(
|
|
|
92
162
|
const current = currentTurnV1(input.events);
|
|
93
163
|
const chatTurn = (turn: number) => (types.get(turn) ?? "chat") === "chat";
|
|
94
164
|
if (chatTurn(current)) {
|
|
95
|
-
|
|
165
|
+
const conversation = input.messages.filter((_, index) =>
|
|
166
|
+
chatTurn(turns[index]!),
|
|
167
|
+
);
|
|
168
|
+
return budgetedMessagesV1(
|
|
169
|
+
conversation,
|
|
170
|
+
turns.filter((turn) => chatTurn(turn)),
|
|
171
|
+
current,
|
|
172
|
+
input.budget ?? CHAT_HISTORY_BUDGET_CHARS_V1,
|
|
173
|
+
);
|
|
96
174
|
}
|
|
97
175
|
const own = input.messages.filter((_, index) => turns[index] === current);
|
|
98
176
|
const chatTurns = new Set(
|
package/src/run-protocol.ts
CHANGED
|
@@ -36,6 +36,8 @@ const MAX_NOTIFICATION_TITLE_BYTES = 512;
|
|
|
36
36
|
const MAX_NOTIFICATION_BODY_BYTES = 2_000;
|
|
37
37
|
const MAX_CLIENT_TURN_BYTES = 256_000;
|
|
38
38
|
const MAX_CURSOR_LENGTH = 320;
|
|
39
|
+
/** A conversation is named by its Session id, which the kernel bounds. */
|
|
40
|
+
const MAX_SESSION_ID_LENGTH = 320;
|
|
39
41
|
const MAX_TASK_DESCRIPTION_BYTES = 800;
|
|
40
42
|
const MAX_TASK_MODEL_BYTES = 512;
|
|
41
43
|
export const CLIENT_RUN_PAGE_LIMIT = 32;
|
|
@@ -251,9 +253,85 @@ export interface ClientRunListV1 {
|
|
|
251
253
|
announcements?: ClientAnnouncementV1[];
|
|
252
254
|
}
|
|
253
255
|
|
|
256
|
+
/**
|
|
257
|
+
* One conversation a Bot has had.
|
|
258
|
+
*
|
|
259
|
+
* A Bot holds one conversation at a time and keeps the ones before it: the
|
|
260
|
+
* transcript shows the current one, and an earlier one is still readable.
|
|
261
|
+
*/
|
|
262
|
+
export interface ClientConversationV1 {
|
|
263
|
+
schemaVersion: 1;
|
|
264
|
+
/** The Session id this conversation's Turns recorded. */
|
|
265
|
+
conversationId: string;
|
|
266
|
+
ordinal: number;
|
|
267
|
+
startedAt: string;
|
|
268
|
+
/** Absent while this is the conversation the Bot is on. */
|
|
269
|
+
endedAt?: string;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export interface ClientConversationListV1 {
|
|
273
|
+
schemaVersion: 1;
|
|
274
|
+
/** Newest first; the first entry is the conversation the Bot is on. */
|
|
275
|
+
conversations: ClientConversationV1[];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function decodeClientConversationListV1(
|
|
279
|
+
input: unknown,
|
|
280
|
+
): ClientConversationListV1 {
|
|
281
|
+
const list = record(input, "conversation list");
|
|
282
|
+
exactKeys(list, ["schemaVersion", "conversations"], "conversation list");
|
|
283
|
+
if (list.schemaVersion !== 1) {
|
|
284
|
+
throw new Error("conversation list.schemaVersion is invalid");
|
|
285
|
+
}
|
|
286
|
+
if (!Array.isArray(list.conversations)) {
|
|
287
|
+
throw new Error("conversation list.conversations is invalid");
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
schemaVersion: 1,
|
|
291
|
+
conversations: list.conversations.map((entry) => {
|
|
292
|
+
const conversation = record(entry, "conversation");
|
|
293
|
+
exactKeys(
|
|
294
|
+
conversation,
|
|
295
|
+
["schemaVersion", "conversationId", "ordinal", "startedAt", "endedAt"],
|
|
296
|
+
"conversation",
|
|
297
|
+
);
|
|
298
|
+
if (conversation.schemaVersion !== 1) {
|
|
299
|
+
throw new Error("conversation.schemaVersion is invalid");
|
|
300
|
+
}
|
|
301
|
+
if (
|
|
302
|
+
typeof conversation.ordinal !== "number" ||
|
|
303
|
+
!Number.isSafeInteger(conversation.ordinal) ||
|
|
304
|
+
conversation.ordinal < 1
|
|
305
|
+
) {
|
|
306
|
+
throw new Error("conversation.ordinal is invalid");
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
schemaVersion: 1 as const,
|
|
310
|
+
conversationId: string(
|
|
311
|
+
conversation,
|
|
312
|
+
"conversationId",
|
|
313
|
+
MAX_SESSION_ID_LENGTH,
|
|
314
|
+
"conversation",
|
|
315
|
+
),
|
|
316
|
+
ordinal: conversation.ordinal,
|
|
317
|
+
startedAt: string(conversation, "startedAt", 64, "conversation"),
|
|
318
|
+
...(conversation.endedAt === undefined
|
|
319
|
+
? {}
|
|
320
|
+
: { endedAt: string(conversation, "endedAt", 64, "conversation") }),
|
|
321
|
+
};
|
|
322
|
+
}),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
254
326
|
export interface ClientRunListQueryV1 {
|
|
255
327
|
schemaVersion: 1;
|
|
256
328
|
before?: string;
|
|
329
|
+
/**
|
|
330
|
+
* The conversation to read. Absent means the one the Bot is on now, which
|
|
331
|
+
* is what the transcript shows; an earlier conversation is named by the
|
|
332
|
+
* Session id `listConversations` gave for it.
|
|
333
|
+
*/
|
|
334
|
+
conversationId?: string;
|
|
257
335
|
}
|
|
258
336
|
|
|
259
337
|
export interface ClientTurnCommandV1 {
|
|
@@ -1385,7 +1463,11 @@ export function decodeClientRunListQueryV1(
|
|
|
1385
1463
|
input: unknown,
|
|
1386
1464
|
): ClientRunListQueryV1 {
|
|
1387
1465
|
const query = record(input, "run list query");
|
|
1388
|
-
exactKeys(
|
|
1466
|
+
exactKeys(
|
|
1467
|
+
query,
|
|
1468
|
+
["schemaVersion", "before", "conversationId"],
|
|
1469
|
+
"run list query",
|
|
1470
|
+
);
|
|
1389
1471
|
if (query.schemaVersion !== 1) {
|
|
1390
1472
|
throw new Error("run list query.schemaVersion is invalid");
|
|
1391
1473
|
}
|
|
@@ -1400,7 +1482,20 @@ export function decodeClientRunListQueryV1(
|
|
|
1400
1482
|
throw new Error("run list query.before is invalid");
|
|
1401
1483
|
}
|
|
1402
1484
|
}
|
|
1403
|
-
|
|
1485
|
+
const conversationId =
|
|
1486
|
+
query.conversationId === undefined
|
|
1487
|
+
? undefined
|
|
1488
|
+
: string(
|
|
1489
|
+
query,
|
|
1490
|
+
"conversationId",
|
|
1491
|
+
MAX_SESSION_ID_LENGTH,
|
|
1492
|
+
"run list query",
|
|
1493
|
+
);
|
|
1494
|
+
return {
|
|
1495
|
+
schemaVersion: 1,
|
|
1496
|
+
...(before ? { before } : {}),
|
|
1497
|
+
...(conversationId ? { conversationId } : {}),
|
|
1498
|
+
};
|
|
1404
1499
|
}
|
|
1405
1500
|
|
|
1406
1501
|
export function decodeClientTurnCommandV1(input: unknown): ClientTurnCommandV1 {
|
package/src/shared.ts
CHANGED
|
@@ -161,6 +161,60 @@ export interface PluginCatalogItem {
|
|
|
161
161
|
settings?: PackageSettingDefinition[];
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/** What an external authorization redirect told the app on the way back. */
|
|
165
|
+
export interface ConnectionReturnV1 {
|
|
166
|
+
/** The Package that owns the Connection, e.g. `composio`. */
|
|
167
|
+
packageId: string;
|
|
168
|
+
status: "ready" | "pending" | "failed";
|
|
169
|
+
/** A provider- or callback-supplied explanation, when there is one. */
|
|
170
|
+
reason?: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const CONNECTION_RETURN_PARAM = "connection";
|
|
174
|
+
const CONNECTION_RETURN_REASON_PARAM = "connection_reason";
|
|
175
|
+
const MAX_CONNECTION_RETURN_REASON = 300;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Read an authorization return out of a URL query string.
|
|
179
|
+
*
|
|
180
|
+
* The callback redirects to `/?connection=<packageId>-<status>`. Left unread it
|
|
181
|
+
* is a stale query string and nothing else: the User is returned to the app
|
|
182
|
+
* with no confirmation, and a `failed` grant vanishes entirely.
|
|
183
|
+
*/
|
|
184
|
+
export function decodeConnectionReturnV1(
|
|
185
|
+
search: string,
|
|
186
|
+
): ConnectionReturnV1 | undefined {
|
|
187
|
+
let params: URLSearchParams;
|
|
188
|
+
try {
|
|
189
|
+
params = new URLSearchParams(search);
|
|
190
|
+
} catch {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
const raw = params.get(CONNECTION_RETURN_PARAM);
|
|
194
|
+
if (!raw) return undefined;
|
|
195
|
+
const separator = raw.lastIndexOf("-");
|
|
196
|
+
if (separator <= 0) return undefined;
|
|
197
|
+
const packageId = raw.slice(0, separator);
|
|
198
|
+
const status = raw.slice(separator + 1);
|
|
199
|
+
if (status !== "ready" && status !== "pending" && status !== "failed") {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(packageId)) return undefined;
|
|
203
|
+
const reason = params
|
|
204
|
+
.get(CONNECTION_RETURN_REASON_PARAM)
|
|
205
|
+
?.slice(0, MAX_CONNECTION_RETURN_REASON);
|
|
206
|
+
return { packageId, status, ...(reason ? { reason } : {}) };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The same query string with the return parameters removed. */
|
|
210
|
+
export function withoutConnectionReturnV1(search: string): string {
|
|
211
|
+
const params = new URLSearchParams(search);
|
|
212
|
+
params.delete(CONNECTION_RETURN_PARAM);
|
|
213
|
+
params.delete(CONNECTION_RETURN_REASON_PARAM);
|
|
214
|
+
const rest = params.toString();
|
|
215
|
+
return rest ? `?${rest}` : "";
|
|
216
|
+
}
|
|
217
|
+
|
|
164
218
|
export interface FrockBotWebData {
|
|
165
219
|
connection: WebConnection;
|
|
166
220
|
modelLabel: string;
|
|
@@ -261,6 +315,12 @@ export interface FrockBotWebData {
|
|
|
261
315
|
*/
|
|
262
316
|
mcpServers?: McpServerStatusViewV1;
|
|
263
317
|
settingsError?: string;
|
|
318
|
+
/**
|
|
319
|
+
* What the browser came back from an external authorization with. Read once
|
|
320
|
+
* from the return URL at boot and cleared when the User has seen it, so a
|
|
321
|
+
* cancelled or failed grant is reported rather than silently discarded.
|
|
322
|
+
*/
|
|
323
|
+
connectionReturn?: ConnectionReturnV1;
|
|
264
324
|
selectBot(botId: string): Promise<void>;
|
|
265
325
|
loadBotSettings(): Promise<void>;
|
|
266
326
|
saveBotProfile(profile: BotProfile): Promise<void>;
|
|
@@ -365,6 +425,11 @@ export interface FrockBotWebData {
|
|
|
365
425
|
values?: Record<string, JsonValue>,
|
|
366
426
|
): Promise<void>;
|
|
367
427
|
uninstallPackage(packageId: string): Promise<void>;
|
|
428
|
+
/**
|
|
429
|
+
* Puts this conversation down and starts the next one. Memory is kept; only
|
|
430
|
+
* the history the next Turn carries is new (ADR 0027).
|
|
431
|
+
*/
|
|
432
|
+
startConversation(): Promise<void>;
|
|
368
433
|
startConnection(
|
|
369
434
|
packageId: string,
|
|
370
435
|
connectionTypeId: string,
|