@frockbot/plugin-shell 0.3.10 → 0.3.12
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 +34 -32
- package/src/agent.test.ts +66 -0
- package/src/agent.ts +107 -2
- package/src/backend-configuration.test.ts +15 -9
- package/src/backend-package-catalog.ts +75 -26
- package/src/backend-runner.ts +19 -2
- package/src/backend.ts +118 -27
- package/src/client/FrockBotApp.vue +405 -134
- package/src/client/activity-trail.test.ts +205 -0
- package/src/client/activity-trail.ts +227 -0
- package/src/client/index.test.ts +25 -5
- package/src/client/index.ts +191 -47
- 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 +134 -89
- 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.test.ts +26 -0
- package/src/notification-id.ts +0 -0
- package/src/run-protocol.test.ts +37 -0
- package/src/run-protocol.ts +148 -38
- package/src/settings-links.test.ts +8 -2
- package/src/settings-links.ts +17 -2
- package/src/shared.ts +36 -0
- package/src/unread.ts +23 -1
- package/tsconfig.json +1 -2
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,
|
|
@@ -86,6 +91,7 @@ import {
|
|
|
86
91
|
type WebTaskChip,
|
|
87
92
|
type WebToolActivity,
|
|
88
93
|
} from "../shared.js";
|
|
94
|
+
import { TranscriptCache } from "./transcript-cache.js";
|
|
89
95
|
import FrockBotApp from "./FrockBotApp.vue";
|
|
90
96
|
import PackageEntryTrigger from "./PackageEntryTrigger.vue";
|
|
91
97
|
import PackageIframeSettings from "./PackageIframeSettings.vue";
|
|
@@ -251,6 +257,11 @@ function activeRunView(run: ClientRun): WebActiveRun | undefined {
|
|
|
251
257
|
return {
|
|
252
258
|
runId: run.runId,
|
|
253
259
|
status: run.status,
|
|
260
|
+
// Never the backend's own sentence. What arrived here read
|
|
261
|
+
// `Model request "1c7dd68e-…" has no durable provider outcome:` — a
|
|
262
|
+
// UUID and two internal nouns, in the one place a User is told what
|
|
263
|
+
// happened to their reply. The raw text stays on the run for the
|
|
264
|
+
// console; the banner says what it means and offers the one action.
|
|
254
265
|
message: run.stopRequestedAt
|
|
255
266
|
? "Stopping…"
|
|
256
267
|
: "Something went wrong mid-reply. Try again to pick it up.",
|
|
@@ -345,7 +356,15 @@ function assistantMessage(
|
|
|
345
356
|
id: `${run.runId}:assistant`,
|
|
346
357
|
runId: run.runId,
|
|
347
358
|
role: "assistant",
|
|
348
|
-
|
|
359
|
+
/*
|
|
360
|
+
* A failure is not something the Bot said. The bubble holds the text
|
|
361
|
+
* the model actually produced — often none — and the notice under it
|
|
362
|
+
* says why the Turn ends there: what arrived here read `Model request
|
|
363
|
+
* "1c7dd68e-…" has no durable provider outcome`, in a bubble styled
|
|
364
|
+
* exactly like the Bot speaking.
|
|
365
|
+
*/
|
|
366
|
+
text: visibleAssistantText(run),
|
|
367
|
+
notice: "This reply stopped partway. Try again to continue it.",
|
|
349
368
|
status: "reconciliation-required",
|
|
350
369
|
tools: toolsFrom(run.events),
|
|
351
370
|
sends: sendsFrom(run.events),
|
|
@@ -375,7 +394,11 @@ function assistantMessage(
|
|
|
375
394
|
runId: run.runId,
|
|
376
395
|
role: "assistant",
|
|
377
396
|
text: run.responseText,
|
|
378
|
-
|
|
397
|
+
// The same sentence the reply-less failure gets. The durable failure
|
|
398
|
+
// text is a provider's, not the product's — `Bot turn ended with
|
|
399
|
+
// outcome model-error`, a status code, once a run UUID — and under a
|
|
400
|
+
// bubble it reads as part of what the Bot was saying.
|
|
401
|
+
notice: "This Bot couldn't finish its reply. Try again.",
|
|
379
402
|
status: "error",
|
|
380
403
|
tools: toolsFrom(run.events),
|
|
381
404
|
sends: sendsFrom(run.events),
|
|
@@ -388,8 +411,13 @@ function assistantMessage(
|
|
|
388
411
|
role: "assistant",
|
|
389
412
|
text:
|
|
390
413
|
run.status === "failed"
|
|
391
|
-
?
|
|
414
|
+
? visibleAssistantText(run)
|
|
392
415
|
: visibleAssistantText(run, notification?.body ?? ""),
|
|
416
|
+
// Why the Turn ends there, under whatever it had already said — never as
|
|
417
|
+
// the bubble's own text, which reads as the Bot saying it.
|
|
418
|
+
...(run.status === "failed"
|
|
419
|
+
? { notice: "This Bot couldn't finish its reply. Try again." }
|
|
420
|
+
: {}),
|
|
393
421
|
status: run.status === "failed" ? "error" : "completed",
|
|
394
422
|
tools: toolsFrom(run.events),
|
|
395
423
|
sends: sendsFrom(run.events),
|
|
@@ -411,7 +439,10 @@ export function projectAnnouncements(
|
|
|
411
439
|
id: announcement.announcementId,
|
|
412
440
|
runId: announcement.announcementId,
|
|
413
441
|
role: "system",
|
|
414
|
-
text:
|
|
442
|
+
text:
|
|
443
|
+
announcement.type === "conversation/compacted"
|
|
444
|
+
? COMPACTED_ANNOUNCEMENT_TEXT_V1
|
|
445
|
+
: `Renamed to ${announcement.to} by ${announcement.namedBy}`,
|
|
415
446
|
at: announcement.at,
|
|
416
447
|
status: "completed",
|
|
417
448
|
tools: [],
|
|
@@ -420,11 +451,34 @@ export function projectAnnouncements(
|
|
|
420
451
|
const index = messages.findIndex(
|
|
421
452
|
(candidate) => candidate.id === announcement.announcementId,
|
|
422
453
|
);
|
|
423
|
-
|
|
424
|
-
|
|
454
|
+
// Removed before it is placed, so a marker that already sits in the thread
|
|
455
|
+
// is re-seated rather than frozen wherever the first poll put it.
|
|
456
|
+
if (index >= 0) messages.splice(index, 1);
|
|
457
|
+
messages.splice(announcementIndex(messages, message.at ?? ""), 0, message);
|
|
425
458
|
}
|
|
426
459
|
}
|
|
427
460
|
|
|
461
|
+
/**
|
|
462
|
+
* Where a system line belongs among the Turns.
|
|
463
|
+
*
|
|
464
|
+
* Only the user message of a Turn carries a timestamp; everything the Bot
|
|
465
|
+
* wrote for that Turn follows it untimed. So an untimed message inherits the
|
|
466
|
+
* last timestamp seen, and the line is placed before the first message that is
|
|
467
|
+
* genuinely later than it. A marker for a range that has scrolled out of the
|
|
468
|
+
* transcript sorts before everything, which puts it at the top of what remains.
|
|
469
|
+
*/
|
|
470
|
+
function announcementIndex(
|
|
471
|
+
messages: readonly WebChatMessage[],
|
|
472
|
+
at: string,
|
|
473
|
+
): number {
|
|
474
|
+
let seen = "";
|
|
475
|
+
for (const [index, candidate] of messages.entries()) {
|
|
476
|
+
if (candidate.at) seen = candidate.at;
|
|
477
|
+
if (seen > at) return index;
|
|
478
|
+
}
|
|
479
|
+
return messages.length;
|
|
480
|
+
}
|
|
481
|
+
|
|
428
482
|
export function projectDurableRuns(
|
|
429
483
|
state: DurableRunProjectionState,
|
|
430
484
|
notifications: readonly ClientNotificationIntent[],
|
|
@@ -885,6 +939,26 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
885
939
|
* epoch is bumped at the boundary so those answers are dropped.
|
|
886
940
|
*/
|
|
887
941
|
let conversationGeneration = 0;
|
|
942
|
+
/*
|
|
943
|
+
* The conversations this client is still holding.
|
|
944
|
+
*
|
|
945
|
+
* Switching Bots used to be a blank thread and a read; the last few are now
|
|
946
|
+
* redrawn from memory and read back behind the paint. `transcriptEpochs`
|
|
947
|
+
* names the conversation each entry belongs to: the backend does not tell a
|
|
948
|
+
* client its Session id, but the client is the one that ends a conversation,
|
|
949
|
+
* so counting that action locally is the same boundary (ADR 0027).
|
|
950
|
+
*/
|
|
951
|
+
const transcripts = new TranscriptCache();
|
|
952
|
+
const transcriptEpochs = new Map<string, number>();
|
|
953
|
+
const conversationKeyFor = (botId: string): string =>
|
|
954
|
+
`${botId}#${transcriptEpochs.get(botId) ?? 0}`;
|
|
955
|
+
/*
|
|
956
|
+
* The Bot whose first channel reset is already answered by the cache. A
|
|
957
|
+
* socket opening emits an untopiced invalidation meaning "read everything";
|
|
958
|
+
* for a transcript restored moments ago that read is the reload the User
|
|
959
|
+
* asked us to stop doing. A real `runs` notice is never suppressed.
|
|
960
|
+
*/
|
|
961
|
+
let restoredWithoutRead: string | undefined;
|
|
888
962
|
let userSettingsGeneration = 0;
|
|
889
963
|
let pluginCatalogGeneration = 0;
|
|
890
964
|
let packageCatalogGeneration = 0;
|
|
@@ -1028,11 +1102,10 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1028
1102
|
if (isTerminalRun(run)) return "admitted";
|
|
1029
1103
|
} catch (error) {
|
|
1030
1104
|
if (signal.aborted) return "detached";
|
|
1031
|
-
reconciliationError = `${
|
|
1032
|
-
error
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
} Retrying…`;
|
|
1105
|
+
reconciliationError = `${presentClientFailureV1(
|
|
1106
|
+
error,
|
|
1107
|
+
"check on your message",
|
|
1108
|
+
)} Retrying…`;
|
|
1036
1109
|
web.value.settingsError = reconciliationError;
|
|
1037
1110
|
}
|
|
1038
1111
|
const delayMs = uncertainAdmissionDelayMsV1(attempt);
|
|
@@ -1084,9 +1157,10 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1084
1157
|
if (isTerminalRun(run)) return;
|
|
1085
1158
|
} catch (error) {
|
|
1086
1159
|
if (signal.aborted) return;
|
|
1087
|
-
observationError = `${
|
|
1088
|
-
error
|
|
1089
|
-
|
|
1160
|
+
observationError = `${presentClientFailureV1(
|
|
1161
|
+
error,
|
|
1162
|
+
"load that reply",
|
|
1163
|
+
)} Retrying…`;
|
|
1090
1164
|
web.value.settingsError = observationError;
|
|
1091
1165
|
}
|
|
1092
1166
|
await waitForRunLookup(delayMs, signal);
|
|
@@ -1141,7 +1215,13 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1141
1215
|
web.value.settingsError = "A completed Bot result is waiting to load";
|
|
1142
1216
|
continue;
|
|
1143
1217
|
}
|
|
1144
|
-
|
|
1218
|
+
// The open Bot is only *read* while the tab is visible and the window
|
|
1219
|
+
// holds focus; `document.hidden` alone called a visible tab behind
|
|
1220
|
+
// another window "open", and the reply that landed there was never
|
|
1221
|
+
// heard about. One definition, shared with the sidebar's badge.
|
|
1222
|
+
if (
|
|
1223
|
+
shouldNotifyForBotV1(readViewerFocusV1(web.value.activeBotId), botId)
|
|
1224
|
+
) {
|
|
1145
1225
|
// One seam: the desktop or mobile notifications Package when the shell
|
|
1146
1226
|
// exposes it, the web API when it does not.
|
|
1147
1227
|
const delivery = await showClientNotificationV1({
|
|
@@ -1295,6 +1375,16 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1295
1375
|
connectionsAvailable: ctx.transport.connectionsAvailable !== false,
|
|
1296
1376
|
activeBotId: undefined,
|
|
1297
1377
|
composerContext: undefined,
|
|
1378
|
+
transcripts: {
|
|
1379
|
+
rememberViewport: (botId, viewport) =>
|
|
1380
|
+
transcripts.rememberViewport(botId, viewport),
|
|
1381
|
+
viewportFor: (botId) => transcripts.viewportFor(botId),
|
|
1382
|
+
forget: (botId) => {
|
|
1383
|
+
transcripts.forget(botId);
|
|
1384
|
+
if (botId === undefined || botId === restoredWithoutRead)
|
|
1385
|
+
restoredWithoutRead = undefined;
|
|
1386
|
+
},
|
|
1387
|
+
},
|
|
1298
1388
|
messages: [],
|
|
1299
1389
|
pluginCatalog: [],
|
|
1300
1390
|
packageCatalog: [],
|
|
@@ -1326,14 +1416,33 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1326
1416
|
admissionObserver?.abort();
|
|
1327
1417
|
runObserver?.abort();
|
|
1328
1418
|
selectionGeneration += 1;
|
|
1419
|
+
// The conversation being left is kept, so coming back to it is a paint
|
|
1420
|
+
// and not a reload. The thread writes its scroll position onto this
|
|
1421
|
+
// entry once Vue has flushed, while the DOM still holds it.
|
|
1422
|
+
const leaving = web.value.activeBotId;
|
|
1423
|
+
if (leaving) {
|
|
1424
|
+
transcripts.save(leaving, {
|
|
1425
|
+
conversationKey: conversationKeyFor(leaving),
|
|
1426
|
+
messages: web.value.messages.map((message) => toRaw(message)),
|
|
1427
|
+
...(web.value.activeRun ? { activeRun: web.value.activeRun } : {}),
|
|
1428
|
+
...(web.value.activeRunId
|
|
1429
|
+
? { activeRunId: web.value.activeRunId }
|
|
1430
|
+
: {}),
|
|
1431
|
+
...(web.value.runningRunId
|
|
1432
|
+
? { runningRunId: web.value.runningRunId }
|
|
1433
|
+
: {}),
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
const restored = transcripts.take(botId, conversationKeyFor(botId));
|
|
1437
|
+
restoredWithoutRead = restored && !restored.stale ? botId : undefined;
|
|
1329
1438
|
web.value.activeBotId = botId;
|
|
1330
1439
|
web.value.composerContext = botId;
|
|
1331
1440
|
web.value.botSettings = undefined;
|
|
1332
1441
|
web.value.modelReady = false;
|
|
1333
|
-
web.value.messages = [];
|
|
1334
|
-
web.value.activeRun =
|
|
1335
|
-
web.value.activeRunId =
|
|
1336
|
-
web.value.runningRunId =
|
|
1442
|
+
web.value.messages = restored?.messages ?? [];
|
|
1443
|
+
web.value.activeRun = restored?.activeRun;
|
|
1444
|
+
web.value.activeRunId = restored?.activeRunId;
|
|
1445
|
+
web.value.runningRunId = restored?.runningRunId;
|
|
1337
1446
|
web.value.skillCatalog = [];
|
|
1338
1447
|
web.value.approvals = [];
|
|
1339
1448
|
web.value.tasks = [];
|
|
@@ -1380,6 +1489,11 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1380
1489
|
// Reads already in flight answer with the conversation that just ended;
|
|
1381
1490
|
// the epoch drops them instead of letting them redraw it.
|
|
1382
1491
|
conversationGeneration += 1;
|
|
1492
|
+
// The conversation just ended keeps none of this Bot's cache: its key
|
|
1493
|
+
// moves with it, and the transcript behind it is not this one.
|
|
1494
|
+
transcriptEpochs.set(botId, (transcriptEpochs.get(botId) ?? 0) + 1);
|
|
1495
|
+
transcripts.forget(botId);
|
|
1496
|
+
restoredWithoutRead = undefined;
|
|
1383
1497
|
runObserver?.abort();
|
|
1384
1498
|
runObserver = undefined;
|
|
1385
1499
|
web.value.messages = [];
|
|
@@ -1793,8 +1907,9 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1793
1907
|
return;
|
|
1794
1908
|
updateSettingsLoadError(
|
|
1795
1909
|
"bot",
|
|
1796
|
-
error
|
|
1910
|
+
presentClientFailureV1(error, "load this Bot's settings"),
|
|
1797
1911
|
);
|
|
1912
|
+
console.debug("bot settings load failed", clientFailureDetailV1(error));
|
|
1798
1913
|
}
|
|
1799
1914
|
},
|
|
1800
1915
|
async saveBotProfile(profile: BotProfile): Promise<void> {
|
|
@@ -1909,7 +2024,11 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1909
2024
|
if (generation !== userSettingsGeneration) return;
|
|
1910
2025
|
updateSettingsLoadError(
|
|
1911
2026
|
"user",
|
|
1912
|
-
error
|
|
2027
|
+
presentClientFailureV1(error, "load your settings"),
|
|
2028
|
+
);
|
|
2029
|
+
console.debug(
|
|
2030
|
+
"user settings load failed",
|
|
2031
|
+
clientFailureDetailV1(error),
|
|
1913
2032
|
);
|
|
1914
2033
|
}
|
|
1915
2034
|
},
|
|
@@ -1971,7 +2090,11 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1971
2090
|
if (catalogGeneration !== pluginCatalogGeneration) return;
|
|
1972
2091
|
updateSettingsLoadError(
|
|
1973
2092
|
"catalog",
|
|
1974
|
-
error
|
|
2093
|
+
presentClientFailureV1(error, "load your plugins"),
|
|
2094
|
+
);
|
|
2095
|
+
console.debug(
|
|
2096
|
+
"plugin catalog load failed",
|
|
2097
|
+
clientFailureDetailV1(error),
|
|
1975
2098
|
);
|
|
1976
2099
|
}
|
|
1977
2100
|
},
|
|
@@ -2088,18 +2211,21 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2088
2211
|
// fault, and the raw server sentence means nothing to a person — so it
|
|
2089
2212
|
// is translated here and the surface renders it instead of the
|
|
2090
2213
|
// "nothing matched your search" empty state.
|
|
2091
|
-
|
|
2092
|
-
|
|
2214
|
+
// The one server sentence worth reading is the one that says there is
|
|
2215
|
+
// nothing to read: everything else becomes the shared failure line,
|
|
2216
|
+
// because a raw fault text means nothing to the person looking at it.
|
|
2217
|
+
const detail = clientFailureDetailV1(error);
|
|
2093
2218
|
web.value.packageCatalog = [];
|
|
2094
2219
|
web.value.packageCatalogGeneration = undefined;
|
|
2095
2220
|
updateSettingsLoadError(
|
|
2096
2221
|
"package-catalog",
|
|
2097
2222
|
/catalog generation was not found|Package Catalog is not configured/.test(
|
|
2098
|
-
|
|
2223
|
+
detail,
|
|
2099
2224
|
)
|
|
2100
2225
|
? "No plugins are published for this deployment yet."
|
|
2101
|
-
:
|
|
2226
|
+
: presentClientFailureV1(error, "load the plugin catalog"),
|
|
2102
2227
|
);
|
|
2228
|
+
console.debug("package catalog load failed", detail);
|
|
2103
2229
|
}
|
|
2104
2230
|
},
|
|
2105
2231
|
async loadCatalogEntry(
|
|
@@ -2532,10 +2658,10 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2532
2658
|
generation === selectionGeneration &&
|
|
2533
2659
|
web.value.activeBotId === botId
|
|
2534
2660
|
)
|
|
2535
|
-
web.value.settingsError =
|
|
2536
|
-
error
|
|
2537
|
-
|
|
2538
|
-
|
|
2661
|
+
web.value.settingsError = presentClientFailureV1(
|
|
2662
|
+
error,
|
|
2663
|
+
"show this Bot's notification",
|
|
2664
|
+
);
|
|
2539
2665
|
}
|
|
2540
2666
|
return { accepted: true, runId: result.runId };
|
|
2541
2667
|
} catch (error) {
|
|
@@ -2562,10 +2688,12 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2562
2688
|
// draft back rather than the thread pretending it was sent.
|
|
2563
2689
|
if (isCertainSendRefusalV1(error)) {
|
|
2564
2690
|
removeMessages(web.value.messages, pendingRunId);
|
|
2691
|
+
// A refusal's own sentence is the one the person needs — the send
|
|
2692
|
+
// that was over the size limit is answered with the limit. Only a
|
|
2693
|
+
// refusal carries one; anything else falls back to the shared line.
|
|
2565
2694
|
const refusal =
|
|
2566
|
-
error
|
|
2567
|
-
|
|
2568
|
-
: "That message didn't go through. Try sending it again.";
|
|
2695
|
+
serverRefusalMessageV1(error) ??
|
|
2696
|
+
presentClientFailureV1(error, "send that message");
|
|
2569
2697
|
web.value.error = refusal;
|
|
2570
2698
|
return { accepted: false, error: refusal };
|
|
2571
2699
|
}
|
|
@@ -2638,22 +2766,28 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2638
2766
|
return { accepted: false, error: UNREACHABLE_BOT_MESSAGE_V1 };
|
|
2639
2767
|
}
|
|
2640
2768
|
if (disposition === "not-admitted") {
|
|
2769
|
+
// One affordance, not two. This used to be an assistant bubble
|
|
2770
|
+
// *and* a banner, both reading "Turn was not admitted." — the Bot
|
|
2771
|
+
// appearing to say a word the product does not use to a User whose
|
|
2772
|
+
// typing had already been thrown away. The draft comes back to the
|
|
2773
|
+
// composer (`FrockBotApp.sendMessage`), so sending again is the
|
|
2774
|
+
// retry, and one system line says so.
|
|
2775
|
+
const notAdmitted =
|
|
2776
|
+
"Your message didn't go through. Try sending it again.";
|
|
2641
2777
|
replaceMessage(web.value.messages, pendingRunId, {
|
|
2642
2778
|
id: `${pendingRunId}:assistant`,
|
|
2643
2779
|
runId: pendingRunId,
|
|
2644
|
-
role: "
|
|
2645
|
-
text:
|
|
2780
|
+
role: "system",
|
|
2781
|
+
text: notAdmitted,
|
|
2646
2782
|
at: placeholderAt,
|
|
2647
2783
|
status: "error",
|
|
2784
|
+
// The same affordance an unreachable send gets: the draft is back
|
|
2785
|
+
// in the composer, and this sends it again.
|
|
2786
|
+
retry: "resend",
|
|
2648
2787
|
tools: [],
|
|
2649
2788
|
sends: [],
|
|
2650
2789
|
});
|
|
2651
|
-
|
|
2652
|
-
"Your message didn't go through. Try sending it again.";
|
|
2653
|
-
return {
|
|
2654
|
-
accepted: false,
|
|
2655
|
-
error: "Your message didn't go through. Try sending it again.",
|
|
2656
|
-
};
|
|
2790
|
+
return { accepted: false, error: notAdmitted };
|
|
2657
2791
|
}
|
|
2658
2792
|
return { accepted: true, runId: pendingRunId };
|
|
2659
2793
|
} finally {
|
|
@@ -2691,16 +2825,18 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2691
2825
|
try {
|
|
2692
2826
|
await ctx.transport.reconcileRun(botId, runId);
|
|
2693
2827
|
} catch (error) {
|
|
2694
|
-
web.value.settingsError =
|
|
2695
|
-
error
|
|
2828
|
+
web.value.settingsError = presentClientFailureV1(
|
|
2829
|
+
error,
|
|
2830
|
+
"pick that reply back up",
|
|
2831
|
+
);
|
|
2696
2832
|
}
|
|
2697
2833
|
try {
|
|
2698
2834
|
await deliverNotifications(botId);
|
|
2699
2835
|
} catch (error) {
|
|
2700
|
-
web.value.settingsError =
|
|
2701
|
-
error
|
|
2702
|
-
|
|
2703
|
-
|
|
2836
|
+
web.value.settingsError = presentClientFailureV1(
|
|
2837
|
+
error,
|
|
2838
|
+
"refresh that reply",
|
|
2839
|
+
);
|
|
2704
2840
|
}
|
|
2705
2841
|
},
|
|
2706
2842
|
async stopRun(): Promise<void> {
|
|
@@ -2873,6 +3009,14 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2873
3009
|
web.value.activeBotId !== botId
|
|
2874
3010
|
)
|
|
2875
3011
|
return;
|
|
3012
|
+
// An untopiced reset on a transcript this switch restored from
|
|
3013
|
+
// memory is the reload the cache exists to avoid. It is answered
|
|
3014
|
+
// once and only once: the next reset reads like any other.
|
|
3015
|
+
if (topic === undefined && restoredWithoutRead === botId) {
|
|
3016
|
+
restoredWithoutRead = undefined;
|
|
3017
|
+
return;
|
|
3018
|
+
}
|
|
3019
|
+
restoredWithoutRead = undefined;
|
|
2876
3020
|
await deliverNotifications(botId, generation);
|
|
2877
3021
|
},
|
|
2878
3022
|
status() {
|
|
@@ -42,11 +42,11 @@ describe("model runtime presentation", () => {
|
|
|
42
42
|
modelRuntimeLabel({
|
|
43
43
|
source: "platform",
|
|
44
44
|
modelDisplayName: "Auto",
|
|
45
|
-
providerModelId: "@
|
|
46
|
-
packageDisplayName: "
|
|
45
|
+
providerModelId: "@frock/auto",
|
|
46
|
+
packageDisplayName: "Frock AI",
|
|
47
47
|
fallback: true,
|
|
48
48
|
}),
|
|
49
|
-
).toBe("Auto ·
|
|
49
|
+
).toBe("Auto · Frock AI · your chosen model is unavailable");
|
|
50
50
|
});
|
|
51
51
|
|
|
52
52
|
test("shows unavailable and backend failure states", () => {
|
|
@@ -20,7 +20,7 @@ const { shellClientPlugin } = await import("./index.js");
|
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* A first-run account: the platform model resolves against a ready ambient
|
|
23
|
-
*
|
|
23
|
+
* Frock AI Connection whose Catalog is fresh, and no Bot has been created yet.
|
|
24
24
|
* The account's model is available, so the shell must not tell the User it is
|
|
25
25
|
* unavailable before they have made their first Bot.
|
|
26
26
|
*/
|
|
@@ -37,7 +37,7 @@ test("does not report the account model unavailable before a Bot exists", async
|
|
|
37
37
|
connectionId: "flock-ai-ambient",
|
|
38
38
|
packageId: "provider-flock-ai",
|
|
39
39
|
connectionTypeId: "flock-ai-account",
|
|
40
|
-
displayName: "
|
|
40
|
+
displayName: "Frock AI",
|
|
41
41
|
state: "ready",
|
|
42
42
|
providerType: "flock-ai",
|
|
43
43
|
safeMetadata: {},
|
|
@@ -47,7 +47,7 @@ test("does not report the account model unavailable before a Bot exists", async
|
|
|
47
47
|
state: "fresh",
|
|
48
48
|
models: [
|
|
49
49
|
{
|
|
50
|
-
providerModelId: "@
|
|
50
|
+
providerModelId: "@frock/auto",
|
|
51
51
|
displayName: "Auto (recommended)",
|
|
52
52
|
capabilities: { tools: true, vision: false, reasoning: true },
|
|
53
53
|
source: "discovered",
|
|
@@ -58,7 +58,7 @@ test("does not report the account model unavailable before a Bot exists", async
|
|
|
58
58
|
],
|
|
59
59
|
platformModel: {
|
|
60
60
|
connectionId: "flock-ai-ambient",
|
|
61
|
-
providerModelId: "@
|
|
61
|
+
providerModelId: "@frock/auto",
|
|
62
62
|
},
|
|
63
63
|
};
|
|
64
64
|
|
|
@@ -74,7 +74,7 @@ test("does not report the account model unavailable before a Bot exists", async
|
|
|
74
74
|
packages: [
|
|
75
75
|
{
|
|
76
76
|
id: "provider-flock-ai",
|
|
77
|
-
displayName: "
|
|
77
|
+
displayName: "Frock AI",
|
|
78
78
|
version: "0.0.1",
|
|
79
79
|
contributions: ["backend", "runtime"],
|
|
80
80
|
configuration: {
|
|
@@ -82,7 +82,7 @@ test("does not report the account model unavailable before a Bot exists", async
|
|
|
82
82
|
connectionTypes: [
|
|
83
83
|
{
|
|
84
84
|
id: "flock-ai-account",
|
|
85
|
-
displayName: "
|
|
85
|
+
displayName: "Frock AI",
|
|
86
86
|
allowMultiple: false,
|
|
87
87
|
authorization: { kind: "ambient-native" },
|
|
88
88
|
capabilities: ["flock-ai-models"],
|
|
@@ -117,6 +117,6 @@ test("does not report the account model unavailable before a Bot exists", async
|
|
|
117
117
|
|
|
118
118
|
// No Bot has been created, so `activeBotId` is unset.
|
|
119
119
|
expect(provided.value.activeBotId).toBeUndefined();
|
|
120
|
-
expect(provided.value.modelLabel).toBe("Auto (recommended) ·
|
|
120
|
+
expect(provided.value.modelLabel).toBe("Auto (recommended) · Frock AI");
|
|
121
121
|
expect(provided.value.modelReady).toBe(true);
|
|
122
122
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import type { ClientSkillCatalogEntryV1 } from "../skill-protocol.js";
|
|
3
3
|
import {
|
|
4
|
+
keptSkillHighlightV1,
|
|
4
5
|
nextSkillHighlightV1,
|
|
5
6
|
rankSkillCandidatesV1,
|
|
6
7
|
SkillAttachmentStore,
|
|
@@ -140,4 +141,37 @@ describe("keyboard navigation", () => {
|
|
|
140
141
|
expect(nextSkillHighlightV1(0, 3, -1)).toBe(2);
|
|
141
142
|
expect(nextSkillHighlightV1(0, 0, 1)).toBe(0);
|
|
142
143
|
});
|
|
144
|
+
|
|
145
|
+
test("a refilter keeps the highlight on the Skill it was on", () => {
|
|
146
|
+
const ranked = rankSkillCandidatesV1(catalog, "standup");
|
|
147
|
+
// The composer refreshes the popover on every keyup, the arrow key's own
|
|
148
|
+
// keyup included. Row two has to survive that refresh, or the highlight
|
|
149
|
+
// snaps back to the first row and the arrow keys look dead.
|
|
150
|
+
const highlighted = ranked[1]!.entry.ref;
|
|
151
|
+
expect(keptSkillHighlightV1(highlighted, ranked)).toBe(1);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("a narrower query keeps the highlight when the Skill is still offered", () => {
|
|
155
|
+
const before = rankSkillCandidatesV1(catalog, "s");
|
|
156
|
+
const highlighted = before.find(
|
|
157
|
+
(candidate) => candidate.entry.ref === "bot/standup-notes",
|
|
158
|
+
)!.entry.ref;
|
|
159
|
+
const after = rankSkillCandidatesV1(catalog, "standup-n");
|
|
160
|
+
expect(after.map((candidate) => candidate.entry.ref)).toContain(
|
|
161
|
+
highlighted,
|
|
162
|
+
);
|
|
163
|
+
expect(keptSkillHighlightV1(highlighted, after)).toBe(
|
|
164
|
+
after.findIndex((candidate) => candidate.entry.ref === highlighted),
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("falls back to the first row once the highlighted Skill is gone", () => {
|
|
169
|
+
const ranked = rankSkillCandidatesV1(catalog, "standup");
|
|
170
|
+
expect(keptSkillHighlightV1("bot/weekly-report", ranked)).toBe(0);
|
|
171
|
+
expect(keptSkillHighlightV1(undefined, ranked)).toBe(0);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("an empty list has no highlight to keep", () => {
|
|
175
|
+
expect(keptSkillHighlightV1("bot/daily-standup", [])).toBe(0);
|
|
176
|
+
});
|
|
143
177
|
});
|
|
@@ -164,6 +164,28 @@ export class SkillAttachmentStore {
|
|
|
164
164
|
}
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
/**
|
|
168
|
+
* The highlight to keep once the candidate list has been recomputed.
|
|
169
|
+
*
|
|
170
|
+
* The popover is refreshed from the composer's own `keyup` — including the
|
|
171
|
+
* `keyup` of the arrow key that has just moved the highlight — so a refresh
|
|
172
|
+
* that reset the highlight to the first row made the arrow keys look like they
|
|
173
|
+
* did nothing at all. The highlight is carried by ref rather than by index: the
|
|
174
|
+
* Skill under it keeps its place for as long as the query still offers it, and
|
|
175
|
+
* only a Skill that has dropped out of the list hands the highlight back to the
|
|
176
|
+
* first row.
|
|
177
|
+
*/
|
|
178
|
+
export function keptSkillHighlightV1(
|
|
179
|
+
highlightedRef: string | undefined,
|
|
180
|
+
candidates: readonly SkillCandidateV1[],
|
|
181
|
+
): number {
|
|
182
|
+
if (candidates.length === 0) return 0;
|
|
183
|
+
const index = candidates.findIndex(
|
|
184
|
+
(candidate) => candidate.entry.ref === highlightedRef,
|
|
185
|
+
);
|
|
186
|
+
return index === -1 ? 0 : index;
|
|
187
|
+
}
|
|
188
|
+
|
|
167
189
|
/** Moves the popover's highlight, wrapping at both ends. */
|
|
168
190
|
export function nextSkillHighlightV1(
|
|
169
191
|
highlighted: number,
|