@frockbot/plugin-shell 0.3.5 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -55,6 +55,7 @@ import { decodeStartConnectionResultV1 } from "@frockbot/connection-core";
55
55
  import { decodeClientSkillCatalogV1 } from "../skill-protocol.js";
56
56
  import {
57
57
  ClientTurnRefusedErrorV1,
58
+ type ClientTurnRefusalReasonV1,
58
59
  decodeClientTurnV1,
59
60
  } from "../run-protocol.js";
60
61
  import {
@@ -243,10 +244,8 @@ function activeRunView(run: ClientRun): WebActiveRun | undefined {
243
244
  runId: run.runId,
244
245
  status: run.status,
245
246
  message: run.stopRequestedAt
246
- ? "Stop accepted; reconciling the provider outcome before cancelling."
247
- : (run.recovery?.message ??
248
- run.failure ??
249
- "This Turn requires provider reconciliation before it can continue."),
247
+ ? "Stopping…"
248
+ : "Something went wrong mid-reply. Try again to pick it up.",
250
249
  // Offered whenever the run is parked, Stop included. Hiding it there
251
250
  // hid it in exactly the case Stop creates: a Turn that was stopped
252
251
  // while the model was mid-answer parks, and the person was left with a
@@ -257,6 +256,31 @@ function activeRunView(run: ClientRun): WebActiveRun | undefined {
257
256
  return undefined;
258
257
  }
259
258
 
259
+ /**
260
+ * What a refused send tells the person. The refusal's own `error` names the
261
+ * durable invariant that declined it, which the debug surface needs and the
262
+ * composer does not, so the typed reason picks the sentence instead.
263
+ */
264
+ function turnRefusalCopyV1(reason: ClientTurnRefusalReasonV1): string {
265
+ if (reason === "busy")
266
+ return "This Bot is still working on your last message.";
267
+ if (reason === "reconciliation-required")
268
+ return "This Bot's last reply stopped partway. Try again to continue it.";
269
+ if (reason === "duplicate") return "That message was already sent.";
270
+ return "That message didn't go through. Try sending it again.";
271
+ }
272
+
273
+ /**
274
+ * The Bot's voice is its sends. When a Turn delivered anything to the User the
275
+ * model's own assistant text is scratch space and the thread does not draw it
276
+ * (issue 153): drawing both is how a one-word reply arrived twice, once as the
277
+ * model's text and once as the bubble that was actually delivered.
278
+ */
279
+ function visibleAssistantText(run: ClientRun, fallback = ""): string {
280
+ if (sendsFrom(run.events).length > 0) return "";
281
+ return run.responseText ?? fallback;
282
+ }
283
+
260
284
  function isTerminalRun(run: ClientRun): boolean {
261
285
  return (
262
286
  run.status === "completed" ||
@@ -277,7 +301,7 @@ function assistantMessage(
277
301
  id: `${run.runId}:assistant`,
278
302
  runId: run.runId,
279
303
  role: "assistant",
280
- text: run.responseText ?? "",
304
+ text: visibleAssistantText(run),
281
305
  status: "streaming",
282
306
  // A Turn that has not started shows nothing of its own: the greyed user
283
307
  // message is the whole of what the thread says about it.
@@ -294,8 +318,8 @@ function assistantMessage(
294
318
  id: `${run.runId}:assistant`,
295
319
  runId: run.runId,
296
320
  role: "assistant",
297
- text: run.responseText ?? "",
298
- notice: run.failure ?? "Interrupted by your next message.",
321
+ text: visibleAssistantText(run),
322
+ notice: "Interrupted by your next message.",
299
323
  status: "aborted",
300
324
  tools: toolsFrom(run.events),
301
325
  sends: sendsFrom(run.events),
@@ -307,10 +331,7 @@ function assistantMessage(
307
331
  id: `${run.runId}:assistant`,
308
332
  runId: run.runId,
309
333
  role: "assistant",
310
- text:
311
- run.recovery?.message ??
312
- run.failure ??
313
- "Provider reconciliation is required before this Turn can continue.",
334
+ text: "This reply stopped partway. Try again to continue it.",
314
335
  status: "reconciliation-required",
315
336
  tools: toolsFrom(run.events),
316
337
  sends: sendsFrom(run.events),
@@ -322,22 +343,39 @@ function assistantMessage(
322
343
  id: `${run.runId}:assistant`,
323
344
  runId: run.runId,
324
345
  role: "assistant",
325
- text: run.responseText ?? "",
326
- notice: run.failure ?? "Stopped by an authenticated Stop command.",
346
+ text: visibleAssistantText(run),
347
+ notice: "You stopped this.",
327
348
  status: "aborted",
328
349
  tools: toolsFrom(run.events),
329
350
  sends: sendsFrom(run.events),
330
351
  tasks: tasksFrom(run.events),
331
352
  };
332
353
  }
354
+ // A Turn that broke after it had started talking keeps what it said, with
355
+ // the reason underneath it — the treatment a stopped Turn already gets, for
356
+ // the same reason: the words arrived and the person read them (ADR 0028).
357
+ // A Turn that broke before saying anything is still just the reason.
358
+ if (run.status === "failed" && run.responseText) {
359
+ return {
360
+ id: `${run.runId}:assistant`,
361
+ runId: run.runId,
362
+ role: "assistant",
363
+ text: run.responseText,
364
+ notice: run.failure ?? "Agent request failed.",
365
+ status: "error",
366
+ tools: toolsFrom(run.events),
367
+ sends: sendsFrom(run.events),
368
+ tasks: tasksFrom(run.events),
369
+ };
370
+ }
333
371
  return {
334
372
  id: `${run.runId}:assistant`,
335
373
  runId: run.runId,
336
374
  role: "assistant",
337
375
  text:
338
376
  run.status === "failed"
339
- ? (run.failure ?? "Agent request failed.")
340
- : (run.responseText ?? notification?.body ?? ""),
377
+ ? "This Bot couldn't finish its reply. Try again."
378
+ : visibleAssistantText(run, notification?.body ?? ""),
341
379
  status: run.status === "failed" ? "error" : "completed",
342
380
  tools: toolsFrom(run.events),
343
381
  sends: sendsFrom(run.events),
@@ -429,8 +467,12 @@ export function projectDurableRuns(
429
467
  activeRun = activeRunView(run) ?? activeRun;
430
468
  if (run.status === "running" || run.status === "reconciliation-required") {
431
469
  busyRunId = run.runId;
432
- if (!run.queued) runningRunId = run.runId;
433
470
  }
471
+ // Stop belongs to a Turn that is executing. A Turn parked on a
472
+ // reconciliation is busy but not running: there is nothing to stop, and
473
+ // offering it left a Stop button standing for good — across reloads,
474
+ // because the state it was keyed off never became terminal.
475
+ if (run.status === "running" && !run.queued) runningRunId = run.runId;
434
476
  if (notification && isTerminalRun(run)) {
435
477
  projected.add(notification.notificationId);
436
478
  }
@@ -448,7 +490,13 @@ export function projectDurableRuns(
448
490
  state.activeRunId = undefined;
449
491
  }
450
492
  if (runningRunId) state.runningRunId = runningRunId;
451
- else if (state.runningRunId && terminalRunIds.has(state.runningRunId)) {
493
+ else if (
494
+ state.runningRunId &&
495
+ runs.some((run) => run.runId === state.runningRunId)
496
+ ) {
497
+ // The channel is carrying this run and it is not executing, whatever it
498
+ // settled as. A run the list does not carry yet is the one this tab just
499
+ // submitted, which keeps its Stop.
452
500
  state.runningRunId = undefined;
453
501
  }
454
502
  if (activeRun) state.activeRun = activeRun;
@@ -704,7 +752,7 @@ export function decodePluginCatalog(value: unknown): PluginCatalogItem[] {
704
752
  !Array.isArray(value.packages) ||
705
753
  value.packages.length > 256
706
754
  ) {
707
- throw new Error("Application manifest is invalid");
755
+ throw new Error("FrockBot couldn't load this deployment. Reload the page.");
708
756
  }
709
757
  return value.packages.flatMap((candidate) => {
710
758
  if (
@@ -737,7 +785,9 @@ export function decodePluginCatalog(value: unknown): PluginCatalogItem[] {
737
785
  kind === "mobile",
738
786
  )
739
787
  ) {
740
- throw new Error("Application Package metadata is invalid");
788
+ throw new Error(
789
+ "FrockBot couldn't load this deployment. Reload the page.",
790
+ );
741
791
  }
742
792
  const decoded = decodeFrockBotManifest({
743
793
  // v4, so a Capability carrying an admission ceiling decodes here too.
@@ -919,7 +969,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
919
969
  web.value.activeRun = {
920
970
  runId,
921
971
  status: "running",
922
- message: "Confirming whether this Turn was admitted.",
972
+ message: "Checking whether your message went through…",
923
973
  canResume: false,
924
974
  };
925
975
  if (!ctx.transport.lookupRun || !ctx.transport.fenceRunAdmission) {
@@ -959,7 +1009,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
959
1009
  reconciliationError = `${
960
1010
  error instanceof Error
961
1011
  ? error.message
962
- : "Turn admission lookup failed"
1012
+ : "Couldn't check on your message."
963
1013
  } Retrying…`;
964
1014
  web.value.settingsError = reconciliationError;
965
1015
  }
@@ -991,7 +1041,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
991
1041
  ) {
992
1042
  return;
993
1043
  }
994
- if (!run) throw new Error("Stopped Turn is unavailable");
1044
+ if (!run) throw new Error("Couldn't load that reply.");
995
1045
  if (web.value.settingsError === observationError) {
996
1046
  web.value.settingsError = undefined;
997
1047
  }
@@ -1001,7 +1051,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1001
1051
  } catch (error) {
1002
1052
  if (signal.aborted) return;
1003
1053
  observationError = `${
1004
- error instanceof Error ? error.message : "Turn lookup failed"
1054
+ error instanceof Error ? error.message : "Couldn't load that reply."
1005
1055
  } Retrying…`;
1006
1056
  web.value.settingsError = observationError;
1007
1057
  }
@@ -1189,7 +1239,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1189
1239
 
1190
1240
  const web: Ref<ShellWebData> = ref({
1191
1241
  connection: "ready",
1192
- modelLabel: "Model unavailable",
1242
+ modelLabel: "No model available — set one up in Models",
1193
1243
  modelReady: false,
1194
1244
  modelSource: "none",
1195
1245
  settingsAvailable: true,
@@ -1519,12 +1569,10 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1519
1569
  const botId = web.value.activeBotId;
1520
1570
  const catalog = web.value.packageUi;
1521
1571
  if (!post || !botId || !catalog || catalog.botId !== botId) {
1522
- throw new Error("Package UI is unavailable");
1572
+ throw new Error("That plugin's page isn't available.");
1523
1573
  }
1524
1574
  if (!packageIframeToolAllowedV1(contribution, name)) {
1525
- throw new Error(
1526
- `Package "${contribution.packageId}" did not declare tool "${name}"`,
1527
- );
1575
+ throw new Error(`That plugin isn't allowed to use ${name}.`);
1528
1576
  }
1529
1577
  const turn = decodeClientTurnV1(
1530
1578
  await post(
@@ -1951,9 +1999,22 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1951
1999
  updateSettingsLoadError("package-catalog");
1952
2000
  } catch (error) {
1953
2001
  if (generation !== packageCatalogGeneration) return;
2002
+ // The gateway answers 404 `catalog generation was not found` when the
2003
+ // deployment has published no Catalog at all. That is a state, not a
2004
+ // fault, and the raw server sentence means nothing to a person — so it
2005
+ // is translated here and the surface renders it instead of the
2006
+ // "nothing matched your search" empty state.
2007
+ const raw =
2008
+ error instanceof Error ? error.message : "Could not load the Catalog";
2009
+ web.value.packageCatalog = [];
2010
+ web.value.packageCatalogGeneration = undefined;
1954
2011
  updateSettingsLoadError(
1955
2012
  "package-catalog",
1956
- error instanceof Error ? error.message : "Could not load the Catalog",
2013
+ /catalog generation was not found|Package Catalog is not configured/.test(
2014
+ raw,
2015
+ )
2016
+ ? "No plugins are published for this deployment yet."
2017
+ : `Plugins could not be loaded: ${raw}`,
1957
2018
  );
1958
2019
  }
1959
2020
  },
@@ -1983,7 +2044,8 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1983
2044
  if (!settings || !ctx.transport.executeConfiguration) {
1984
2045
  throw new Error("Plugins are unavailable");
1985
2046
  }
1986
- if (!generation) throw new Error("The Catalog generation is unknown");
2047
+ if (!generation)
2048
+ throw new Error("The catalog isn't loaded yet. Try again in a moment.");
1987
2049
  const receipt = await ctx.transport.executeConfiguration({
1988
2050
  schemaVersion: 1,
1989
2051
  type: "user/install-package",
@@ -2266,8 +2328,8 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2266
2328
  if (result.status !== "applied") {
2267
2329
  throw new Error(
2268
2330
  result.status === "reconciliation-required"
2269
- ? "Connection revocation requires reconciliation"
2270
- : "Connection revocation failed",
2331
+ ? "Disconnecting didn't finish. Try again."
2332
+ : "Couldn't disconnect that account.",
2271
2333
  );
2272
2334
  }
2273
2335
  },
@@ -2370,7 +2432,10 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2370
2432
  id: `${result.runId}:assistant`,
2371
2433
  runId: result.runId,
2372
2434
  role: "assistant",
2373
- text: result.text,
2435
+ // The same rule the durable projection follows: a Turn that
2436
+ // delivered something speaks through its sends, not through the
2437
+ // model's own text (issue 153).
2438
+ text: sendsFrom(result.events).length > 0 ? "" : result.text,
2374
2439
  at: optimisticAt,
2375
2440
  status: "completed",
2376
2441
  tools: toolsFrom(result.events),
@@ -2401,8 +2466,9 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2401
2466
  // that was never admitted only threw the reason away.
2402
2467
  if (error instanceof ClientTurnRefusedErrorV1) {
2403
2468
  removeMessages(web.value.messages, pendingRunId);
2404
- web.value.error = error.refusal.error;
2405
- return { accepted: false, error: error.refusal.error };
2469
+ const refusal = turnRefusalCopyV1(error.refusal.reason);
2470
+ web.value.error = refusal;
2471
+ return { accepted: false, error: refusal };
2406
2472
  }
2407
2473
  const aborted =
2408
2474
  error instanceof DOMException && error.name === "AbortError";
@@ -2410,9 +2476,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2410
2476
  id: `${pendingRunId}:assistant`,
2411
2477
  runId: pendingRunId,
2412
2478
  role: "assistant",
2413
- text: aborted
2414
- ? "Request stopped locally; checking whether it started."
2415
- : "Confirming whether this Turn was admitted.",
2479
+ text: "Checking whether your message went through…",
2416
2480
  at: optimisticAt,
2417
2481
  status: "interrupted",
2418
2482
  tools: [],
@@ -2441,14 +2505,18 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2441
2505
  id: `${pendingRunId}:assistant`,
2442
2506
  runId: pendingRunId,
2443
2507
  role: "assistant",
2444
- text: "Turn was not admitted.",
2508
+ text: "Your message didn't go through. Try sending it again.",
2445
2509
  at: optimisticAt,
2446
2510
  status: "error",
2447
2511
  tools: [],
2448
2512
  sends: [],
2449
2513
  });
2450
- web.value.error = "Turn was not admitted";
2451
- return { accepted: false, error: "Turn was not admitted" };
2514
+ web.value.error =
2515
+ "Your message didn't go through. Try sending it again.";
2516
+ return {
2517
+ accepted: false,
2518
+ error: "Your message didn't go through. Try sending it again.",
2519
+ };
2452
2520
  }
2453
2521
  return { accepted: true, runId: pendingRunId };
2454
2522
  } finally {
@@ -2471,14 +2539,14 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2471
2539
  },
2472
2540
  async resumeRun(runId: string): Promise<void> {
2473
2541
  if (!ctx.transport.reconcileRun) {
2474
- web.value.settingsError = "Turn reconciliation is unavailable";
2542
+ web.value.settingsError = "Can't retry this right now.";
2475
2543
  return;
2476
2544
  }
2477
2545
  if (web.value.activeRun?.runId !== runId) return;
2478
2546
  web.value.activeRun = {
2479
2547
  runId,
2480
2548
  status: "running",
2481
- message: "Reconciliation requested; checking progress.",
2549
+ message: "Retrying…",
2482
2550
  canResume: false,
2483
2551
  };
2484
2552
  const botId = web.value.activeBotId;
@@ -2487,7 +2555,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2487
2555
  await ctx.transport.reconcileRun(botId, runId);
2488
2556
  } catch (error) {
2489
2557
  web.value.settingsError =
2490
- error instanceof Error ? error.message : "Reconciliation failed";
2558
+ error instanceof Error ? error.message : "Couldn't retry that.";
2491
2559
  }
2492
2560
  try {
2493
2561
  await deliverNotifications(botId);
@@ -2495,7 +2563,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2495
2563
  web.value.settingsError =
2496
2564
  error instanceof Error
2497
2565
  ? error.message
2498
- : "Could not refresh the reconciled Turn";
2566
+ : "Couldn't refresh this reply.";
2499
2567
  }
2500
2568
  },
2501
2569
  async stopRun(): Promise<void> {
@@ -31,12 +31,14 @@ describe("model runtime presentation", () => {
31
31
  "Llama 3 · Ollama Cloud · Account model",
32
32
  );
33
33
  expect(modelRuntimeLabel({ ...label, source: "bot" })).toBe(
34
- "Llama 3 · Ollama Cloud · Bot override",
34
+ "Llama 3 · Ollama Cloud · this Bot only",
35
35
  );
36
36
  });
37
37
 
38
38
  test("shows unavailable and backend failure states", () => {
39
- expect(modelRuntimeLabel({ source: "none" })).toBe("Model unavailable");
39
+ expect(modelRuntimeLabel({ source: "none" })).toBe(
40
+ "No model available — set one up in Models",
41
+ );
40
42
  expect(
41
43
  modelRuntimeLabel({
42
44
  source: "account",
@@ -14,13 +14,13 @@ export function modelRuntimeLabel(input: {
14
14
  }): string {
15
15
  if (input.failure) return input.failure;
16
16
  if (input.source === "none" || !input.providerModelId) {
17
- return "Model unavailable";
17
+ return "No model available — set one up in Models";
18
18
  }
19
19
  const model =
20
20
  input.modelDisplayName ?? input.providerModelId ?? "Connected model";
21
21
  const provider = input.packageDisplayName ?? input.connectionDisplayName;
22
22
  const runtime = provider ? `${model} · ${provider}` : model;
23
- if (input.source === "bot") return `${runtime} · Bot override`;
23
+ if (input.source === "bot") return `${runtime} · this Bot only`;
24
24
  if (input.source === "account") return `${runtime} · Account model`;
25
25
  return runtime;
26
26
  }
@@ -301,13 +301,27 @@
301
301
  font-size: var(--frock-text-xs);
302
302
  }
303
303
 
304
- /* An assistant Turn is its avatar and, once there is text, its bubble. */
304
+ /*
305
+ * An assistant Turn is its avatar and, beside it, one column holding
306
+ * everything the Turn produced. The row has exactly two children: bubbles,
307
+ * notices, sends and chips stack inside the column, so a one-word reply is a
308
+ * bubble the width of its word rather than a sliver of a shared row.
309
+ */
305
310
  .message-assistant {
306
311
  flex-direction: row;
307
312
  align-items: flex-start;
308
313
  gap: 8px;
309
314
  }
310
315
 
316
+ .message-column {
317
+ display: flex;
318
+ min-width: 0;
319
+ flex: 1 1 auto;
320
+ flex-direction: column;
321
+ align-items: flex-start;
322
+ gap: 6px;
323
+ }
324
+
311
325
  .bot-avatar {
312
326
  position: relative;
313
327
  display: grid;
@@ -179,7 +179,12 @@ export interface ClientDynamicToolCallInputV1 {
179
179
 
180
180
  export type ClientRunOutcomeV1 =
181
181
  | { type: "completed"; text: string }
182
- | { type: "failed"; message: string }
182
+ /**
183
+ * A Turn that broke keeps what it had already said, for the same reason a
184
+ * stopped one does: the words arrived, the person read them, and replacing
185
+ * them with a notice would rewrite what they watched happen (ADR 0028).
186
+ */
187
+ | { type: "failed"; message: string; text?: string }
183
188
  /**
184
189
  * A Turn a Stop or a later message ended keeps what it had already said:
185
190
  * `text` is that partial answer, and `message` is the line saying why it
@@ -673,6 +678,7 @@ export function projectClientRunV1(run: StoredRun): ClientRunV1 {
673
678
  run.failure ?? "Agent request failed.",
674
679
  MAX_FAILURE_BYTES,
675
680
  ),
681
+ ...interruptedOutcomeTextV1(run),
676
682
  } satisfies ClientRunOutcomeV1)
677
683
  : status === "cancelled"
678
684
  ? ({
@@ -1121,10 +1127,11 @@ function decodeOutcome(
1121
1127
  };
1122
1128
  }
1123
1129
  if (outcome.type === "failed" && runStatus === "failed") {
1124
- exactKeys(outcome, ["type", "message"], "run.outcome");
1130
+ exactKeys(outcome, ["type", "message", "text"], "run.outcome");
1125
1131
  return {
1126
1132
  type: "failed",
1127
1133
  message: wireString(outcome, "message", MAX_FAILURE_BYTES, "run.outcome"),
1134
+ ...decodeInterruptedTextV1(outcome),
1128
1135
  };
1129
1136
  }
1130
1137
  if (outcome.type === "cancelled" && runStatus === "cancelled") {
@@ -1249,7 +1256,12 @@ function decodeRun(value: unknown): ClientRun {
1249
1256
  ...(stopRequestedAt ? { stopRequestedAt } : {}),
1250
1257
  ...(run.queued === true ? { queued: true as const } : {}),
1251
1258
  ...(outcome?.type === "completed" ? { responseText: outcome.text } : {}),
1252
- ...(outcome?.type === "failed" ? { failure: outcome.message } : {}),
1259
+ ...(outcome?.type === "failed"
1260
+ ? {
1261
+ failure: outcome.message,
1262
+ ...(outcome.text ? { responseText: outcome.text } : {}),
1263
+ }
1264
+ : {}),
1253
1265
  ...(outcome?.type === "cancelled" || outcome?.type === "superseded"
1254
1266
  ? {
1255
1267
  failure: outcome.message,