@frockbot/plugin-shell 0.3.11 → 0.3.13
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 +35 -33
- package/src/agent.test.ts +78 -0
- package/src/agent.ts +130 -2
- package/src/backend-configuration.test.ts +26 -26
- package/src/backend-recovery-integration.test.ts +10 -10
- package/src/backend-runner.ts +19 -2
- package/src/backend.ts +85 -18
- package/src/client/AppletCanvas.vue +19 -6
- package/src/client/FrockBotApp.vue +405 -75
- package/src/client/activity-trail.test.ts +205 -0
- package/src/client/activity-trail.ts +227 -0
- package/src/client/applets-client.test.ts +62 -0
- package/src/client/applets-client.ts +19 -0
- package/src/client/index.test.ts +128 -21
- package/src/client/index.ts +359 -114
- package/src/client/model-presentation.test.ts +3 -3
- package/src/client/no-bot-model-label.test.ts +7 -7
- package/src/client/skill-invocation.test.ts +34 -0
- package/src/client/skill-invocation.ts +22 -0
- package/src/client/styles.css +69 -19
- package/src/client/transcript-cache.test.ts +125 -0
- package/src/client/transcript-cache.ts +190 -0
- package/src/compaction-scheduler.test.ts +96 -0
- package/src/compaction-scheduler.ts +108 -0
- package/src/compaction-transcript.test.ts +174 -0
- package/src/compaction.test.ts +596 -0
- package/src/compaction.ts +539 -0
- package/src/focus.test.ts +222 -0
- package/src/focus.ts +93 -0
- package/src/history.ts +86 -8
- package/src/legacy-frock-model-id.test.ts +148 -0
- package/src/notification-id.ts +0 -0
- package/src/run-failure-copy.test.ts +150 -0
- package/src/run-failure-copy.ts +110 -0
- package/src/run-protocol.test.ts +50 -7
- package/src/run-protocol.ts +152 -43
- package/src/settings-links.test.ts +8 -2
- package/src/settings-links.ts +11 -2
- package/src/shared.ts +36 -0
- package/tsconfig.json +1 -2
- package/src/client/activity-ring.test.ts +0 -89
- package/src/client/activity-ring.ts +0 -94
package/src/client/index.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/// <reference path="../env.d.ts" />
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
clientFailureDetailV1,
|
|
4
5
|
decodeExternalAuthorizationUrl,
|
|
6
|
+
presentClientFailureV1,
|
|
7
|
+
serverRefusalMessageV1,
|
|
5
8
|
type AgentTransport,
|
|
6
9
|
type ClientAnnouncement,
|
|
7
10
|
type ClientNotificationIntent,
|
|
@@ -11,6 +14,8 @@ import {
|
|
|
11
14
|
type ClientTurnEvent,
|
|
12
15
|
} from "@frockbot/client-core";
|
|
13
16
|
import { clientSurfaceRegistryKey } from "@frockbot/client-core";
|
|
17
|
+
import { COMPACTED_ANNOUNCEMENT_TEXT_V1 } from "../compaction.js";
|
|
18
|
+
import { readViewerFocusV1, shouldNotifyForBotV1 } from "../focus.js";
|
|
14
19
|
// Connection mutations use the provider-neutral hosted command contract.
|
|
15
20
|
import type {
|
|
16
21
|
ConnectionCommandReceiptV1,
|
|
@@ -53,6 +58,7 @@ import {
|
|
|
53
58
|
import { MCP_OAUTH_CONNECTION_TYPE_ID } from "@frockbot/plugin-mcp/agent";
|
|
54
59
|
import { decodeStartConnectionResultV1 } from "@frockbot/connection-core";
|
|
55
60
|
import { decodeClientSkillCatalogV1 } from "../skill-protocol.js";
|
|
61
|
+
import { knownFailureCopyV1 } from "../run-failure-copy.js";
|
|
56
62
|
import {
|
|
57
63
|
ClientTurnRefusedErrorV1,
|
|
58
64
|
type ClientTurnRefusalReasonV1,
|
|
@@ -86,6 +92,7 @@ import {
|
|
|
86
92
|
type WebTaskChip,
|
|
87
93
|
type WebToolActivity,
|
|
88
94
|
} from "../shared.js";
|
|
95
|
+
import { TranscriptCache } from "./transcript-cache.js";
|
|
89
96
|
import FrockBotApp from "./FrockBotApp.vue";
|
|
90
97
|
import PackageEntryTrigger from "./PackageEntryTrigger.vue";
|
|
91
98
|
import PackageIframeSettings from "./PackageIframeSettings.vue";
|
|
@@ -251,6 +258,11 @@ function activeRunView(run: ClientRun): WebActiveRun | undefined {
|
|
|
251
258
|
return {
|
|
252
259
|
runId: run.runId,
|
|
253
260
|
status: run.status,
|
|
261
|
+
// Never the backend's own sentence. What arrived here read
|
|
262
|
+
// `Model request "1c7dd68e-…" has no durable provider outcome:` — a
|
|
263
|
+
// UUID and two internal nouns, in the one place a User is told what
|
|
264
|
+
// happened to their reply. The raw text stays on the run for the
|
|
265
|
+
// console; the banner says what it means and offers the one action.
|
|
254
266
|
message: run.stopRequestedAt
|
|
255
267
|
? "Stopping…"
|
|
256
268
|
: "Something went wrong mid-reply. Try again to pick it up.",
|
|
@@ -282,13 +294,28 @@ function turnRefusalCopyV1(reason: ClientTurnRefusalReasonV1): string {
|
|
|
282
294
|
* The Bot's voice is its sends. When a Turn delivered anything to the User the
|
|
283
295
|
* model's own assistant text is scratch space and the thread does not draw it
|
|
284
296
|
* (issue 153): drawing both is how a one-word reply arrived twice, once as the
|
|
285
|
-
* model's text and once as the bubble that was actually delivered
|
|
297
|
+
* model's text and once as the bubble that was actually delivered — the Turn's
|
|
298
|
+
* derived text is the last text send when the model wrote no message of its
|
|
299
|
+
* own (`backend-runner.ts`'s `lastSentTextV1`), so it is literally that copy.
|
|
286
300
|
*
|
|
287
301
|
* A running Turn has no `responseText` yet — that is written only at
|
|
288
302
|
* settlement — so it draws the words it has written so far. They occupy the
|
|
289
|
-
* same bubble the settled answer will, and
|
|
290
|
-
*
|
|
291
|
-
*
|
|
303
|
+
* same bubble the settled answer will, and that bubble is the Turn's own line,
|
|
304
|
+
* which follows the sends rather than replacing any of them.
|
|
305
|
+
*
|
|
306
|
+
* The gate is absolute, and it has to be. Relaxing it to "suppress only text
|
|
307
|
+
* that duplicates a send" looked safer and was not: the model's *last* step
|
|
308
|
+
* routinely writes something of its own after the step that spoke — the e2e
|
|
309
|
+
* that pins this sends "pong" through the tool and then answers again in text —
|
|
310
|
+
* and comparing the two drew both. Two bubbles for one reply is the exact
|
|
311
|
+
* regression issue 153 named.
|
|
312
|
+
*
|
|
313
|
+
* So a Turn that sent anything is drawn entirely from its sends, and text the
|
|
314
|
+
* model wrote beside them is scratch space. That is what makes the promotion in
|
|
315
|
+
* `promoteAssistantTextToSendV1` the right shape: an acknowledgement reaches
|
|
316
|
+
* the person by *becoming* a send — and under the per-send projection it is
|
|
317
|
+
* then its own bubble, in the order it was journaled — rather than being drawn
|
|
318
|
+
* as text next to one.
|
|
292
319
|
*/
|
|
293
320
|
function visibleAssistantText(run: ClientRun, fallback = ""): string {
|
|
294
321
|
if (sendsFrom(run.events).length > 0) return "";
|
|
@@ -304,6 +331,33 @@ function isTerminalRun(run: ClientRun): boolean {
|
|
|
304
331
|
);
|
|
305
332
|
}
|
|
306
333
|
|
|
334
|
+
/**
|
|
335
|
+
* One message per `send_to_user`, in the order the Bot sent them.
|
|
336
|
+
*
|
|
337
|
+
* A Turn is not one bubble. The Bot acknowledges, works, and reports back,
|
|
338
|
+
* and each of those is a message in the conversation exactly as it would be
|
|
339
|
+
* from a person (issue 153). The order is the durable order of the run's
|
|
340
|
+
* events, so the thread a reload draws is the thread that was watched being
|
|
341
|
+
* written, and a bubble is never edited once it is in the transcript: a later
|
|
342
|
+
* send appends, it does not replace.
|
|
343
|
+
*/
|
|
344
|
+
function sendMessages(run: ClientRun): WebChatMessage[] {
|
|
345
|
+
return sendsFrom(run.events).map((send, index) => ({
|
|
346
|
+
id: `${run.runId}:send:${index}`,
|
|
347
|
+
runId: run.runId,
|
|
348
|
+
role: "assistant" as const,
|
|
349
|
+
text: "",
|
|
350
|
+
status: "completed" as const,
|
|
351
|
+
tools: [],
|
|
352
|
+
sends: [send],
|
|
353
|
+
}));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The Turn's own line: the model's words, why the Turn ended where it did,
|
|
358
|
+
* the tools it ran and the subagents it dispatched. It closes the run, under
|
|
359
|
+
* whatever the Turn had already sent.
|
|
360
|
+
*/
|
|
307
361
|
function assistantMessage(
|
|
308
362
|
run: ClientRun,
|
|
309
363
|
notification: ClientNotificationIntent | undefined,
|
|
@@ -321,22 +375,23 @@ function assistantMessage(
|
|
|
321
375
|
// message is the whole of what the thread says about it.
|
|
322
376
|
...(run.queued ? { pending: true } : {}),
|
|
323
377
|
tools: toolsFrom(run.events),
|
|
324
|
-
sends:
|
|
378
|
+
sends: [],
|
|
325
379
|
tasks: tasksFrom(run.events),
|
|
326
380
|
};
|
|
327
381
|
}
|
|
328
382
|
if (run.status === "superseded") {
|
|
329
|
-
//
|
|
330
|
-
//
|
|
383
|
+
// Quieter than a stopped Turn: it keeps everything it already sent and
|
|
384
|
+
// carries no notice at all. The message that superseded it is sitting
|
|
385
|
+
// right underneath, in the person's own words, and it explains the ending
|
|
386
|
+
// better than a line of ours would (ADR 0024).
|
|
331
387
|
return {
|
|
332
388
|
id: `${run.runId}:assistant`,
|
|
333
389
|
runId: run.runId,
|
|
334
390
|
role: "assistant",
|
|
335
391
|
text: visibleAssistantText(run),
|
|
336
|
-
notice: "Interrupted by your next message.",
|
|
337
392
|
status: "aborted",
|
|
338
393
|
tools: toolsFrom(run.events),
|
|
339
|
-
sends:
|
|
394
|
+
sends: [],
|
|
340
395
|
tasks: tasksFrom(run.events),
|
|
341
396
|
};
|
|
342
397
|
}
|
|
@@ -345,10 +400,18 @@ function assistantMessage(
|
|
|
345
400
|
id: `${run.runId}:assistant`,
|
|
346
401
|
runId: run.runId,
|
|
347
402
|
role: "assistant",
|
|
348
|
-
|
|
403
|
+
/*
|
|
404
|
+
* A failure is not something the Bot said. The bubble holds the text
|
|
405
|
+
* the model actually produced — often none — and the notice under it
|
|
406
|
+
* says why the Turn ends there: what arrived here read `Model request
|
|
407
|
+
* "1c7dd68e-…" has no durable provider outcome`, in a bubble styled
|
|
408
|
+
* exactly like the Bot speaking.
|
|
409
|
+
*/
|
|
410
|
+
text: visibleAssistantText(run),
|
|
411
|
+
notice: "This reply stopped partway. Try again to continue it.",
|
|
349
412
|
status: "reconciliation-required",
|
|
350
413
|
tools: toolsFrom(run.events),
|
|
351
|
-
sends:
|
|
414
|
+
sends: [],
|
|
352
415
|
tasks: tasksFrom(run.events),
|
|
353
416
|
};
|
|
354
417
|
}
|
|
@@ -361,7 +424,7 @@ function assistantMessage(
|
|
|
361
424
|
notice: "You stopped this.",
|
|
362
425
|
status: "aborted",
|
|
363
426
|
tools: toolsFrom(run.events),
|
|
364
|
-
sends:
|
|
427
|
+
sends: [],
|
|
365
428
|
tasks: tasksFrom(run.events),
|
|
366
429
|
};
|
|
367
430
|
}
|
|
@@ -375,10 +438,18 @@ function assistantMessage(
|
|
|
375
438
|
runId: run.runId,
|
|
376
439
|
role: "assistant",
|
|
377
440
|
text: run.responseText,
|
|
378
|
-
|
|
441
|
+
// The durable failure text is a provider's, not the product's — `Bot
|
|
442
|
+
// turn ended with outcome model-error`, a status code, once a run UUID —
|
|
443
|
+
// and under a bubble it reads as part of what the Bot was saying. By the
|
|
444
|
+
// time it reaches here it is already the sentence for a person: the
|
|
445
|
+
// projection maps it through `runFailureCopyV1` before it crosses the
|
|
446
|
+
// wire, so this keeps whatever that chose — the model-deadline copy says
|
|
447
|
+
// something the outcome alone cannot — and falls back to the same line a
|
|
448
|
+
// reply-less failure gets.
|
|
449
|
+
notice: knownFailureCopyV1(run.failure),
|
|
379
450
|
status: "error",
|
|
380
451
|
tools: toolsFrom(run.events),
|
|
381
|
-
sends:
|
|
452
|
+
sends: [],
|
|
382
453
|
tasks: tasksFrom(run.events),
|
|
383
454
|
};
|
|
384
455
|
}
|
|
@@ -388,11 +459,16 @@ function assistantMessage(
|
|
|
388
459
|
role: "assistant",
|
|
389
460
|
text:
|
|
390
461
|
run.status === "failed"
|
|
391
|
-
?
|
|
462
|
+
? visibleAssistantText(run)
|
|
392
463
|
: visibleAssistantText(run, notification?.body ?? ""),
|
|
464
|
+
// Why the Turn ends there, under whatever it had already said — never as
|
|
465
|
+
// the bubble's own text, which reads as the Bot saying it.
|
|
466
|
+
...(run.status === "failed"
|
|
467
|
+
? { notice: knownFailureCopyV1(run.failure) }
|
|
468
|
+
: {}),
|
|
393
469
|
status: run.status === "failed" ? "error" : "completed",
|
|
394
470
|
tools: toolsFrom(run.events),
|
|
395
|
-
sends:
|
|
471
|
+
sends: [],
|
|
396
472
|
tasks: tasksFrom(run.events),
|
|
397
473
|
};
|
|
398
474
|
}
|
|
@@ -411,7 +487,10 @@ export function projectAnnouncements(
|
|
|
411
487
|
id: announcement.announcementId,
|
|
412
488
|
runId: announcement.announcementId,
|
|
413
489
|
role: "system",
|
|
414
|
-
text:
|
|
490
|
+
text:
|
|
491
|
+
announcement.type === "conversation/compacted"
|
|
492
|
+
? COMPACTED_ANNOUNCEMENT_TEXT_V1
|
|
493
|
+
: `Renamed to ${announcement.to} by ${announcement.namedBy}`,
|
|
415
494
|
at: announcement.at,
|
|
416
495
|
status: "completed",
|
|
417
496
|
tools: [],
|
|
@@ -420,9 +499,32 @@ export function projectAnnouncements(
|
|
|
420
499
|
const index = messages.findIndex(
|
|
421
500
|
(candidate) => candidate.id === announcement.announcementId,
|
|
422
501
|
);
|
|
423
|
-
|
|
424
|
-
|
|
502
|
+
// Removed before it is placed, so a marker that already sits in the thread
|
|
503
|
+
// is re-seated rather than frozen wherever the first poll put it.
|
|
504
|
+
if (index >= 0) messages.splice(index, 1);
|
|
505
|
+
messages.splice(announcementIndex(messages, message.at ?? ""), 0, message);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Where a system line belongs among the Turns.
|
|
511
|
+
*
|
|
512
|
+
* Only the user message of a Turn carries a timestamp; everything the Bot
|
|
513
|
+
* wrote for that Turn follows it untimed. So an untimed message inherits the
|
|
514
|
+
* last timestamp seen, and the line is placed before the first message that is
|
|
515
|
+
* genuinely later than it. A marker for a range that has scrolled out of the
|
|
516
|
+
* transcript sorts before everything, which puts it at the top of what remains.
|
|
517
|
+
*/
|
|
518
|
+
function announcementIndex(
|
|
519
|
+
messages: readonly WebChatMessage[],
|
|
520
|
+
at: string,
|
|
521
|
+
): number {
|
|
522
|
+
let seen = "";
|
|
523
|
+
for (const [index, candidate] of messages.entries()) {
|
|
524
|
+
if (candidate.at) seen = candidate.at;
|
|
525
|
+
if (seen > at) return index;
|
|
425
526
|
}
|
|
527
|
+
return messages.length;
|
|
426
528
|
}
|
|
427
529
|
|
|
428
530
|
export function projectDurableRuns(
|
|
@@ -467,16 +569,12 @@ export function projectDurableRuns(
|
|
|
467
569
|
if (userIndex >= 0) state.messages[userIndex] = user;
|
|
468
570
|
else state.messages.push(user);
|
|
469
571
|
|
|
470
|
-
|
|
471
|
-
|
|
572
|
+
replaceTurnMessages(
|
|
573
|
+
state.messages,
|
|
574
|
+
run.runId,
|
|
575
|
+
[...sendMessages(run), assistantMessage(run, notification)],
|
|
576
|
+
run.admittedAt,
|
|
472
577
|
);
|
|
473
|
-
const assistant = assistantMessage(run, notification);
|
|
474
|
-
const assistantAt =
|
|
475
|
-
run.admittedAt ??
|
|
476
|
-
(assistantIndex >= 0 ? state.messages[assistantIndex]?.at : undefined);
|
|
477
|
-
if (assistantAt) assistant.at = assistantAt;
|
|
478
|
-
if (assistantIndex >= 0) state.messages[assistantIndex] = assistant;
|
|
479
|
-
else state.messages.push(assistant);
|
|
480
578
|
|
|
481
579
|
activeRun = activeRunView(run) ?? activeRun;
|
|
482
580
|
if (run.status === "running" || run.status === "reconciliation-required") {
|
|
@@ -885,6 +983,26 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
885
983
|
* epoch is bumped at the boundary so those answers are dropped.
|
|
886
984
|
*/
|
|
887
985
|
let conversationGeneration = 0;
|
|
986
|
+
/*
|
|
987
|
+
* The conversations this client is still holding.
|
|
988
|
+
*
|
|
989
|
+
* Switching Bots used to be a blank thread and a read; the last few are now
|
|
990
|
+
* redrawn from memory and read back behind the paint. `transcriptEpochs`
|
|
991
|
+
* names the conversation each entry belongs to: the backend does not tell a
|
|
992
|
+
* client its Session id, but the client is the one that ends a conversation,
|
|
993
|
+
* so counting that action locally is the same boundary (ADR 0027).
|
|
994
|
+
*/
|
|
995
|
+
const transcripts = new TranscriptCache();
|
|
996
|
+
const transcriptEpochs = new Map<string, number>();
|
|
997
|
+
const conversationKeyFor = (botId: string): string =>
|
|
998
|
+
`${botId}#${transcriptEpochs.get(botId) ?? 0}`;
|
|
999
|
+
/*
|
|
1000
|
+
* The Bot whose first channel reset is already answered by the cache. A
|
|
1001
|
+
* socket opening emits an untopiced invalidation meaning "read everything";
|
|
1002
|
+
* for a transcript restored moments ago that read is the reload the User
|
|
1003
|
+
* asked us to stop doing. A real `runs` notice is never suppressed.
|
|
1004
|
+
*/
|
|
1005
|
+
let restoredWithoutRead: string | undefined;
|
|
888
1006
|
let userSettingsGeneration = 0;
|
|
889
1007
|
let pluginCatalogGeneration = 0;
|
|
890
1008
|
let packageCatalogGeneration = 0;
|
|
@@ -1028,11 +1146,10 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1028
1146
|
if (isTerminalRun(run)) return "admitted";
|
|
1029
1147
|
} catch (error) {
|
|
1030
1148
|
if (signal.aborted) return "detached";
|
|
1031
|
-
reconciliationError = `${
|
|
1032
|
-
error
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
} Retrying…`;
|
|
1149
|
+
reconciliationError = `${presentClientFailureV1(
|
|
1150
|
+
error,
|
|
1151
|
+
"check on your message",
|
|
1152
|
+
)} Retrying…`;
|
|
1036
1153
|
web.value.settingsError = reconciliationError;
|
|
1037
1154
|
}
|
|
1038
1155
|
const delayMs = uncertainAdmissionDelayMsV1(attempt);
|
|
@@ -1084,9 +1201,10 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1084
1201
|
if (isTerminalRun(run)) return;
|
|
1085
1202
|
} catch (error) {
|
|
1086
1203
|
if (signal.aborted) return;
|
|
1087
|
-
observationError = `${
|
|
1088
|
-
error
|
|
1089
|
-
|
|
1204
|
+
observationError = `${presentClientFailureV1(
|
|
1205
|
+
error,
|
|
1206
|
+
"load that reply",
|
|
1207
|
+
)} Retrying…`;
|
|
1090
1208
|
web.value.settingsError = observationError;
|
|
1091
1209
|
}
|
|
1092
1210
|
await waitForRunLookup(delayMs, signal);
|
|
@@ -1141,7 +1259,13 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1141
1259
|
web.value.settingsError = "A completed Bot result is waiting to load";
|
|
1142
1260
|
continue;
|
|
1143
1261
|
}
|
|
1144
|
-
|
|
1262
|
+
// The open Bot is only *read* while the tab is visible and the window
|
|
1263
|
+
// holds focus; `document.hidden` alone called a visible tab behind
|
|
1264
|
+
// another window "open", and the reply that landed there was never
|
|
1265
|
+
// heard about. One definition, shared with the sidebar's badge.
|
|
1266
|
+
if (
|
|
1267
|
+
shouldNotifyForBotV1(readViewerFocusV1(web.value.activeBotId), botId)
|
|
1268
|
+
) {
|
|
1145
1269
|
// One seam: the desktop or mobile notifications Package when the shell
|
|
1146
1270
|
// exposes it, the web API when it does not.
|
|
1147
1271
|
const delivery = await showClientNotificationV1({
|
|
@@ -1295,6 +1419,16 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1295
1419
|
connectionsAvailable: ctx.transport.connectionsAvailable !== false,
|
|
1296
1420
|
activeBotId: undefined,
|
|
1297
1421
|
composerContext: undefined,
|
|
1422
|
+
transcripts: {
|
|
1423
|
+
rememberViewport: (botId, viewport) =>
|
|
1424
|
+
transcripts.rememberViewport(botId, viewport),
|
|
1425
|
+
viewportFor: (botId) => transcripts.viewportFor(botId),
|
|
1426
|
+
forget: (botId) => {
|
|
1427
|
+
transcripts.forget(botId);
|
|
1428
|
+
if (botId === undefined || botId === restoredWithoutRead)
|
|
1429
|
+
restoredWithoutRead = undefined;
|
|
1430
|
+
},
|
|
1431
|
+
},
|
|
1298
1432
|
messages: [],
|
|
1299
1433
|
pluginCatalog: [],
|
|
1300
1434
|
packageCatalog: [],
|
|
@@ -1326,14 +1460,33 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1326
1460
|
admissionObserver?.abort();
|
|
1327
1461
|
runObserver?.abort();
|
|
1328
1462
|
selectionGeneration += 1;
|
|
1463
|
+
// The conversation being left is kept, so coming back to it is a paint
|
|
1464
|
+
// and not a reload. The thread writes its scroll position onto this
|
|
1465
|
+
// entry once Vue has flushed, while the DOM still holds it.
|
|
1466
|
+
const leaving = web.value.activeBotId;
|
|
1467
|
+
if (leaving) {
|
|
1468
|
+
transcripts.save(leaving, {
|
|
1469
|
+
conversationKey: conversationKeyFor(leaving),
|
|
1470
|
+
messages: web.value.messages.map((message) => toRaw(message)),
|
|
1471
|
+
...(web.value.activeRun ? { activeRun: web.value.activeRun } : {}),
|
|
1472
|
+
...(web.value.activeRunId
|
|
1473
|
+
? { activeRunId: web.value.activeRunId }
|
|
1474
|
+
: {}),
|
|
1475
|
+
...(web.value.runningRunId
|
|
1476
|
+
? { runningRunId: web.value.runningRunId }
|
|
1477
|
+
: {}),
|
|
1478
|
+
});
|
|
1479
|
+
}
|
|
1480
|
+
const restored = transcripts.take(botId, conversationKeyFor(botId));
|
|
1481
|
+
restoredWithoutRead = restored && !restored.stale ? botId : undefined;
|
|
1329
1482
|
web.value.activeBotId = botId;
|
|
1330
1483
|
web.value.composerContext = botId;
|
|
1331
1484
|
web.value.botSettings = undefined;
|
|
1332
1485
|
web.value.modelReady = false;
|
|
1333
|
-
web.value.messages = [];
|
|
1334
|
-
web.value.activeRun =
|
|
1335
|
-
web.value.activeRunId =
|
|
1336
|
-
web.value.runningRunId =
|
|
1486
|
+
web.value.messages = restored?.messages ?? [];
|
|
1487
|
+
web.value.activeRun = restored?.activeRun;
|
|
1488
|
+
web.value.activeRunId = restored?.activeRunId;
|
|
1489
|
+
web.value.runningRunId = restored?.runningRunId;
|
|
1337
1490
|
web.value.skillCatalog = [];
|
|
1338
1491
|
web.value.approvals = [];
|
|
1339
1492
|
web.value.tasks = [];
|
|
@@ -1380,6 +1533,11 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1380
1533
|
// Reads already in flight answer with the conversation that just ended;
|
|
1381
1534
|
// the epoch drops them instead of letting them redraw it.
|
|
1382
1535
|
conversationGeneration += 1;
|
|
1536
|
+
// The conversation just ended keeps none of this Bot's cache: its key
|
|
1537
|
+
// moves with it, and the transcript behind it is not this one.
|
|
1538
|
+
transcriptEpochs.set(botId, (transcriptEpochs.get(botId) ?? 0) + 1);
|
|
1539
|
+
transcripts.forget(botId);
|
|
1540
|
+
restoredWithoutRead = undefined;
|
|
1383
1541
|
runObserver?.abort();
|
|
1384
1542
|
runObserver = undefined;
|
|
1385
1543
|
web.value.messages = [];
|
|
@@ -1793,8 +1951,9 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1793
1951
|
return;
|
|
1794
1952
|
updateSettingsLoadError(
|
|
1795
1953
|
"bot",
|
|
1796
|
-
error
|
|
1954
|
+
presentClientFailureV1(error, "load this Bot's settings"),
|
|
1797
1955
|
);
|
|
1956
|
+
console.debug("bot settings load failed", clientFailureDetailV1(error));
|
|
1798
1957
|
}
|
|
1799
1958
|
},
|
|
1800
1959
|
async saveBotProfile(profile: BotProfile): Promise<void> {
|
|
@@ -1909,7 +2068,11 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1909
2068
|
if (generation !== userSettingsGeneration) return;
|
|
1910
2069
|
updateSettingsLoadError(
|
|
1911
2070
|
"user",
|
|
1912
|
-
error
|
|
2071
|
+
presentClientFailureV1(error, "load your settings"),
|
|
2072
|
+
);
|
|
2073
|
+
console.debug(
|
|
2074
|
+
"user settings load failed",
|
|
2075
|
+
clientFailureDetailV1(error),
|
|
1913
2076
|
);
|
|
1914
2077
|
}
|
|
1915
2078
|
},
|
|
@@ -1971,7 +2134,11 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1971
2134
|
if (catalogGeneration !== pluginCatalogGeneration) return;
|
|
1972
2135
|
updateSettingsLoadError(
|
|
1973
2136
|
"catalog",
|
|
1974
|
-
error
|
|
2137
|
+
presentClientFailureV1(error, "load your plugins"),
|
|
2138
|
+
);
|
|
2139
|
+
console.debug(
|
|
2140
|
+
"plugin catalog load failed",
|
|
2141
|
+
clientFailureDetailV1(error),
|
|
1975
2142
|
);
|
|
1976
2143
|
}
|
|
1977
2144
|
},
|
|
@@ -2088,18 +2255,21 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2088
2255
|
// fault, and the raw server sentence means nothing to a person — so it
|
|
2089
2256
|
// is translated here and the surface renders it instead of the
|
|
2090
2257
|
// "nothing matched your search" empty state.
|
|
2091
|
-
|
|
2092
|
-
|
|
2258
|
+
// The one server sentence worth reading is the one that says there is
|
|
2259
|
+
// nothing to read: everything else becomes the shared failure line,
|
|
2260
|
+
// because a raw fault text means nothing to the person looking at it.
|
|
2261
|
+
const detail = clientFailureDetailV1(error);
|
|
2093
2262
|
web.value.packageCatalog = [];
|
|
2094
2263
|
web.value.packageCatalogGeneration = undefined;
|
|
2095
2264
|
updateSettingsLoadError(
|
|
2096
2265
|
"package-catalog",
|
|
2097
2266
|
/catalog generation was not found|Package Catalog is not configured/.test(
|
|
2098
|
-
|
|
2267
|
+
detail,
|
|
2099
2268
|
)
|
|
2100
2269
|
? "No plugins are published for this deployment yet."
|
|
2101
|
-
:
|
|
2270
|
+
: presentClientFailureV1(error, "load the plugin catalog"),
|
|
2102
2271
|
);
|
|
2272
|
+
console.debug("package catalog load failed", detail);
|
|
2103
2273
|
}
|
|
2104
2274
|
},
|
|
2105
2275
|
async loadCatalogEntry(
|
|
@@ -2512,19 +2682,24 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2512
2682
|
message.runId = result.runId;
|
|
2513
2683
|
message.id = `${result.runId}:${message.role}`;
|
|
2514
2684
|
}
|
|
2515
|
-
|
|
2516
|
-
|
|
2685
|
+
// Projected exactly as the durable read projects it, from the same
|
|
2686
|
+
// events: one bubble per send, in order, and the model's own text
|
|
2687
|
+
// after them (issue 153). The POST's copy of a Turn and the transcript
|
|
2688
|
+
// read must draw the same thread, or the reply rearranges itself on
|
|
2689
|
+
// the next reload.
|
|
2690
|
+
const settled: ClientRun = {
|
|
2517
2691
|
runId: result.runId,
|
|
2518
|
-
|
|
2519
|
-
// The same rule the durable projection follows: a Turn that
|
|
2520
|
-
// delivered something speaks through its sends, not through the
|
|
2521
|
-
// model's own text (issue 153).
|
|
2522
|
-
text: sendsFrom(result.events).length > 0 ? "" : result.text,
|
|
2523
|
-
at: optimisticAt,
|
|
2692
|
+
input: text,
|
|
2524
2693
|
status: "completed",
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
}
|
|
2694
|
+
responseText: result.text,
|
|
2695
|
+
events: result.events,
|
|
2696
|
+
};
|
|
2697
|
+
replaceTurnMessages(
|
|
2698
|
+
web.value.messages,
|
|
2699
|
+
result.runId,
|
|
2700
|
+
[...sendMessages(settled), assistantMessage(settled, undefined)],
|
|
2701
|
+
optimisticAt,
|
|
2702
|
+
);
|
|
2528
2703
|
try {
|
|
2529
2704
|
await deliverNotifications(botId, generation);
|
|
2530
2705
|
} catch (error) {
|
|
@@ -2532,10 +2707,10 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2532
2707
|
generation === selectionGeneration &&
|
|
2533
2708
|
web.value.activeBotId === botId
|
|
2534
2709
|
)
|
|
2535
|
-
web.value.settingsError =
|
|
2536
|
-
error
|
|
2537
|
-
|
|
2538
|
-
|
|
2710
|
+
web.value.settingsError = presentClientFailureV1(
|
|
2711
|
+
error,
|
|
2712
|
+
"show this Bot's notification",
|
|
2713
|
+
);
|
|
2539
2714
|
}
|
|
2540
2715
|
return { accepted: true, runId: result.runId };
|
|
2541
2716
|
} catch (error) {
|
|
@@ -2562,10 +2737,12 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2562
2737
|
// draft back rather than the thread pretending it was sent.
|
|
2563
2738
|
if (isCertainSendRefusalV1(error)) {
|
|
2564
2739
|
removeMessages(web.value.messages, pendingRunId);
|
|
2740
|
+
// A refusal's own sentence is the one the person needs — the send
|
|
2741
|
+
// that was over the size limit is answered with the limit. Only a
|
|
2742
|
+
// refusal carries one; anything else falls back to the shared line.
|
|
2565
2743
|
const refusal =
|
|
2566
|
-
error
|
|
2567
|
-
|
|
2568
|
-
: "That message didn't go through. Try sending it again.";
|
|
2744
|
+
serverRefusalMessageV1(error) ??
|
|
2745
|
+
presentClientFailureV1(error, "send that message");
|
|
2569
2746
|
web.value.error = refusal;
|
|
2570
2747
|
return { accepted: false, error: refusal };
|
|
2571
2748
|
}
|
|
@@ -2581,16 +2758,23 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2581
2758
|
message.runId === pendingRunId && message.role === "user",
|
|
2582
2759
|
)?.at ?? optimisticAt,
|
|
2583
2760
|
);
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2761
|
+
replaceTurnMessages(
|
|
2762
|
+
web.value.messages,
|
|
2763
|
+
pendingRunId,
|
|
2764
|
+
[
|
|
2765
|
+
{
|
|
2766
|
+
id: `${pendingRunId}:assistant`,
|
|
2767
|
+
runId: pendingRunId,
|
|
2768
|
+
role: "assistant",
|
|
2769
|
+
text: "Checking whether your message went through…",
|
|
2770
|
+
at: placeholderAt,
|
|
2771
|
+
status: "interrupted",
|
|
2772
|
+
tools: [],
|
|
2773
|
+
sends: [],
|
|
2774
|
+
},
|
|
2775
|
+
],
|
|
2776
|
+
placeholderAt,
|
|
2777
|
+
);
|
|
2594
2778
|
if (aborted) {
|
|
2595
2779
|
web.value.error = undefined;
|
|
2596
2780
|
} else {
|
|
@@ -2616,17 +2800,24 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2616
2800
|
// have to improvise — and with the Turn no longer running, so Stop
|
|
2617
2801
|
// stops standing for a Turn nobody is executing.
|
|
2618
2802
|
if (disposition === "unreachable") {
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2803
|
+
replaceTurnMessages(
|
|
2804
|
+
web.value.messages,
|
|
2805
|
+
pendingRunId,
|
|
2806
|
+
[
|
|
2807
|
+
{
|
|
2808
|
+
id: `${pendingRunId}:assistant`,
|
|
2809
|
+
runId: pendingRunId,
|
|
2810
|
+
role: "assistant",
|
|
2811
|
+
text: UNREACHABLE_BOT_MESSAGE_V1,
|
|
2812
|
+
at: placeholderAt,
|
|
2813
|
+
status: "error",
|
|
2814
|
+
retry: "resend",
|
|
2815
|
+
tools: [],
|
|
2816
|
+
sends: [],
|
|
2817
|
+
},
|
|
2818
|
+
],
|
|
2819
|
+
placeholderAt,
|
|
2820
|
+
);
|
|
2630
2821
|
// The bubble is the report, and it is the one carrying the Retry.
|
|
2631
2822
|
// Saying the same sentence again in the banner above it is what the
|
|
2632
2823
|
// thread already looked like when it was broken — the same string
|
|
@@ -2638,22 +2829,35 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2638
2829
|
return { accepted: false, error: UNREACHABLE_BOT_MESSAGE_V1 };
|
|
2639
2830
|
}
|
|
2640
2831
|
if (disposition === "not-admitted") {
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
tools: [],
|
|
2649
|
-
sends: [],
|
|
2650
|
-
});
|
|
2651
|
-
web.value.error =
|
|
2832
|
+
// One affordance, not two. This used to be an assistant bubble
|
|
2833
|
+
// *and* a banner, both reading "Turn was not admitted." — the Bot
|
|
2834
|
+
// appearing to say a word the product does not use to a User whose
|
|
2835
|
+
// typing had already been thrown away. The draft comes back to the
|
|
2836
|
+
// composer (`FrockBotApp.sendMessage`), so sending again is the
|
|
2837
|
+
// retry, and one system line says so.
|
|
2838
|
+
const notAdmitted =
|
|
2652
2839
|
"Your message didn't go through. Try sending it again.";
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2840
|
+
replaceTurnMessages(
|
|
2841
|
+
web.value.messages,
|
|
2842
|
+
pendingRunId,
|
|
2843
|
+
[
|
|
2844
|
+
{
|
|
2845
|
+
id: `${pendingRunId}:assistant`,
|
|
2846
|
+
runId: pendingRunId,
|
|
2847
|
+
role: "system",
|
|
2848
|
+
text: notAdmitted,
|
|
2849
|
+
at: placeholderAt,
|
|
2850
|
+
status: "error",
|
|
2851
|
+
// The same affordance an unreachable send gets: the draft is
|
|
2852
|
+
// back in the composer, and this sends it again.
|
|
2853
|
+
retry: "resend",
|
|
2854
|
+
tools: [],
|
|
2855
|
+
sends: [],
|
|
2856
|
+
},
|
|
2857
|
+
],
|
|
2858
|
+
placeholderAt,
|
|
2859
|
+
);
|
|
2860
|
+
return { accepted: false, error: notAdmitted };
|
|
2657
2861
|
}
|
|
2658
2862
|
return { accepted: true, runId: pendingRunId };
|
|
2659
2863
|
} finally {
|
|
@@ -2691,16 +2895,18 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2691
2895
|
try {
|
|
2692
2896
|
await ctx.transport.reconcileRun(botId, runId);
|
|
2693
2897
|
} catch (error) {
|
|
2694
|
-
web.value.settingsError =
|
|
2695
|
-
error
|
|
2898
|
+
web.value.settingsError = presentClientFailureV1(
|
|
2899
|
+
error,
|
|
2900
|
+
"pick that reply back up",
|
|
2901
|
+
);
|
|
2696
2902
|
}
|
|
2697
2903
|
try {
|
|
2698
2904
|
await deliverNotifications(botId);
|
|
2699
2905
|
} catch (error) {
|
|
2700
|
-
web.value.settingsError =
|
|
2701
|
-
error
|
|
2702
|
-
|
|
2703
|
-
|
|
2906
|
+
web.value.settingsError = presentClientFailureV1(
|
|
2907
|
+
error,
|
|
2908
|
+
"refresh that reply",
|
|
2909
|
+
);
|
|
2704
2910
|
}
|
|
2705
2911
|
},
|
|
2706
2912
|
async stopRun(): Promise<void> {
|
|
@@ -2873,6 +3079,14 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2873
3079
|
web.value.activeBotId !== botId
|
|
2874
3080
|
)
|
|
2875
3081
|
return;
|
|
3082
|
+
// An untopiced reset on a transcript this switch restored from
|
|
3083
|
+
// memory is the reload the cache exists to avoid. It is answered
|
|
3084
|
+
// once and only once: the next reset reads like any other.
|
|
3085
|
+
if (topic === undefined && restoredWithoutRead === botId) {
|
|
3086
|
+
restoredWithoutRead = undefined;
|
|
3087
|
+
return;
|
|
3088
|
+
}
|
|
3089
|
+
restoredWithoutRead = undefined;
|
|
2876
3090
|
await deliverNotifications(botId, generation);
|
|
2877
3091
|
},
|
|
2878
3092
|
status() {
|
|
@@ -2937,15 +3151,46 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2937
3151
|
];
|
|
2938
3152
|
};
|
|
2939
3153
|
|
|
2940
|
-
|
|
3154
|
+
/**
|
|
3155
|
+
* Puts a Turn's Bot-side lines in the thread, in order, in one place.
|
|
3156
|
+
*
|
|
3157
|
+
* A Turn is one user message and then however many the Bot sent, so a merge
|
|
3158
|
+
* cannot key one bubble by run id and overwrite it — that is exactly how a
|
|
3159
|
+
* second `send_to_user` used to replace the first. Every Bot-side line the
|
|
3160
|
+
* run already has is lifted out and the new ones go back at the same
|
|
3161
|
+
* position, which keeps the sends in their durable order and keeps the whole
|
|
3162
|
+
* Turn together between the Turn before it and the Turn after it.
|
|
3163
|
+
*
|
|
3164
|
+
* Where a line already carried a timestamp and the caller offers none, the
|
|
3165
|
+
* one it had is kept: the thread sorts by time and a line must not jump.
|
|
3166
|
+
*/
|
|
3167
|
+
function replaceTurnMessages(
|
|
2941
3168
|
messages: WebChatMessage[],
|
|
2942
3169
|
runId: string,
|
|
2943
|
-
|
|
3170
|
+
replacements: WebChatMessage[],
|
|
3171
|
+
at?: string,
|
|
2944
3172
|
): void {
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
)
|
|
2948
|
-
|
|
3173
|
+
let start = -1;
|
|
3174
|
+
let existingAt: string | undefined;
|
|
3175
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
3176
|
+
const message = messages[index];
|
|
3177
|
+
if (!message || message.runId !== runId || message.role !== "assistant")
|
|
3178
|
+
continue;
|
|
3179
|
+
existingAt = message.at ?? existingAt;
|
|
3180
|
+
start = index;
|
|
3181
|
+
messages.splice(index, 1);
|
|
3182
|
+
}
|
|
3183
|
+
if (start < 0) {
|
|
3184
|
+
// No Bot-side line yet: it belongs directly after this Turn's prompt, so
|
|
3185
|
+
// an older Turn's reply can never come between them.
|
|
3186
|
+
const userIndex = messages.findIndex(
|
|
3187
|
+
(message) => message.runId === runId && message.role === "user",
|
|
3188
|
+
);
|
|
3189
|
+
start = userIndex >= 0 ? userIndex + 1 : messages.length;
|
|
3190
|
+
}
|
|
3191
|
+
const stamp = at ?? existingAt;
|
|
3192
|
+
if (stamp) for (const message of replacements) message.at = stamp;
|
|
3193
|
+
messages.splice(start, 0, ...replacements);
|
|
2949
3194
|
}
|
|
2950
3195
|
|
|
2951
3196
|
/** Takes back both optimistic lines of a send the Bot never admitted. */
|