@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.
Files changed (42) hide show
  1. package/package.json +35 -33
  2. package/src/agent.test.ts +78 -0
  3. package/src/agent.ts +130 -2
  4. package/src/backend-configuration.test.ts +26 -26
  5. package/src/backend-recovery-integration.test.ts +10 -10
  6. package/src/backend-runner.ts +19 -2
  7. package/src/backend.ts +85 -18
  8. package/src/client/AppletCanvas.vue +19 -6
  9. package/src/client/FrockBotApp.vue +405 -75
  10. package/src/client/activity-trail.test.ts +205 -0
  11. package/src/client/activity-trail.ts +227 -0
  12. package/src/client/applets-client.test.ts +62 -0
  13. package/src/client/applets-client.ts +19 -0
  14. package/src/client/index.test.ts +128 -21
  15. package/src/client/index.ts +359 -114
  16. package/src/client/model-presentation.test.ts +3 -3
  17. package/src/client/no-bot-model-label.test.ts +7 -7
  18. package/src/client/skill-invocation.test.ts +34 -0
  19. package/src/client/skill-invocation.ts +22 -0
  20. package/src/client/styles.css +69 -19
  21. package/src/client/transcript-cache.test.ts +125 -0
  22. package/src/client/transcript-cache.ts +190 -0
  23. package/src/compaction-scheduler.test.ts +96 -0
  24. package/src/compaction-scheduler.ts +108 -0
  25. package/src/compaction-transcript.test.ts +174 -0
  26. package/src/compaction.test.ts +596 -0
  27. package/src/compaction.ts +539 -0
  28. package/src/focus.test.ts +222 -0
  29. package/src/focus.ts +93 -0
  30. package/src/history.ts +86 -8
  31. package/src/legacy-frock-model-id.test.ts +148 -0
  32. package/src/notification-id.ts +0 -0
  33. package/src/run-failure-copy.test.ts +150 -0
  34. package/src/run-failure-copy.ts +110 -0
  35. package/src/run-protocol.test.ts +50 -7
  36. package/src/run-protocol.ts +152 -43
  37. package/src/settings-links.test.ts +8 -2
  38. package/src/settings-links.ts +11 -2
  39. package/src/shared.ts +36 -0
  40. package/tsconfig.json +1 -2
  41. package/src/client/activity-ring.test.ts +0 -89
  42. package/src/client/activity-ring.ts +0 -94
@@ -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 the same send gate applies to
290
- * both: a Turn that has already delivered a bubble streams nothing into a
291
- * second one.
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: sendsFrom(run.events),
378
+ sends: [],
325
379
  tasks: tasksFrom(run.events),
326
380
  };
327
381
  }
328
382
  if (run.status === "superseded") {
329
- // The same quiet treatment a stopped Turn gets. It keeps everything it
330
- // already sent; the line only says why it ends where it does.
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: sendsFrom(run.events),
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
- text: "This reply stopped partway. Try again to continue it.",
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: sendsFrom(run.events),
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: sendsFrom(run.events),
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
- notice: run.failure ?? "Agent request failed.",
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: sendsFrom(run.events),
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
- ? "This Bot couldn't finish its reply. Try again."
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: sendsFrom(run.events),
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: `Renamed to ${announcement.to} by ${announcement.namedBy}`,
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
- if (index >= 0) messages[index] = message;
424
- else messages.push(message);
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
- const assistantIndex = state.messages.findIndex(
471
- (message) => message.runId === run.runId && message.role === "assistant",
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 instanceof Error
1033
- ? error.message
1034
- : "Couldn't check on your message."
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 instanceof Error ? error.message : "Couldn't load that reply."
1089
- } Retrying…`;
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
- if (document.hidden) {
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 = undefined;
1335
- web.value.activeRunId = undefined;
1336
- web.value.runningRunId = undefined;
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 instanceof Error ? error.message : "Could not load settings",
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 instanceof Error ? error.message : "Could not load settings",
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 instanceof Error ? error.message : "Could not load Plugins",
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
- const raw =
2092
- error instanceof Error ? error.message : "Could not load the Catalog";
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
- raw,
2267
+ detail,
2099
2268
  )
2100
2269
  ? "No plugins are published for this deployment yet."
2101
- : `Plugins could not be loaded: ${raw}`,
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
- replaceMessage(web.value.messages, result.runId, {
2516
- id: `${result.runId}:assistant`,
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
- role: "assistant",
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
- tools: toolsFrom(result.events),
2526
- sends: sendsFrom(result.events),
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 instanceof Error
2537
- ? error.message
2538
- : "Notification delivery failed";
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 instanceof Error && error.message
2567
- ? error.message
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
- replaceMessage(web.value.messages, pendingRunId, {
2585
- id: `${pendingRunId}:assistant`,
2586
- runId: pendingRunId,
2587
- role: "assistant",
2588
- text: "Checking whether your message went through…",
2589
- at: placeholderAt,
2590
- status: "interrupted",
2591
- tools: [],
2592
- sends: [],
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
- replaceMessage(web.value.messages, pendingRunId, {
2620
- id: `${pendingRunId}:assistant`,
2621
- runId: pendingRunId,
2622
- role: "assistant",
2623
- text: UNREACHABLE_BOT_MESSAGE_V1,
2624
- at: placeholderAt,
2625
- status: "error",
2626
- retry: "resend",
2627
- tools: [],
2628
- sends: [],
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
- replaceMessage(web.value.messages, pendingRunId, {
2642
- id: `${pendingRunId}:assistant`,
2643
- runId: pendingRunId,
2644
- role: "assistant",
2645
- text: "Your message didn't go through. Try sending it again.",
2646
- at: placeholderAt,
2647
- status: "error",
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
- return {
2654
- accepted: false,
2655
- error: "Your message didn't go through. Try sending it again.",
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 instanceof Error ? error.message : "Couldn't retry that.";
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 instanceof Error
2702
- ? error.message
2703
- : "Couldn't refresh this reply.";
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
- function replaceMessage(
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
- replacement: WebChatMessage,
3170
+ replacements: WebChatMessage[],
3171
+ at?: string,
2944
3172
  ): void {
2945
- const index = messages.findIndex(
2946
- (message) => message.runId === runId && message.role === "assistant",
2947
- );
2948
- if (index >= 0) messages[index] = replacement;
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. */