@frockbot/plugin-shell 0.3.5 → 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/frockbot.json +1 -1
- package/package.json +31 -31
- package/src/backend-authoring.test.ts +72 -0
- package/src/backend-authoring.ts +82 -16
- package/src/backend-configuration.test.ts +6 -4
- package/src/backend-package-catalog.test.ts +23 -3
- package/src/backend-package-catalog.ts +7 -1
- package/src/backend-recovery-integration.test.ts +25 -32
- package/src/backend.ts +71 -6
- package/src/client/AppletCanvas.vue +3 -5
- package/src/client/FrockBotApp.vue +176 -106
- package/src/client/PackageIframeHost.vue +15 -6
- package/src/client/index.test.ts +101 -12
- package/src/client/index.ts +191 -59
- package/src/client/model-presentation.test.ts +4 -2
- package/src/client/model-presentation.ts +2 -2
- package/src/client/styles.css +15 -1
- 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 +112 -5
- package/src/shared.ts +65 -0
package/src/client/index.ts
CHANGED
|
@@ -55,6 +55,7 @@ import { decodeStartConnectionResultV1 } from "@frockbot/connection-core";
|
|
|
55
55
|
import { decodeClientSkillCatalogV1 } from "../skill-protocol.js";
|
|
56
56
|
import {
|
|
57
57
|
ClientTurnRefusedErrorV1,
|
|
58
|
+
type ClientTurnRefusalReasonV1,
|
|
58
59
|
decodeClientTurnV1,
|
|
59
60
|
} from "../run-protocol.js";
|
|
60
61
|
import {
|
|
@@ -69,6 +70,8 @@ import { defineComponent, h, ref, toRaw, watch, type Ref } from "vue";
|
|
|
69
70
|
import {
|
|
70
71
|
frockBotWebDataKey,
|
|
71
72
|
type FrockBotWebData,
|
|
73
|
+
decodeConnectionReturnV1,
|
|
74
|
+
withoutConnectionReturnV1,
|
|
72
75
|
type PluginCatalogItem,
|
|
73
76
|
type SendPromptResult,
|
|
74
77
|
type WebActiveRun,
|
|
@@ -243,10 +246,8 @@ function activeRunView(run: ClientRun): WebActiveRun | undefined {
|
|
|
243
246
|
runId: run.runId,
|
|
244
247
|
status: run.status,
|
|
245
248
|
message: run.stopRequestedAt
|
|
246
|
-
? "
|
|
247
|
-
:
|
|
248
|
-
run.failure ??
|
|
249
|
-
"This Turn requires provider reconciliation before it can continue."),
|
|
249
|
+
? "Stopping…"
|
|
250
|
+
: "Something went wrong mid-reply. Try again to pick it up.",
|
|
250
251
|
// Offered whenever the run is parked, Stop included. Hiding it there
|
|
251
252
|
// hid it in exactly the case Stop creates: a Turn that was stopped
|
|
252
253
|
// while the model was mid-answer parks, and the person was left with a
|
|
@@ -257,6 +258,31 @@ function activeRunView(run: ClientRun): WebActiveRun | undefined {
|
|
|
257
258
|
return undefined;
|
|
258
259
|
}
|
|
259
260
|
|
|
261
|
+
/**
|
|
262
|
+
* What a refused send tells the person. The refusal's own `error` names the
|
|
263
|
+
* durable invariant that declined it, which the debug surface needs and the
|
|
264
|
+
* composer does not, so the typed reason picks the sentence instead.
|
|
265
|
+
*/
|
|
266
|
+
function turnRefusalCopyV1(reason: ClientTurnRefusalReasonV1): string {
|
|
267
|
+
if (reason === "busy")
|
|
268
|
+
return "This Bot is still working on your last message.";
|
|
269
|
+
if (reason === "reconciliation-required")
|
|
270
|
+
return "This Bot's last reply stopped partway. Try again to continue it.";
|
|
271
|
+
if (reason === "duplicate") return "That message was already sent.";
|
|
272
|
+
return "That message didn't go through. Try sending it again.";
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The Bot's voice is its sends. When a Turn delivered anything to the User the
|
|
277
|
+
* model's own assistant text is scratch space and the thread does not draw it
|
|
278
|
+
* (issue 153): drawing both is how a one-word reply arrived twice, once as the
|
|
279
|
+
* model's text and once as the bubble that was actually delivered.
|
|
280
|
+
*/
|
|
281
|
+
function visibleAssistantText(run: ClientRun, fallback = ""): string {
|
|
282
|
+
if (sendsFrom(run.events).length > 0) return "";
|
|
283
|
+
return run.responseText ?? fallback;
|
|
284
|
+
}
|
|
285
|
+
|
|
260
286
|
function isTerminalRun(run: ClientRun): boolean {
|
|
261
287
|
return (
|
|
262
288
|
run.status === "completed" ||
|
|
@@ -277,7 +303,7 @@ function assistantMessage(
|
|
|
277
303
|
id: `${run.runId}:assistant`,
|
|
278
304
|
runId: run.runId,
|
|
279
305
|
role: "assistant",
|
|
280
|
-
text: run
|
|
306
|
+
text: visibleAssistantText(run),
|
|
281
307
|
status: "streaming",
|
|
282
308
|
// A Turn that has not started shows nothing of its own: the greyed user
|
|
283
309
|
// message is the whole of what the thread says about it.
|
|
@@ -294,8 +320,8 @@ function assistantMessage(
|
|
|
294
320
|
id: `${run.runId}:assistant`,
|
|
295
321
|
runId: run.runId,
|
|
296
322
|
role: "assistant",
|
|
297
|
-
text: run
|
|
298
|
-
notice:
|
|
323
|
+
text: visibleAssistantText(run),
|
|
324
|
+
notice: "Interrupted by your next message.",
|
|
299
325
|
status: "aborted",
|
|
300
326
|
tools: toolsFrom(run.events),
|
|
301
327
|
sends: sendsFrom(run.events),
|
|
@@ -307,10 +333,7 @@ function assistantMessage(
|
|
|
307
333
|
id: `${run.runId}:assistant`,
|
|
308
334
|
runId: run.runId,
|
|
309
335
|
role: "assistant",
|
|
310
|
-
text:
|
|
311
|
-
run.recovery?.message ??
|
|
312
|
-
run.failure ??
|
|
313
|
-
"Provider reconciliation is required before this Turn can continue.",
|
|
336
|
+
text: "This reply stopped partway. Try again to continue it.",
|
|
314
337
|
status: "reconciliation-required",
|
|
315
338
|
tools: toolsFrom(run.events),
|
|
316
339
|
sends: sendsFrom(run.events),
|
|
@@ -322,22 +345,39 @@ function assistantMessage(
|
|
|
322
345
|
id: `${run.runId}:assistant`,
|
|
323
346
|
runId: run.runId,
|
|
324
347
|
role: "assistant",
|
|
325
|
-
text: run
|
|
326
|
-
notice:
|
|
348
|
+
text: visibleAssistantText(run),
|
|
349
|
+
notice: "You stopped this.",
|
|
327
350
|
status: "aborted",
|
|
328
351
|
tools: toolsFrom(run.events),
|
|
329
352
|
sends: sendsFrom(run.events),
|
|
330
353
|
tasks: tasksFrom(run.events),
|
|
331
354
|
};
|
|
332
355
|
}
|
|
356
|
+
// A Turn that broke after it had started talking keeps what it said, with
|
|
357
|
+
// the reason underneath it — the treatment a stopped Turn already gets, for
|
|
358
|
+
// the same reason: the words arrived and the person read them (ADR 0028).
|
|
359
|
+
// A Turn that broke before saying anything is still just the reason.
|
|
360
|
+
if (run.status === "failed" && run.responseText) {
|
|
361
|
+
return {
|
|
362
|
+
id: `${run.runId}:assistant`,
|
|
363
|
+
runId: run.runId,
|
|
364
|
+
role: "assistant",
|
|
365
|
+
text: run.responseText,
|
|
366
|
+
notice: run.failure ?? "Agent request failed.",
|
|
367
|
+
status: "error",
|
|
368
|
+
tools: toolsFrom(run.events),
|
|
369
|
+
sends: sendsFrom(run.events),
|
|
370
|
+
tasks: tasksFrom(run.events),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
333
373
|
return {
|
|
334
374
|
id: `${run.runId}:assistant`,
|
|
335
375
|
runId: run.runId,
|
|
336
376
|
role: "assistant",
|
|
337
377
|
text:
|
|
338
378
|
run.status === "failed"
|
|
339
|
-
?
|
|
340
|
-
: (run
|
|
379
|
+
? "This Bot couldn't finish its reply. Try again."
|
|
380
|
+
: visibleAssistantText(run, notification?.body ?? ""),
|
|
341
381
|
status: run.status === "failed" ? "error" : "completed",
|
|
342
382
|
tools: toolsFrom(run.events),
|
|
343
383
|
sends: sendsFrom(run.events),
|
|
@@ -429,8 +469,12 @@ export function projectDurableRuns(
|
|
|
429
469
|
activeRun = activeRunView(run) ?? activeRun;
|
|
430
470
|
if (run.status === "running" || run.status === "reconciliation-required") {
|
|
431
471
|
busyRunId = run.runId;
|
|
432
|
-
if (!run.queued) runningRunId = run.runId;
|
|
433
472
|
}
|
|
473
|
+
// Stop belongs to a Turn that is executing. A Turn parked on a
|
|
474
|
+
// reconciliation is busy but not running: there is nothing to stop, and
|
|
475
|
+
// offering it left a Stop button standing for good — across reloads,
|
|
476
|
+
// because the state it was keyed off never became terminal.
|
|
477
|
+
if (run.status === "running" && !run.queued) runningRunId = run.runId;
|
|
434
478
|
if (notification && isTerminalRun(run)) {
|
|
435
479
|
projected.add(notification.notificationId);
|
|
436
480
|
}
|
|
@@ -448,7 +492,13 @@ export function projectDurableRuns(
|
|
|
448
492
|
state.activeRunId = undefined;
|
|
449
493
|
}
|
|
450
494
|
if (runningRunId) state.runningRunId = runningRunId;
|
|
451
|
-
else if (
|
|
495
|
+
else if (
|
|
496
|
+
state.runningRunId &&
|
|
497
|
+
runs.some((run) => run.runId === state.runningRunId)
|
|
498
|
+
) {
|
|
499
|
+
// The channel is carrying this run and it is not executing, whatever it
|
|
500
|
+
// settled as. A run the list does not carry yet is the one this tab just
|
|
501
|
+
// submitted, which keeps its Stop.
|
|
452
502
|
state.runningRunId = undefined;
|
|
453
503
|
}
|
|
454
504
|
if (activeRun) state.activeRun = activeRun;
|
|
@@ -704,7 +754,7 @@ export function decodePluginCatalog(value: unknown): PluginCatalogItem[] {
|
|
|
704
754
|
!Array.isArray(value.packages) ||
|
|
705
755
|
value.packages.length > 256
|
|
706
756
|
) {
|
|
707
|
-
throw new Error("
|
|
757
|
+
throw new Error("FrockBot couldn't load this deployment. Reload the page.");
|
|
708
758
|
}
|
|
709
759
|
return value.packages.flatMap((candidate) => {
|
|
710
760
|
if (
|
|
@@ -737,7 +787,9 @@ export function decodePluginCatalog(value: unknown): PluginCatalogItem[] {
|
|
|
737
787
|
kind === "mobile",
|
|
738
788
|
)
|
|
739
789
|
) {
|
|
740
|
-
throw new Error(
|
|
790
|
+
throw new Error(
|
|
791
|
+
"FrockBot couldn't load this deployment. Reload the page.",
|
|
792
|
+
);
|
|
741
793
|
}
|
|
742
794
|
const decoded = decodeFrockBotManifest({
|
|
743
795
|
// v4, so a Capability carrying an admission ceiling decodes here too.
|
|
@@ -812,6 +864,15 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
812
864
|
let admissionObserver: AbortController | undefined;
|
|
813
865
|
let runObserver: AbortController | undefined;
|
|
814
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;
|
|
815
876
|
let userSettingsGeneration = 0;
|
|
816
877
|
let pluginCatalogGeneration = 0;
|
|
817
878
|
let packageCatalogGeneration = 0;
|
|
@@ -919,7 +980,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
919
980
|
web.value.activeRun = {
|
|
920
981
|
runId,
|
|
921
982
|
status: "running",
|
|
922
|
-
message: "
|
|
983
|
+
message: "Checking whether your message went through…",
|
|
923
984
|
canResume: false,
|
|
924
985
|
};
|
|
925
986
|
if (!ctx.transport.lookupRun || !ctx.transport.fenceRunAdmission) {
|
|
@@ -959,7 +1020,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
959
1020
|
reconciliationError = `${
|
|
960
1021
|
error instanceof Error
|
|
961
1022
|
? error.message
|
|
962
|
-
: "
|
|
1023
|
+
: "Couldn't check on your message."
|
|
963
1024
|
} Retrying…`;
|
|
964
1025
|
web.value.settingsError = reconciliationError;
|
|
965
1026
|
}
|
|
@@ -978,6 +1039,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
978
1039
|
if (!ctx.transport.lookupRun) return;
|
|
979
1040
|
let delayMs = 250;
|
|
980
1041
|
let observationError: string | undefined;
|
|
1042
|
+
const conversation = conversationGeneration;
|
|
981
1043
|
while (!signal.aborted) {
|
|
982
1044
|
try {
|
|
983
1045
|
const run = await observeWhileAttached(
|
|
@@ -987,11 +1049,14 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
987
1049
|
if (
|
|
988
1050
|
signal.aborted ||
|
|
989
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 ||
|
|
990
1055
|
web.value.activeBotId !== botId
|
|
991
1056
|
) {
|
|
992
1057
|
return;
|
|
993
1058
|
}
|
|
994
|
-
if (!run) throw new Error("
|
|
1059
|
+
if (!run) throw new Error("Couldn't load that reply.");
|
|
995
1060
|
if (web.value.settingsError === observationError) {
|
|
996
1061
|
web.value.settingsError = undefined;
|
|
997
1062
|
}
|
|
@@ -1001,7 +1066,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1001
1066
|
} catch (error) {
|
|
1002
1067
|
if (signal.aborted) return;
|
|
1003
1068
|
observationError = `${
|
|
1004
|
-
error instanceof Error ? error.message : "
|
|
1069
|
+
error instanceof Error ? error.message : "Couldn't load that reply."
|
|
1005
1070
|
} Retrying…`;
|
|
1006
1071
|
web.value.settingsError = observationError;
|
|
1007
1072
|
}
|
|
@@ -1014,15 +1079,18 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1014
1079
|
botId: string,
|
|
1015
1080
|
generation = selectionGeneration,
|
|
1016
1081
|
): Promise<void> {
|
|
1082
|
+
const conversation = conversationGeneration;
|
|
1083
|
+
const current = () =>
|
|
1084
|
+
generation === selectionGeneration &&
|
|
1085
|
+
conversation === conversationGeneration &&
|
|
1086
|
+
web.value.activeBotId === botId;
|
|
1017
1087
|
const runs = await (ctx.transport.listRuns?.(botId) ?? Promise.resolve([]));
|
|
1018
|
-
if (
|
|
1019
|
-
return;
|
|
1088
|
+
if (!current()) return;
|
|
1020
1089
|
projectDurableRuns(web.value, [], runs);
|
|
1021
1090
|
try {
|
|
1022
1091
|
const announcements = await (ctx.transport.listAnnouncements?.(botId) ??
|
|
1023
1092
|
Promise.resolve([]));
|
|
1024
|
-
if (
|
|
1025
|
-
projectAnnouncements(web.value.messages, announcements);
|
|
1093
|
+
if (current()) projectAnnouncements(web.value.messages, announcements);
|
|
1026
1094
|
} catch {
|
|
1027
1095
|
// Announcements are conversational history, never admission: a Session
|
|
1028
1096
|
// that cannot read them still shows every Turn.
|
|
@@ -1031,17 +1099,14 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1031
1099
|
try {
|
|
1032
1100
|
notifications = await (ctx.transport.listNotifications?.(botId) ??
|
|
1033
1101
|
Promise.resolve([]));
|
|
1034
|
-
if (
|
|
1035
|
-
return;
|
|
1102
|
+
if (!current()) return;
|
|
1036
1103
|
} catch (error) {
|
|
1037
|
-
if (
|
|
1038
|
-
return;
|
|
1104
|
+
if (!current()) return;
|
|
1039
1105
|
web.value.settingsError =
|
|
1040
1106
|
error instanceof Error ? error.message : "Could not load notifications";
|
|
1041
1107
|
return;
|
|
1042
1108
|
}
|
|
1043
|
-
if (
|
|
1044
|
-
return;
|
|
1109
|
+
if (!current()) return;
|
|
1045
1110
|
const projected = projectDurableRuns(web.value, notifications, runs);
|
|
1046
1111
|
// A decision may have been recorded on another device since the last poll,
|
|
1047
1112
|
// and an expiry is recorded by an alarm nobody clicked.
|
|
@@ -1049,12 +1114,10 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1049
1114
|
// A background subagent settles after its Turn is over, so the chips in
|
|
1050
1115
|
// the transcript learn what became of it here and not from the run.
|
|
1051
1116
|
await web.value.loadTasks();
|
|
1052
|
-
if (
|
|
1053
|
-
return;
|
|
1117
|
+
if (!current()) return;
|
|
1054
1118
|
if (!ctx.transport.acknowledgeNotification) return;
|
|
1055
1119
|
for (const notification of notifications) {
|
|
1056
|
-
if (
|
|
1057
|
-
return;
|
|
1120
|
+
if (!current()) return;
|
|
1058
1121
|
if (!projected.has(notification.notificationId)) {
|
|
1059
1122
|
web.value.settingsError = "A completed Bot result is waiting to load";
|
|
1060
1123
|
continue;
|
|
@@ -1187,9 +1250,25 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1187
1250
|
): Promise<void>;
|
|
1188
1251
|
};
|
|
1189
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
|
+
|
|
1190
1268
|
const web: Ref<ShellWebData> = ref({
|
|
1191
1269
|
connection: "ready",
|
|
1192
|
-
|
|
1270
|
+
...(connectionReturn ? { connectionReturn } : {}),
|
|
1271
|
+
modelLabel: "No model available — set one up in Models",
|
|
1193
1272
|
modelReady: false,
|
|
1194
1273
|
modelSource: "none",
|
|
1195
1274
|
settingsAvailable: true,
|
|
@@ -1254,6 +1333,41 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1254
1333
|
}
|
|
1255
1334
|
await web.value.loadBotSettings();
|
|
1256
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
|
+
},
|
|
1257
1371
|
async loadSkillCatalog(): Promise<void> {
|
|
1258
1372
|
// A missing transport method or an unreadable catalog is an empty
|
|
1259
1373
|
// popover, never a visible error: a Skill list the User did not ask for
|
|
@@ -1519,12 +1633,10 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1519
1633
|
const botId = web.value.activeBotId;
|
|
1520
1634
|
const catalog = web.value.packageUi;
|
|
1521
1635
|
if (!post || !botId || !catalog || catalog.botId !== botId) {
|
|
1522
|
-
throw new Error("
|
|
1636
|
+
throw new Error("That plugin's page isn't available.");
|
|
1523
1637
|
}
|
|
1524
1638
|
if (!packageIframeToolAllowedV1(contribution, name)) {
|
|
1525
|
-
throw new Error(
|
|
1526
|
-
`Package "${contribution.packageId}" did not declare tool "${name}"`,
|
|
1527
|
-
);
|
|
1639
|
+
throw new Error(`That plugin isn't allowed to use ${name}.`);
|
|
1528
1640
|
}
|
|
1529
1641
|
const turn = decodeClientTurnV1(
|
|
1530
1642
|
await post(
|
|
@@ -1951,9 +2063,22 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1951
2063
|
updateSettingsLoadError("package-catalog");
|
|
1952
2064
|
} catch (error) {
|
|
1953
2065
|
if (generation !== packageCatalogGeneration) return;
|
|
2066
|
+
// The gateway answers 404 `catalog generation was not found` when the
|
|
2067
|
+
// deployment has published no Catalog at all. That is a state, not a
|
|
2068
|
+
// fault, and the raw server sentence means nothing to a person — so it
|
|
2069
|
+
// is translated here and the surface renders it instead of the
|
|
2070
|
+
// "nothing matched your search" empty state.
|
|
2071
|
+
const raw =
|
|
2072
|
+
error instanceof Error ? error.message : "Could not load the Catalog";
|
|
2073
|
+
web.value.packageCatalog = [];
|
|
2074
|
+
web.value.packageCatalogGeneration = undefined;
|
|
1954
2075
|
updateSettingsLoadError(
|
|
1955
2076
|
"package-catalog",
|
|
1956
|
-
|
|
2077
|
+
/catalog generation was not found|Package Catalog is not configured/.test(
|
|
2078
|
+
raw,
|
|
2079
|
+
)
|
|
2080
|
+
? "No plugins are published for this deployment yet."
|
|
2081
|
+
: `Plugins could not be loaded: ${raw}`,
|
|
1957
2082
|
);
|
|
1958
2083
|
}
|
|
1959
2084
|
},
|
|
@@ -1983,7 +2108,8 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1983
2108
|
if (!settings || !ctx.transport.executeConfiguration) {
|
|
1984
2109
|
throw new Error("Plugins are unavailable");
|
|
1985
2110
|
}
|
|
1986
|
-
if (!generation)
|
|
2111
|
+
if (!generation)
|
|
2112
|
+
throw new Error("The catalog isn't loaded yet. Try again in a moment.");
|
|
1987
2113
|
const receipt = await ctx.transport.executeConfiguration({
|
|
1988
2114
|
schemaVersion: 1,
|
|
1989
2115
|
type: "user/install-package",
|
|
@@ -2266,8 +2392,8 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2266
2392
|
if (result.status !== "applied") {
|
|
2267
2393
|
throw new Error(
|
|
2268
2394
|
result.status === "reconciliation-required"
|
|
2269
|
-
? "
|
|
2270
|
-
: "
|
|
2395
|
+
? "Disconnecting didn't finish. Try again."
|
|
2396
|
+
: "Couldn't disconnect that account.",
|
|
2271
2397
|
);
|
|
2272
2398
|
}
|
|
2273
2399
|
},
|
|
@@ -2370,7 +2496,10 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2370
2496
|
id: `${result.runId}:assistant`,
|
|
2371
2497
|
runId: result.runId,
|
|
2372
2498
|
role: "assistant",
|
|
2373
|
-
|
|
2499
|
+
// The same rule the durable projection follows: a Turn that
|
|
2500
|
+
// delivered something speaks through its sends, not through the
|
|
2501
|
+
// model's own text (issue 153).
|
|
2502
|
+
text: sendsFrom(result.events).length > 0 ? "" : result.text,
|
|
2374
2503
|
at: optimisticAt,
|
|
2375
2504
|
status: "completed",
|
|
2376
2505
|
tools: toolsFrom(result.events),
|
|
@@ -2401,8 +2530,9 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2401
2530
|
// that was never admitted only threw the reason away.
|
|
2402
2531
|
if (error instanceof ClientTurnRefusedErrorV1) {
|
|
2403
2532
|
removeMessages(web.value.messages, pendingRunId);
|
|
2404
|
-
|
|
2405
|
-
|
|
2533
|
+
const refusal = turnRefusalCopyV1(error.refusal.reason);
|
|
2534
|
+
web.value.error = refusal;
|
|
2535
|
+
return { accepted: false, error: refusal };
|
|
2406
2536
|
}
|
|
2407
2537
|
const aborted =
|
|
2408
2538
|
error instanceof DOMException && error.name === "AbortError";
|
|
@@ -2410,9 +2540,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2410
2540
|
id: `${pendingRunId}:assistant`,
|
|
2411
2541
|
runId: pendingRunId,
|
|
2412
2542
|
role: "assistant",
|
|
2413
|
-
text:
|
|
2414
|
-
? "Request stopped locally; checking whether it started."
|
|
2415
|
-
: "Confirming whether this Turn was admitted.",
|
|
2543
|
+
text: "Checking whether your message went through…",
|
|
2416
2544
|
at: optimisticAt,
|
|
2417
2545
|
status: "interrupted",
|
|
2418
2546
|
tools: [],
|
|
@@ -2441,14 +2569,18 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2441
2569
|
id: `${pendingRunId}:assistant`,
|
|
2442
2570
|
runId: pendingRunId,
|
|
2443
2571
|
role: "assistant",
|
|
2444
|
-
text: "
|
|
2572
|
+
text: "Your message didn't go through. Try sending it again.",
|
|
2445
2573
|
at: optimisticAt,
|
|
2446
2574
|
status: "error",
|
|
2447
2575
|
tools: [],
|
|
2448
2576
|
sends: [],
|
|
2449
2577
|
});
|
|
2450
|
-
web.value.error =
|
|
2451
|
-
|
|
2578
|
+
web.value.error =
|
|
2579
|
+
"Your message didn't go through. Try sending it again.";
|
|
2580
|
+
return {
|
|
2581
|
+
accepted: false,
|
|
2582
|
+
error: "Your message didn't go through. Try sending it again.",
|
|
2583
|
+
};
|
|
2452
2584
|
}
|
|
2453
2585
|
return { accepted: true, runId: pendingRunId };
|
|
2454
2586
|
} finally {
|
|
@@ -2471,14 +2603,14 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2471
2603
|
},
|
|
2472
2604
|
async resumeRun(runId: string): Promise<void> {
|
|
2473
2605
|
if (!ctx.transport.reconcileRun) {
|
|
2474
|
-
web.value.settingsError = "
|
|
2606
|
+
web.value.settingsError = "Can't retry this right now.";
|
|
2475
2607
|
return;
|
|
2476
2608
|
}
|
|
2477
2609
|
if (web.value.activeRun?.runId !== runId) return;
|
|
2478
2610
|
web.value.activeRun = {
|
|
2479
2611
|
runId,
|
|
2480
2612
|
status: "running",
|
|
2481
|
-
message: "
|
|
2613
|
+
message: "Retrying…",
|
|
2482
2614
|
canResume: false,
|
|
2483
2615
|
};
|
|
2484
2616
|
const botId = web.value.activeBotId;
|
|
@@ -2487,7 +2619,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2487
2619
|
await ctx.transport.reconcileRun(botId, runId);
|
|
2488
2620
|
} catch (error) {
|
|
2489
2621
|
web.value.settingsError =
|
|
2490
|
-
error instanceof Error ? error.message : "
|
|
2622
|
+
error instanceof Error ? error.message : "Couldn't retry that.";
|
|
2491
2623
|
}
|
|
2492
2624
|
try {
|
|
2493
2625
|
await deliverNotifications(botId);
|
|
@@ -2495,7 +2627,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2495
2627
|
web.value.settingsError =
|
|
2496
2628
|
error instanceof Error
|
|
2497
2629
|
? error.message
|
|
2498
|
-
: "
|
|
2630
|
+
: "Couldn't refresh this reply.";
|
|
2499
2631
|
}
|
|
2500
2632
|
},
|
|
2501
2633
|
async stopRun(): Promise<void> {
|
|
@@ -31,12 +31,14 @@ describe("model runtime presentation", () => {
|
|
|
31
31
|
"Llama 3 · Ollama Cloud · Account model",
|
|
32
32
|
);
|
|
33
33
|
expect(modelRuntimeLabel({ ...label, source: "bot" })).toBe(
|
|
34
|
-
"Llama 3 · Ollama Cloud · Bot
|
|
34
|
+
"Llama 3 · Ollama Cloud · this Bot only",
|
|
35
35
|
);
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
test("shows unavailable and backend failure states", () => {
|
|
39
|
-
expect(modelRuntimeLabel({ source: "none" })).toBe(
|
|
39
|
+
expect(modelRuntimeLabel({ source: "none" })).toBe(
|
|
40
|
+
"No model available — set one up in Models",
|
|
41
|
+
);
|
|
40
42
|
expect(
|
|
41
43
|
modelRuntimeLabel({
|
|
42
44
|
source: "account",
|
|
@@ -14,13 +14,13 @@ export function modelRuntimeLabel(input: {
|
|
|
14
14
|
}): string {
|
|
15
15
|
if (input.failure) return input.failure;
|
|
16
16
|
if (input.source === "none" || !input.providerModelId) {
|
|
17
|
-
return "
|
|
17
|
+
return "No model available — set one up in Models";
|
|
18
18
|
}
|
|
19
19
|
const model =
|
|
20
20
|
input.modelDisplayName ?? input.providerModelId ?? "Connected model";
|
|
21
21
|
const provider = input.packageDisplayName ?? input.connectionDisplayName;
|
|
22
22
|
const runtime = provider ? `${model} · ${provider}` : model;
|
|
23
|
-
if (input.source === "bot") return `${runtime} · Bot
|
|
23
|
+
if (input.source === "bot") return `${runtime} · this Bot only`;
|
|
24
24
|
if (input.source === "account") return `${runtime} · Account model`;
|
|
25
25
|
return runtime;
|
|
26
26
|
}
|
package/src/client/styles.css
CHANGED
|
@@ -301,13 +301,27 @@
|
|
|
301
301
|
font-size: var(--frock-text-xs);
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
-
/*
|
|
304
|
+
/*
|
|
305
|
+
* An assistant Turn is its avatar and, beside it, one column holding
|
|
306
|
+
* everything the Turn produced. The row has exactly two children: bubbles,
|
|
307
|
+
* notices, sends and chips stack inside the column, so a one-word reply is a
|
|
308
|
+
* bubble the width of its word rather than a sliver of a shared row.
|
|
309
|
+
*/
|
|
305
310
|
.message-assistant {
|
|
306
311
|
flex-direction: row;
|
|
307
312
|
align-items: flex-start;
|
|
308
313
|
gap: 8px;
|
|
309
314
|
}
|
|
310
315
|
|
|
316
|
+
.message-column {
|
|
317
|
+
display: flex;
|
|
318
|
+
min-width: 0;
|
|
319
|
+
flex: 1 1 auto;
|
|
320
|
+
flex-direction: column;
|
|
321
|
+
align-items: flex-start;
|
|
322
|
+
gap: 6px;
|
|
323
|
+
}
|
|
324
|
+
|
|
311
325
|
.bot-avatar {
|
|
312
326
|
position: relative;
|
|
313
327
|
display: grid;
|
|
@@ -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
|
+
});
|