@frockbot/plugin-shell 0.3.5 → 0.3.6
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 +13 -14
- package/src/backend.ts +28 -4
- package/src/client/AppletCanvas.vue +3 -5
- package/src/client/FrockBotApp.vue +163 -106
- package/src/client/PackageIframeHost.vue +15 -6
- package/src/client/index.test.ts +101 -12
- package/src/client/index.ts +113 -45
- 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/run-protocol.ts +15 -3
|
@@ -288,7 +288,7 @@ const threadHeading = computed(() => {
|
|
|
288
288
|
const threadHint = computed(() => {
|
|
289
289
|
if (!hasBot.value) return "Add your first sheep to start a conversation.";
|
|
290
290
|
if (state.value.modelReady) {
|
|
291
|
-
return "
|
|
291
|
+
return "Say anything to get started.";
|
|
292
292
|
}
|
|
293
293
|
return state.value.modelLabel;
|
|
294
294
|
});
|
|
@@ -416,10 +416,47 @@ function toolChipsOf(message: WebChatMessage): WebToolActivity[] {
|
|
|
416
416
|
return message.tools.filter((tool) => iframeEntriesFor(tool).length === 0);
|
|
417
417
|
}
|
|
418
418
|
|
|
419
|
+
/**
|
|
420
|
+
* What a chip calls a tool, in the User's words rather than the model's.
|
|
421
|
+
*
|
|
422
|
+
* A tool name is an identifier — `send_to_user`, `user-Github--acme/search_issues`
|
|
423
|
+
* — and the transcript is a conversation, so the chip drops the namespace,
|
|
424
|
+
* un-snakes the rest and capitalises it.
|
|
425
|
+
*/
|
|
426
|
+
function toolChipLabel(tool: WebToolActivity): string {
|
|
427
|
+
const bare = tool.name.split("/").pop() ?? tool.name;
|
|
428
|
+
const words = bare.replace(/[_.-]+/g, " ").trim();
|
|
429
|
+
if (words.length === 0) return tool.name;
|
|
430
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Whether a chip is drawn as a failure.
|
|
435
|
+
*
|
|
436
|
+
* A tool call the model recovered from is not a failure the User has anything
|
|
437
|
+
* to do with: a refused call followed by a Turn that went on to finish is the
|
|
438
|
+
* Bot correcting itself, and colouring it red reports a broken Turn that
|
|
439
|
+
* worked. Only a Turn that itself ended badly keeps the failed state.
|
|
440
|
+
*/
|
|
441
|
+
function toolChipState(
|
|
442
|
+
tool: WebToolActivity,
|
|
443
|
+
message: WebChatMessage,
|
|
444
|
+
): "running" | "completed" | "failed" | "retried" {
|
|
445
|
+
if (tool.status !== "failed") return tool.status;
|
|
446
|
+
return message.status === "completed" || message.status === "streaming"
|
|
447
|
+
? "retried"
|
|
448
|
+
: "failed";
|
|
449
|
+
}
|
|
450
|
+
|
|
419
451
|
/** What a chip says a tool is doing. Its status, in the User's words. */
|
|
420
|
-
function toolChipStatus(
|
|
421
|
-
|
|
422
|
-
|
|
452
|
+
function toolChipStatus(
|
|
453
|
+
tool: WebToolActivity,
|
|
454
|
+
message: WebChatMessage,
|
|
455
|
+
): string {
|
|
456
|
+
const state = toolChipState(tool, message);
|
|
457
|
+
if (state === "running") return "running";
|
|
458
|
+
if (state === "retried") return "retried";
|
|
459
|
+
return state === "failed" ? "failed" : "done";
|
|
423
460
|
}
|
|
424
461
|
|
|
425
462
|
/** Which tool chips the User has opened. Local, and per chip. */
|
|
@@ -883,139 +920,159 @@ function handleComposerKeydown(event: KeyboardEvent): void {
|
|
|
883
920
|
/></span>
|
|
884
921
|
<k-slot name="frockbot.bot-avatar" />
|
|
885
922
|
</div>
|
|
886
|
-
<div v-if="message.text" class="message-bubble">
|
|
887
|
-
<UiMarkdown :text="message.text" />
|
|
888
|
-
</div>
|
|
889
923
|
<!--
|
|
924
|
+
Everything the Turn produced stacks in one column beside the
|
|
925
|
+
avatar. The row holds exactly two children — avatar, column —
|
|
926
|
+
so a bubble, a notice and a chip are stacked lines rather than
|
|
927
|
+
side-by-side columns squeezing the reply to a few pixels.
|
|
928
|
+
-->
|
|
929
|
+
<div class="message-column">
|
|
930
|
+
<div v-if="message.text" class="message-bubble">
|
|
931
|
+
<UiMarkdown :text="message.text" />
|
|
932
|
+
</div>
|
|
933
|
+
<!--
|
|
890
934
|
Why the Turn ends where it does, under whatever it had already
|
|
891
935
|
said rather than in place of it.
|
|
892
936
|
-->
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
937
|
+
<p v-if="message.notice" class="message-notice">
|
|
938
|
+
{{ message.notice }}
|
|
939
|
+
</p>
|
|
940
|
+
<template v-for="tool in message.tools" :key="tool.id">
|
|
941
|
+
<PackageIframeHost
|
|
942
|
+
v-for="entry in iframeEntriesFor(tool)"
|
|
943
|
+
:key="`${tool.id}:${entry.contribution.packageId}:${entry.page.id}`"
|
|
944
|
+
class="message-package-iframe"
|
|
945
|
+
:contribution="entry.contribution"
|
|
946
|
+
:page="entry.page"
|
|
947
|
+
:slot="entry.slot"
|
|
948
|
+
:states="{ [`tool:${tool.name}`]: toolResultState(tool) }"
|
|
949
|
+
/>
|
|
950
|
+
</template>
|
|
951
|
+
<!--
|
|
908
952
|
A binary a tool filed in the Workspace, drawn from the
|
|
909
953
|
Workspace read route. The thread carries the path, never the
|
|
910
954
|
bytes, so a long conversation costs paths and the image is
|
|
911
955
|
fetched only when it is on screen.
|
|
912
956
|
-->
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
>
|
|
917
|
-
<a
|
|
918
|
-
v-for="attachment in attachmentsOf(message)"
|
|
919
|
-
:key="attachment.contentHash"
|
|
920
|
-
:href="workspaceFileUrl(attachment.path)"
|
|
921
|
-
target="_blank"
|
|
922
|
-
rel="noreferrer"
|
|
957
|
+
<div
|
|
958
|
+
v-if="attachmentsOf(message).length > 0"
|
|
959
|
+
class="message-attachments"
|
|
923
960
|
>
|
|
924
|
-
<
|
|
925
|
-
|
|
926
|
-
:
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
961
|
+
<a
|
|
962
|
+
v-for="attachment in attachmentsOf(message)"
|
|
963
|
+
:key="attachment.contentHash"
|
|
964
|
+
:href="workspaceFileUrl(attachment.path)"
|
|
965
|
+
target="_blank"
|
|
966
|
+
rel="noreferrer"
|
|
967
|
+
>
|
|
968
|
+
<img
|
|
969
|
+
:src="workspaceFileUrl(attachment.path)"
|
|
970
|
+
:alt="`Attachment from ${message.runId}`"
|
|
971
|
+
loading="lazy"
|
|
972
|
+
/>
|
|
973
|
+
</a>
|
|
974
|
+
</div>
|
|
975
|
+
<!--
|
|
932
976
|
Sends sit beside the derived text rather than inside it: each
|
|
933
977
|
payload is its own block, and a widget-ended Turn has no text
|
|
934
978
|
bubble at all.
|
|
935
979
|
-->
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
980
|
+
<div v-if="message.sends.length > 0" class="message-sends">
|
|
981
|
+
<SendPayloadView
|
|
982
|
+
v-for="(send, sendIndex) in message.sends"
|
|
983
|
+
:key="sendIndex"
|
|
984
|
+
:send="send"
|
|
985
|
+
/>
|
|
986
|
+
</div>
|
|
987
|
+
<!--
|
|
944
988
|
What the Bot did, while it is doing it. The chip is the
|
|
945
989
|
conversation's whole account of an ordinary tool call: its
|
|
946
990
|
name, whether it is running, and — when the User opens it —
|
|
947
991
|
what it returned.
|
|
948
992
|
-->
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
:key="tool.id"
|
|
953
|
-
type="button"
|
|
954
|
-
class="tool-chip"
|
|
955
|
-
:class="`tool-chip-${tool.status}`"
|
|
956
|
-
:aria-expanded="expandedTools.has(tool.id)"
|
|
957
|
-
@click="toggleTool(tool.id)"
|
|
993
|
+
<div
|
|
994
|
+
v-if="toolChipsOf(message).length > 0"
|
|
995
|
+
class="message-tools"
|
|
958
996
|
>
|
|
959
|
-
<
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
997
|
+
<button
|
|
998
|
+
v-for="tool in toolChipsOf(message)"
|
|
999
|
+
:key="tool.id"
|
|
1000
|
+
type="button"
|
|
1001
|
+
class="tool-chip"
|
|
1002
|
+
:class="`tool-chip-${toolChipState(tool, message)}`"
|
|
1003
|
+
:aria-expanded="expandedTools.has(tool.id)"
|
|
1004
|
+
@click="toggleTool(tool.id)"
|
|
967
1005
|
>
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
1006
|
+
<span class="tool-chip-name">{{
|
|
1007
|
+
toolChipLabel(tool)
|
|
1008
|
+
}}</span>
|
|
1009
|
+
<span class="tool-chip-status">{{
|
|
1010
|
+
toolChipStatus(tool, message)
|
|
1011
|
+
}}</span>
|
|
1012
|
+
<span
|
|
1013
|
+
v-if="
|
|
1014
|
+
expandedTools.has(tool.id) && tool.text !== undefined
|
|
1015
|
+
"
|
|
1016
|
+
class="tool-chip-result"
|
|
1017
|
+
>{{ tool.text }}</span
|
|
1018
|
+
>
|
|
1019
|
+
</button>
|
|
1020
|
+
</div>
|
|
1021
|
+
<!--
|
|
971
1022
|
The subagents this Turn dispatched. The child's own Session is
|
|
972
1023
|
never in this transcript, so the chip is the whole of what the
|
|
973
1024
|
conversation says about it; opening one shows the summary the
|
|
974
1025
|
child handed back and nothing else.
|
|
975
1026
|
-->
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
:key="entry.chip.taskId"
|
|
980
|
-
type="button"
|
|
981
|
-
class="task-chip"
|
|
982
|
-
:class="`task-chip-${entry.status}`"
|
|
983
|
-
:aria-expanded="expandedTasks.has(entry.chip.taskId)"
|
|
984
|
-
@click="toggleTask(entry.chip.taskId)"
|
|
1027
|
+
<div
|
|
1028
|
+
v-if="taskChipsOf(message).length > 0"
|
|
1029
|
+
class="message-tasks"
|
|
985
1030
|
>
|
|
986
|
-
<
|
|
987
|
-
|
|
988
|
-
entry.chip.
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
class="task-chip-summary"
|
|
995
|
-
>{{
|
|
996
|
-
entry.summary ??
|
|
997
|
-
"This subagent has not reported a summary yet."
|
|
998
|
-
}}</span
|
|
1031
|
+
<button
|
|
1032
|
+
v-for="entry in taskChipsOf(message)"
|
|
1033
|
+
:key="entry.chip.taskId"
|
|
1034
|
+
type="button"
|
|
1035
|
+
class="task-chip"
|
|
1036
|
+
:class="`task-chip-${entry.status}`"
|
|
1037
|
+
:aria-expanded="expandedTasks.has(entry.chip.taskId)"
|
|
1038
|
+
@click="toggleTask(entry.chip.taskId)"
|
|
999
1039
|
>
|
|
1000
|
-
|
|
1040
|
+
<span class="task-chip-type">{{
|
|
1041
|
+
entry.chip.taskType
|
|
1042
|
+
}}</span>
|
|
1043
|
+
<span class="task-chip-description">{{
|
|
1044
|
+
entry.chip.description
|
|
1045
|
+
}}</span>
|
|
1046
|
+
<span class="task-chip-status">{{ entry.status }}</span>
|
|
1047
|
+
<span class="task-chip-model">{{ entry.chip.model }}</span>
|
|
1048
|
+
<span
|
|
1049
|
+
v-if="expandedTasks.has(entry.chip.taskId)"
|
|
1050
|
+
class="task-chip-summary"
|
|
1051
|
+
>{{
|
|
1052
|
+
entry.summary ??
|
|
1053
|
+
"This subagent has not reported a summary yet."
|
|
1054
|
+
}}</span
|
|
1055
|
+
>
|
|
1056
|
+
<!--
|
|
1001
1057
|
Cancellation is the User's, explicit and authenticated. It
|
|
1002
1058
|
is offered only while the subagent is live: a settled one
|
|
1003
1059
|
has an outcome, and stopping it would rewrite it.
|
|
1004
1060
|
-->
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1061
|
+
<span
|
|
1062
|
+
v-if="
|
|
1063
|
+
expandedTasks.has(entry.chip.taskId) &&
|
|
1064
|
+
isTaskLive(entry.status)
|
|
1065
|
+
"
|
|
1066
|
+
class="task-chip-stop"
|
|
1067
|
+
role="button"
|
|
1068
|
+
tabindex="0"
|
|
1069
|
+
@click.stop="stopTask(entry.chip.taskId)"
|
|
1070
|
+
@keydown.enter.stop.prevent="stopTask(entry.chip.taskId)"
|
|
1071
|
+
@keydown.space.stop.prevent="stopTask(entry.chip.taskId)"
|
|
1072
|
+
>Stop this subagent</span
|
|
1073
|
+
>
|
|
1074
|
+
</button>
|
|
1075
|
+
</div>
|
|
1019
1076
|
</div>
|
|
1020
1077
|
</template>
|
|
1021
1078
|
<div v-else class="message-bubble">{{ message.text }}</div>
|
|
@@ -1046,7 +1103,7 @@ function handleComposerKeydown(event: KeyboardEvent): void {
|
|
|
1046
1103
|
type="button"
|
|
1047
1104
|
@click="web.resumeRun(state.activeRun.runId)"
|
|
1048
1105
|
>
|
|
1049
|
-
|
|
1106
|
+
Try again
|
|
1050
1107
|
</button>
|
|
1051
1108
|
</div>
|
|
1052
1109
|
</Transition>
|
|
@@ -34,6 +34,13 @@ const providedWeb = inject(frockBotWebDataKey);
|
|
|
34
34
|
if (!providedWeb) throw new Error("Package iframe host data was not provided");
|
|
35
35
|
const web = providedWeb;
|
|
36
36
|
const frame = ref<HTMLIFrameElement>();
|
|
37
|
+
/** Where a page came from, said plainly. First-party pages say nothing. */
|
|
38
|
+
const provenanceLabel = computed(() => {
|
|
39
|
+
if (props.contribution.provenance === "Bot-authored")
|
|
40
|
+
return "Built by this Bot";
|
|
41
|
+
if (props.contribution.provenance === "User-installed") return "Added by you";
|
|
42
|
+
return undefined;
|
|
43
|
+
});
|
|
37
44
|
const height = ref(240);
|
|
38
45
|
const failure = ref<string>();
|
|
39
46
|
const lastStateWireByName = new Map<string, string>();
|
|
@@ -123,7 +130,9 @@ function post(message: PackageIframeHostMessageV2): void {
|
|
|
123
130
|
postPackageIframeHostMessage(target, message, lastStateWireByName);
|
|
124
131
|
} catch (error) {
|
|
125
132
|
failure.value =
|
|
126
|
-
error instanceof Error
|
|
133
|
+
error instanceof Error
|
|
134
|
+
? error.message
|
|
135
|
+
: "This page sent something FrockBot couldn't read.";
|
|
127
136
|
}
|
|
128
137
|
}
|
|
129
138
|
|
|
@@ -179,7 +188,7 @@ async function onMessage(event: MessageEvent): Promise<void> {
|
|
|
179
188
|
}
|
|
180
189
|
if (message.type === "focus") {
|
|
181
190
|
if (!packageIframeFocusAllowedV2(props.contribution)) {
|
|
182
|
-
failure.value = "This
|
|
191
|
+
failure.value = "This plugin can't change which Applet is open.";
|
|
183
192
|
return;
|
|
184
193
|
}
|
|
185
194
|
failure.value = undefined;
|
|
@@ -189,7 +198,7 @@ async function onMessage(event: MessageEvent): Promise<void> {
|
|
|
189
198
|
if (message.type === "openExternal") {
|
|
190
199
|
const origin = catalog.value?.artifactOrigin;
|
|
191
200
|
if (!origin || !packageIframeExternalUrlAllowedV2(message.url, origin)) {
|
|
192
|
-
failure.value = "This
|
|
201
|
+
failure.value = "This plugin can only open its own pages.";
|
|
193
202
|
return;
|
|
194
203
|
}
|
|
195
204
|
failure.value = undefined;
|
|
@@ -197,7 +206,7 @@ async function onMessage(event: MessageEvent): Promise<void> {
|
|
|
197
206
|
return;
|
|
198
207
|
}
|
|
199
208
|
if (!packageIframeToolAllowedV1(props.contribution, message.name)) {
|
|
200
|
-
failure.value = `This
|
|
209
|
+
failure.value = `This plugin isn't allowed to use ${message.name}.`;
|
|
201
210
|
return;
|
|
202
211
|
}
|
|
203
212
|
failure.value = undefined;
|
|
@@ -232,12 +241,12 @@ onBeforeUnmount(() => window.removeEventListener("message", onMessage));
|
|
|
232
241
|
<section class="package-iframe-frame" :class="`package-iframe-${layout}`">
|
|
233
242
|
<header v-if="attribution" class="package-iframe-attribution">
|
|
234
243
|
<strong>{{ contribution.displayName }}</strong>
|
|
235
|
-
<span>{{
|
|
244
|
+
<span v-if="provenanceLabel">{{ provenanceLabel }}</span>
|
|
236
245
|
</header>
|
|
237
246
|
<!-- Load eagerly because lazy iframes defer the init/resize handshake until the browser decides the frame is near the viewport, which headless Chromium may never do. -->
|
|
238
247
|
<iframe
|
|
239
248
|
ref="frame"
|
|
240
|
-
:title="
|
|
249
|
+
:title="contribution.displayName"
|
|
241
250
|
:src="source"
|
|
242
251
|
:style="layout === 'fill' ? undefined : { height: `${height}px` }"
|
|
243
252
|
sandbox="allow-scripts"
|
package/src/client/index.test.ts
CHANGED
|
@@ -516,7 +516,7 @@ describe("Bot selection", () => {
|
|
|
516
516
|
const requestCount = requests.length;
|
|
517
517
|
await expect(
|
|
518
518
|
provided.value.callPackageUiTool(contribution, "package_author", {}),
|
|
519
|
-
).rejects.toThrow("
|
|
519
|
+
).rejects.toThrow("isn't allowed to use");
|
|
520
520
|
expect(requests).toHaveLength(requestCount);
|
|
521
521
|
});
|
|
522
522
|
|
|
@@ -571,6 +571,47 @@ describe("Bot selection", () => {
|
|
|
571
571
|
expect(provided.value.settingsError).toBe("Bot settings unavailable");
|
|
572
572
|
});
|
|
573
573
|
|
|
574
|
+
test("says a deployment has no Catalog instead of relaying the gateway's sentence", async () => {
|
|
575
|
+
// F4: the Plugins/Catalog surface rendered `catalog generation was not
|
|
576
|
+
// found` beside "No Catalog entry matches that search", for a User who had
|
|
577
|
+
// searched nothing on a deployment that had published nothing.
|
|
578
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
579
|
+
let failure = new Error("catalog generation was not found");
|
|
580
|
+
await shellClientPlugin({
|
|
581
|
+
transport: {
|
|
582
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
583
|
+
readConfiguration: () => Promise.reject(new Error("unused")),
|
|
584
|
+
hostedRequest: (path) => {
|
|
585
|
+
expect(path).toBe("/catalog/v1/index");
|
|
586
|
+
return Promise.reject(failure);
|
|
587
|
+
},
|
|
588
|
+
},
|
|
589
|
+
slot: () => () => {},
|
|
590
|
+
inject: () => {
|
|
591
|
+
throw new Error("unexpected client provider injection");
|
|
592
|
+
},
|
|
593
|
+
provide: (_key, value) => {
|
|
594
|
+
provided = value as Ref<FrockBotWebData>;
|
|
595
|
+
return () => {};
|
|
596
|
+
},
|
|
597
|
+
});
|
|
598
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
599
|
+
|
|
600
|
+
await provided.value.loadPackageCatalog();
|
|
601
|
+
expect(provided.value.packageCatalog).toEqual([]);
|
|
602
|
+
expect(provided.value.packageCatalogGeneration).toBeUndefined();
|
|
603
|
+
expect(provided.value.settingsError).toBe(
|
|
604
|
+
"No plugins are published for this deployment yet.",
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
// A genuine fault still reaches the operator, labelled as one.
|
|
608
|
+
failure = new Error("R2 is down");
|
|
609
|
+
await provided.value.loadPackageCatalog();
|
|
610
|
+
expect(provided.value.settingsError).toBe(
|
|
611
|
+
"Plugins could not be loaded: R2 is down",
|
|
612
|
+
);
|
|
613
|
+
});
|
|
614
|
+
|
|
574
615
|
test("commits a catalog without overwriting newer User settings", async () => {
|
|
575
616
|
let provided: Ref<FrockBotWebData> | undefined;
|
|
576
617
|
const catalogManifest = Promise.withResolvers<unknown>();
|
|
@@ -648,7 +689,7 @@ describe("Bot selection", () => {
|
|
|
648
689
|
},
|
|
649
690
|
accountValues: undefined,
|
|
650
691
|
platformModel: undefined,
|
|
651
|
-
expectedLabel: "Model name · Model provider · Bot
|
|
692
|
+
expectedLabel: "Model name · Model provider · this Bot only",
|
|
652
693
|
expectedProjection: "bot",
|
|
653
694
|
ready: true,
|
|
654
695
|
},
|
|
@@ -683,7 +724,7 @@ describe("Bot selection", () => {
|
|
|
683
724
|
botValues: {},
|
|
684
725
|
accountValues: undefined,
|
|
685
726
|
platformModel: undefined,
|
|
686
|
-
expectedLabel: "
|
|
727
|
+
expectedLabel: "No model available — set one up in Models",
|
|
687
728
|
expectedProjection: "none",
|
|
688
729
|
ready: false,
|
|
689
730
|
},
|
|
@@ -900,7 +941,7 @@ describe("Bot selection", () => {
|
|
|
900
941
|
|
|
901
942
|
expect(provided.value.modelReady).toBe(false);
|
|
902
943
|
expect(provided.value.modelLabel).toBe(
|
|
903
|
-
|
|
944
|
+
"That account needs reconnecting before this Bot can reply.",
|
|
904
945
|
);
|
|
905
946
|
});
|
|
906
947
|
|
|
@@ -1217,7 +1258,7 @@ describe("detached Turn projection", () => {
|
|
|
1217
1258
|
{ role: "user", status: "completed" },
|
|
1218
1259
|
{
|
|
1219
1260
|
role: "assistant",
|
|
1220
|
-
text: "
|
|
1261
|
+
text: "This Bot couldn't finish its reply. Try again.",
|
|
1221
1262
|
status: "error",
|
|
1222
1263
|
},
|
|
1223
1264
|
]);
|
|
@@ -1482,6 +1523,55 @@ describe("active durable Turn projection", () => {
|
|
|
1482
1523
|
]);
|
|
1483
1524
|
});
|
|
1484
1525
|
|
|
1526
|
+
test("a Turn that sent something draws the send, not the model's own text", () => {
|
|
1527
|
+
const state: Pick<
|
|
1528
|
+
FrockBotWebData,
|
|
1529
|
+
"messages" | "activeRunId" | "activeRun"
|
|
1530
|
+
> = { messages: [] };
|
|
1531
|
+
|
|
1532
|
+
projectDurableRuns(
|
|
1533
|
+
state,
|
|
1534
|
+
[],
|
|
1535
|
+
[
|
|
1536
|
+
{
|
|
1537
|
+
runId: "run-once",
|
|
1538
|
+
input: "ping",
|
|
1539
|
+
status: "completed",
|
|
1540
|
+
responseText: "pong",
|
|
1541
|
+
events: [
|
|
1542
|
+
{ type: "send/to-user", payload: { type: "text", text: "pong" } },
|
|
1543
|
+
],
|
|
1544
|
+
},
|
|
1545
|
+
],
|
|
1546
|
+
);
|
|
1547
|
+
|
|
1548
|
+
expect(state.messages[1]?.text).toBe("");
|
|
1549
|
+
expect(state.messages[1]?.sends).toHaveLength(1);
|
|
1550
|
+
});
|
|
1551
|
+
|
|
1552
|
+
test("a Turn that sent nothing still draws the model's text", () => {
|
|
1553
|
+
const state: Pick<
|
|
1554
|
+
FrockBotWebData,
|
|
1555
|
+
"messages" | "activeRunId" | "activeRun"
|
|
1556
|
+
> = { messages: [] };
|
|
1557
|
+
|
|
1558
|
+
projectDurableRuns(
|
|
1559
|
+
state,
|
|
1560
|
+
[],
|
|
1561
|
+
[
|
|
1562
|
+
{
|
|
1563
|
+
runId: "run-plain",
|
|
1564
|
+
input: "ping",
|
|
1565
|
+
status: "completed",
|
|
1566
|
+
responseText: "pong",
|
|
1567
|
+
events: [],
|
|
1568
|
+
},
|
|
1569
|
+
],
|
|
1570
|
+
);
|
|
1571
|
+
|
|
1572
|
+
expect(state.messages[1]?.text).toBe("pong");
|
|
1573
|
+
});
|
|
1574
|
+
|
|
1485
1575
|
test("streams running text without a banner and clears busy state", () => {
|
|
1486
1576
|
const state: Pick<
|
|
1487
1577
|
FrockBotWebData,
|
|
@@ -1562,11 +1652,11 @@ describe("active durable Turn projection", () => {
|
|
|
1562
1652
|
expect(reconciliation.activeRun).toEqual({
|
|
1563
1653
|
runId: "run-reconciliation",
|
|
1564
1654
|
status: "reconciliation-required",
|
|
1565
|
-
message: "
|
|
1655
|
+
message: "Something went wrong mid-reply. Try again to pick it up.",
|
|
1566
1656
|
canResume: true,
|
|
1567
1657
|
});
|
|
1568
1658
|
expect(reconciliation.messages[1]).toMatchObject({
|
|
1569
|
-
text: "
|
|
1659
|
+
text: "This reply stopped partway. Try again to continue it.",
|
|
1570
1660
|
status: "reconciliation-required",
|
|
1571
1661
|
});
|
|
1572
1662
|
});
|
|
@@ -1845,8 +1935,7 @@ describe("hosted Stop", () => {
|
|
|
1845
1935
|
await provided.value.stopRun();
|
|
1846
1936
|
expect(provided.value.activeRun).toMatchObject({
|
|
1847
1937
|
status: "reconciliation-required",
|
|
1848
|
-
message:
|
|
1849
|
-
"Stop accepted; reconciling the provider outcome before cancelling.",
|
|
1938
|
+
message: "Stopping…",
|
|
1850
1939
|
// A parked Turn is offered the resolve control, Stop included: hiding
|
|
1851
1940
|
// it there hid it in exactly the case Stop creates.
|
|
1852
1941
|
canResume: true,
|
|
@@ -1858,7 +1947,7 @@ describe("hosted Stop", () => {
|
|
|
1858
1947
|
// The Turn keeps whatever it had already said; the notice is the line
|
|
1859
1948
|
// that says why it ends where it does.
|
|
1860
1949
|
expect(provided.value.messages[1]).toMatchObject({
|
|
1861
|
-
notice: "
|
|
1950
|
+
notice: "You stopped this.",
|
|
1862
1951
|
status: "aborted",
|
|
1863
1952
|
});
|
|
1864
1953
|
|
|
@@ -1923,7 +2012,7 @@ describe("hosted Stop", () => {
|
|
|
1923
2012
|
expect(provided.value.activeRunId).toBeUndefined();
|
|
1924
2013
|
expect(provided.value.messages.at(-1)).toMatchObject({
|
|
1925
2014
|
runId: "run-1",
|
|
1926
|
-
notice: "
|
|
2015
|
+
notice: "You stopped this.",
|
|
1927
2016
|
status: "aborted",
|
|
1928
2017
|
});
|
|
1929
2018
|
});
|
|
@@ -2513,7 +2602,7 @@ describe("Connection operation reconciliation", () => {
|
|
|
2513
2602
|
).rejects.toThrow("Connection state update failed");
|
|
2514
2603
|
await expect(
|
|
2515
2604
|
provided.value.disconnectConnection("connection-1"),
|
|
2516
|
-
).rejects.toThrow("
|
|
2605
|
+
).rejects.toThrow("Couldn't disconnect that account.");
|
|
2517
2606
|
expect(commands).toEqual([
|
|
2518
2607
|
"connection/update-label",
|
|
2519
2608
|
"connection/set-enabled",
|