@frockbot/plugin-shell 0.3.3 → 0.3.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-shell",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -47,41 +47,41 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "@cordisjs/plugin-webui": "0.8.2",
50
- "@frockbot/agent-runtime": "0.3.3",
51
- "@frockbot/application-foundation": "0.3.3",
52
- "@frockbot/catalog-core": "0.3.3",
53
- "@frockbot/client-core": "0.3.3",
54
- "@frockbot/client-ui": "0.3.3",
55
- "@frockbot/computer-core": "0.3.3",
56
- "@frockbot/computer-host-protocol": "0.3.3",
57
- "@frockbot/configuration-core": "0.3.3",
58
- "@frockbot/connection-core": "0.3.3",
59
- "@frockbot/kernel-agent-loop": "0.3.3",
60
- "@frockbot/kernel-composition": "0.3.3",
61
- "@frockbot/kernel-contracts": "0.3.3",
62
- "@frockbot/kernel-do": "0.3.3",
63
- "@frockbot/machine-protocol": "0.3.3",
64
- "@frockbot/plugin-applets": "0.3.3",
65
- "@frockbot/plugin-authoring": "0.3.3",
66
- "@frockbot/plugin-bot-template": "0.3.3",
67
- "@frockbot/plugin-computer": "0.3.3",
68
- "@frockbot/plugin-flock": "0.3.3",
69
- "@frockbot/plugin-image": "0.3.3",
70
- "@frockbot/plugin-machine-messages": "0.3.3",
71
- "@frockbot/plugin-mcp": "0.3.3",
72
- "@frockbot/plugin-memory": "0.3.3",
73
- "@frockbot/plugin-package-catalog": "0.3.3",
74
- "@frockbot/plugin-routines": "0.3.3",
75
- "@frockbot/plugin-skills": "0.3.3",
76
- "@frockbot/plugin-subagents": "0.3.3",
77
- "@frockbot/plugin-user-machine": "0.3.3",
78
- "@frockbot/protocol": "0.3.3",
50
+ "@frockbot/agent-runtime": "0.3.5",
51
+ "@frockbot/application-foundation": "0.3.5",
52
+ "@frockbot/catalog-core": "0.3.5",
53
+ "@frockbot/client-core": "0.3.5",
54
+ "@frockbot/client-ui": "0.3.5",
55
+ "@frockbot/computer-core": "0.3.5",
56
+ "@frockbot/computer-host-protocol": "0.3.5",
57
+ "@frockbot/configuration-core": "0.3.5",
58
+ "@frockbot/connection-core": "0.3.5",
59
+ "@frockbot/kernel-agent-loop": "0.3.5",
60
+ "@frockbot/kernel-composition": "0.3.5",
61
+ "@frockbot/kernel-contracts": "0.3.5",
62
+ "@frockbot/kernel-do": "0.3.5",
63
+ "@frockbot/machine-protocol": "0.3.5",
64
+ "@frockbot/plugin-applets": "0.3.5",
65
+ "@frockbot/plugin-authoring": "0.3.5",
66
+ "@frockbot/plugin-bot-template": "0.3.5",
67
+ "@frockbot/plugin-computer": "0.3.5",
68
+ "@frockbot/plugin-flock": "0.3.5",
69
+ "@frockbot/plugin-image": "0.3.5",
70
+ "@frockbot/plugin-machine-messages": "0.3.5",
71
+ "@frockbot/plugin-mcp": "0.3.5",
72
+ "@frockbot/plugin-memory": "0.3.5",
73
+ "@frockbot/plugin-package-catalog": "0.3.5",
74
+ "@frockbot/plugin-routines": "0.3.5",
75
+ "@frockbot/plugin-skills": "0.3.5",
76
+ "@frockbot/plugin-subagents": "0.3.5",
77
+ "@frockbot/plugin-user-machine": "0.3.5",
78
+ "@frockbot/protocol": "0.3.5",
79
79
  "cordis": "4.0.0-rc.8",
80
80
  "vue": "3.5.41"
81
81
  },
82
82
  "devDependencies": {
83
83
  "@cloudflare/workers-types": "latest",
84
- "@frockbot/plugin-tools": "0.3.3",
84
+ "@frockbot/plugin-tools": "0.3.5",
85
85
  "@types/bun": "1.3.6",
86
86
  "@types/node": "26.2.0",
87
87
  "@vitejs/plugin-vue": "6.0.8",
@@ -1310,17 +1310,18 @@ describe("Bot recovery", () => {
1310
1310
  { schemaVersion: 1, runId: `bounded-fence-${index}` },
1311
1311
  );
1312
1312
  }
1313
- await expect(
1314
- contribution.fenceRunAdmission(
1315
- { userId: "user-1", botId: "primary" },
1316
- { schemaVersion: 1, runId: "fence-over-capacity" },
1317
- ),
1318
- ).rejects.toThrow("Run admission fence capacity reached");
1313
+ // The index is a bounded FIFO: the oldest fence ages out, and the fence
1314
+ // itself always succeeds. Refusing it instead meant a Bot that had refused
1315
+ // 256 sends answered every later fence 500, and left the client looping
1316
+ // "Turn admission lookup failed".
1317
+ await contribution.fenceRunAdmission(
1318
+ { userId: "user-1", botId: "primary" },
1319
+ { schemaVersion: 1, runId: "fence-over-capacity" },
1320
+ );
1319
1321
  const fences = await storage.get<string[]>("run-admission-fences");
1320
1322
  expect(fences).toHaveLength(256);
1321
- expect(fences).toContain("command-fenced");
1322
- expect(fences).toContain("bounded-fence-0");
1323
- expect(fences).not.toContain("fence-over-capacity");
1323
+ expect(fences).toContain("fence-over-capacity");
1324
+ expect(fences).not.toContain("command-fenced");
1324
1325
  });
1325
1326
 
1326
1327
  test("rechecks a fence committed during execution-context resolution", async () => {
package/src/backend.ts CHANGED
@@ -328,7 +328,11 @@ import {
328
328
  type PendingBotInputV1,
329
329
  type RoutineInboxEntryV1,
330
330
  } from "@frockbot/plugin-routines/inbox";
331
- import type { RoutineScheduler } from "@frockbot/plugin-routines/scheduler";
331
+ import type {
332
+ RoutineFireOutcomeV1,
333
+ RoutineScheduler,
334
+ } from "@frockbot/plugin-routines/scheduler";
335
+ import type { RoutineFireV1 } from "@frockbot/plugin-routines/firing";
332
336
  import {
333
337
  RoutineNotFoundError,
334
338
  type RoutineStore,
@@ -395,6 +399,7 @@ import {
395
399
  isVisibleRunV1,
396
400
  projectClientRunLookupV1,
397
401
  projectClientRunV1,
402
+ projectClientRunOrDegradedV1,
398
403
  projectClientAnnouncementsV1,
399
404
  projectClientTurnV1,
400
405
  type ClientRunLookupV1,
@@ -2975,17 +2980,24 @@ export class ShellBotBackendContribution {
2975
2980
  }
2976
2981
 
2977
2982
  private async settleScheduledWork(): Promise<void> {
2978
- await this.settleRoutineFirings();
2979
- await this.runOwedSubagentTurns();
2980
- await this.reconcileOverdueTasks();
2981
- await this.expireDueApprovals();
2982
- await this.replayPendingWakeNotifications();
2983
- await this.hostSettleScheduledWork?.();
2984
- // The alarm that woke this object has been consumed. Re-arm on whatever is
2985
- // owed next, or a Routine that fired once would never fire again.
2986
- await this.ctx.storage.transaction((transaction) =>
2987
- this.authority.refreshRecoveryAlarm(transaction),
2988
- );
2983
+ // The re-arm is in a `finally` because it is the object's only way back.
2984
+ // The alarm that woke this object has already been consumed by the
2985
+ // platform; a throw in any one settler used to skip the re-arm, and then
2986
+ // nothing — no Routine, no approval expiry, no owed subagent Turn — ever
2987
+ // woke this Bot again except by a caller's luck. One producer failing must
2988
+ // cost that producer its pass, never the clock.
2989
+ try {
2990
+ await this.settleRoutineFirings();
2991
+ await this.runOwedSubagentTurns();
2992
+ await this.reconcileOverdueTasks();
2993
+ await this.expireDueApprovals();
2994
+ await this.replayPendingWakeNotifications();
2995
+ await this.hostSettleScheduledWork?.();
2996
+ } finally {
2997
+ await this.ctx.storage.transaction((transaction) =>
2998
+ this.authority.refreshRecoveryAlarm(transaction),
2999
+ );
3000
+ }
2989
3001
  }
2990
3002
 
2991
3003
  /**
@@ -3005,21 +3017,73 @@ export class ShellBotBackendContribution {
3005
3017
  // here whenever the Turn is executing in this isolate, but a durable active
3006
3018
  // run outlives an eviction, and admitting a firing against one would burn
3007
3019
  // the occurrence on an error instead of holding the debt.
3008
- if (await this.authority.readActiveRunId()) return;
3020
+ //
3021
+ // Returning was not enough: the debt stayed past-due, so `deadlines()`
3022
+ // re-armed on a moment already gone and the alarm spun straight back into
3023
+ // this same bail-out — which is how a Routine racing a long chat Turn
3024
+ // failed once a minute for ever. The hold is what turns the bail-out into
3025
+ // a deferral: `dueAt` does not move, so the firing still lands.
3026
+ if (await this.authority.readActiveRunId()) {
3027
+ await this.ctx.storage.transaction((transaction) =>
3028
+ this.routineScheduler.defer(transaction),
3029
+ );
3030
+ return;
3031
+ }
3009
3032
  await this.routineScheduler.settle(async (fire) => {
3010
- try {
3011
- await this.authority.run(
3012
- routineTurnCommandV1(identity, fire, new Date().toISOString()),
3013
- );
3014
- } catch (error) {
3015
- return routineFireOutcomeV1(
3016
- await this.authority.readStoredRun(fire.fireId),
3017
- error,
3018
- );
3019
- }
3033
+ const outcome = await this.runOneFiring(identity, fire);
3034
+ await this.notifyFailedFiring(identity, fire, outcome);
3035
+ return outcome;
3036
+ });
3037
+ }
3038
+
3039
+ private async runOneFiring(
3040
+ identity: BotIdentity,
3041
+ fire: RoutineFireV1,
3042
+ ): Promise<RoutineFireOutcomeV1> {
3043
+ try {
3044
+ await this.authority.run(
3045
+ routineTurnCommandV1(identity, fire, new Date().toISOString()),
3046
+ );
3047
+ } catch (error) {
3020
3048
  return routineFireOutcomeV1(
3021
3049
  await this.authority.readStoredRun(fire.fireId),
3050
+ error,
3022
3051
  );
3052
+ }
3053
+ return routineFireOutcomeV1(
3054
+ await this.authority.readStoredRun(fire.fireId),
3055
+ );
3056
+ }
3057
+
3058
+ /**
3059
+ * Tell the person that a firing did not work.
3060
+ *
3061
+ * The scheduler has already written the durable completion-inbox entry in
3062
+ * the transaction that settled the firing; this is the delivery half — the
3063
+ * same seam a hand-off uses, so a Routine that breaks reaches the same place
3064
+ * a Routine that finishes does instead of only a `failed` row nobody opens.
3065
+ * `notifications.enabled` is honoured: it is the mute on updates, and a
3066
+ * broken Routine is an update, not a decision the Bot is waiting on.
3067
+ */
3068
+ private async notifyFailedFiring(
3069
+ identity: BotIdentity,
3070
+ fire: RoutineFireV1,
3071
+ outcome: RoutineFireOutcomeV1,
3072
+ ): Promise<void> {
3073
+ if (outcome.status === "ok") return;
3074
+ const settings = await this.getSettings(identity);
3075
+ if (!settings.notifications.enabled) return;
3076
+ await this.authority.recordNotification({
3077
+ // The same id shape the completion path uses, so one firing is one
3078
+ // intent however many times the alarm retries it.
3079
+ notificationId: `routine-failed:${fire.fireId}`,
3080
+ runId: fire.fireId,
3081
+ createdAt: new Date().toISOString(),
3082
+ title: `${settings.profile.name} could not run a Routine`,
3083
+ body: (outcome.summary ?? "The firing ended without saying why.").slice(
3084
+ 0,
3085
+ 240,
3086
+ ),
3023
3087
  });
3024
3088
  }
3025
3089
  // -------------------------------------------------------------------------
@@ -5370,7 +5434,9 @@ export class ShellBotBackendContribution {
5370
5434
  // still not part of the conversation: the visible transcript never
5371
5435
  // shows one, running or settled.
5372
5436
  if (active && isVisibleRunV1(active))
5373
- selected.set(active.runId, { run: projectClientRunV1(active) });
5437
+ selected.set(active.runId, {
5438
+ run: projectClientRunOrDegradedV1(active),
5439
+ });
5374
5440
  }
5375
5441
  const available = candidates.slice(0, CLIENT_RUN_PAGE_LIMIT);
5376
5442
  let stoppedEarly = false;
@@ -5382,7 +5448,7 @@ export class ShellBotBackendContribution {
5382
5448
  }
5383
5449
  const stored = await this.authority.readStoredRun(candidate.runId);
5384
5450
  if (!stored || !isVisibleRunV1(stored)) continue;
5385
- const projected = projectClientRunV1(stored);
5451
+ const projected = projectClientRunOrDegradedV1(stored);
5386
5452
  const tentative = [
5387
5453
  ...selected.values(),
5388
5454
  { cursor: candidate.cursor, run: projected },
@@ -267,9 +267,31 @@ const macDesktop =
267
267
  typeof navigator !== "undefined" &&
268
268
  /Electron/u.test(navigator.userAgent) &&
269
269
  /Mac/u.test(navigator.platform);
270
- const botName = computed(
271
- () => state.value.botSettings?.profile.name ?? "Barebones",
272
- );
270
+ // The Bot's own name, or nothing: an account with no Bot, and a Bot whose
271
+ // settings have not arrived yet, must never be given a made-up name.
272
+ const botName = computed(() => state.value.botSettings?.profile.name ?? "");
273
+ const hasBot = computed(() => Boolean(state.value.activeBotId));
274
+ /**
275
+ * The greeting, the composer placeholder and the not-ready line all read off
276
+ * the same two facts: whether a Bot is open, and what the model resolver said.
277
+ * `state.modelLabel` already carries the resolver's own repairable failure
278
+ * sentence when the binding failed, so the surface repeats it rather than
279
+ * inventing "Model unavailable" of its own.
280
+ */
281
+ const threadHeading = computed(() => {
282
+ if (!hasBot.value) return "No Bots yet.";
283
+ if (!botName.value) return state.value.modelReady ? "Ready." : "Not ready.";
284
+ return state.value.modelReady
285
+ ? `${botName.value} is ready.`
286
+ : `${botName.value} isn't ready.`;
287
+ });
288
+ const threadHint = computed(() => {
289
+ if (!hasBot.value) return "Add your first sheep to start a conversation.";
290
+ if (state.value.modelReady) {
291
+ return "Start with a conversation. Cordis plugins can add the rest.";
292
+ }
293
+ return state.value.modelLabel;
294
+ });
273
295
  /** A Turn is executing. The composer stays open; only Stop depends on this. */
274
296
  const isRunning = computed(() => Boolean(state.value.runningRunId));
275
297
  const isConnecting = computed(() => state.value.connection !== "ready");
@@ -278,6 +300,11 @@ const isConnecting = computed(() => state.value.connection !== "ready");
278
300
  * running Turn. So the only things that close the composer are the ones that
279
301
  * would make any message impossible.
280
302
  */
303
+ const composerPlaceholder = computed(() => {
304
+ if (isConnecting.value) return "Connecting…";
305
+ if (!state.value.modelReady) return state.value.modelLabel;
306
+ return botName.value ? `Message ${botName.value}` : "Message";
307
+ });
281
308
  const canSend = computed(
282
309
  () =>
283
310
  state.value.connection === "ready" &&
@@ -378,6 +405,32 @@ function taskChipsOf(message: WebChatMessage): Array<{
378
405
  });
379
406
  }
380
407
 
408
+ /**
409
+ * The tools this Turn ran, as the thread draws them.
410
+ *
411
+ * A tool whose Package draws its own surface is shown by that surface; every
412
+ * other one is a chip, because a Turn that spends a minute making tool calls
413
+ * used to show the User nothing at all but a spinning avatar.
414
+ */
415
+ function toolChipsOf(message: WebChatMessage): WebToolActivity[] {
416
+ return message.tools.filter((tool) => iframeEntriesFor(tool).length === 0);
417
+ }
418
+
419
+ /** What a chip says a tool is doing. Its status, in the User's words. */
420
+ function toolChipStatus(tool: WebToolActivity): string {
421
+ if (tool.status === "running") return "running";
422
+ return tool.status === "failed" ? "failed" : "done";
423
+ }
424
+
425
+ /** Which tool chips the User has opened. Local, and per chip. */
426
+ const expandedTools = ref(new Set<string>());
427
+
428
+ function toggleTool(toolId: string): void {
429
+ const next = new Set(expandedTools.value);
430
+ if (!next.delete(toolId)) next.add(toolId);
431
+ expandedTools.value = next;
432
+ }
433
+
381
434
  /** Which chips the User has opened. Local, and per chip. */
382
435
  const expandedTasks = ref(new Set<string>());
383
436
 
@@ -404,6 +457,7 @@ function isVisible(message: WebChatMessage): boolean {
404
457
  // visible act was dispatching a subagent.
405
458
  return (
406
459
  message.text.length > 0 ||
460
+ (message.notice?.length ?? 0) > 0 ||
407
461
  message.tools.some((tool) => iframeEntriesFor(tool).length > 0) ||
408
462
  message.sends.length > 0 ||
409
463
  (message.tasks?.length ?? 0) > 0 ||
@@ -778,7 +832,7 @@ function handleComposerKeydown(event: KeyboardEvent): void {
778
832
  <span class="bot-identity"
779
833
  ><k-slot name="frockbot.bot-identity"
780
834
  /></span>
781
- <div class="workspace-title">
835
+ <div v-if="hasBot" class="workspace-title">
782
836
  <strong>{{ botName }}</strong>
783
837
  <small>{{ state.modelLabel }}</small>
784
838
  </div>
@@ -793,20 +847,8 @@ function handleComposerKeydown(event: KeyboardEvent): void {
793
847
  >
794
848
  <div v-if="messages.length === 0" class="empty-thread">
795
849
  <div class="empty-mark"><UiIcon name="sparkle" size="lg" /></div>
796
- <h1>
797
- {{
798
- state.modelReady
799
- ? `${botName} is ready.`
800
- : `${botName} isn't ready.`
801
- }}
802
- </h1>
803
- <p>
804
- {{
805
- state.modelReady
806
- ? "Start with a conversation. Cordis plugins can add the rest."
807
- : "Check this Bot's model Connection."
808
- }}
809
- </p>
850
+ <h1>{{ threadHeading }}</h1>
851
+ <p>{{ threadHint }}</p>
810
852
  </div>
811
853
  <article
812
854
  v-for="message in messages"
@@ -844,6 +886,13 @@ function handleComposerKeydown(event: KeyboardEvent): void {
844
886
  <div v-if="message.text" class="message-bubble">
845
887
  <UiMarkdown :text="message.text" />
846
888
  </div>
889
+ <!--
890
+ Why the Turn ends where it does, under whatever it had already
891
+ said rather than in place of it.
892
+ -->
893
+ <p v-if="message.notice" class="message-notice">
894
+ {{ message.notice }}
895
+ </p>
847
896
  <template v-for="tool in message.tools" :key="tool.id">
848
897
  <PackageIframeHost
849
898
  v-for="entry in iframeEntriesFor(tool)"
@@ -891,6 +940,33 @@ function handleComposerKeydown(event: KeyboardEvent): void {
891
940
  :send="send"
892
941
  />
893
942
  </div>
943
+ <!--
944
+ What the Bot did, while it is doing it. The chip is the
945
+ conversation's whole account of an ordinary tool call: its
946
+ name, whether it is running, and — when the User opens it —
947
+ what it returned.
948
+ -->
949
+ <div v-if="toolChipsOf(message).length > 0" class="message-tools">
950
+ <button
951
+ v-for="tool in toolChipsOf(message)"
952
+ :key="tool.id"
953
+ type="button"
954
+ class="tool-chip"
955
+ :class="`tool-chip-${tool.status}`"
956
+ :aria-expanded="expandedTools.has(tool.id)"
957
+ @click="toggleTool(tool.id)"
958
+ >
959
+ <span class="tool-chip-name">{{ tool.name }}</span>
960
+ <span class="tool-chip-status">{{
961
+ toolChipStatus(tool)
962
+ }}</span>
963
+ <span
964
+ v-if="expandedTools.has(tool.id) && tool.text !== undefined"
965
+ class="tool-chip-result"
966
+ >{{ tool.text }}</span
967
+ >
968
+ </button>
969
+ </div>
894
970
  <!--
895
971
  The subagents this Turn dispatched. The child's own Session is
896
972
  never in this transcript, so the chip is the whole of what the
@@ -975,7 +1051,12 @@ function handleComposerKeydown(event: KeyboardEvent): void {
975
1051
  </div>
976
1052
  </Transition>
977
1053
 
1054
+ <!--
1055
+ No Bot, no composer. A disabled input under a made-up Bot name reads
1056
+ as a broken Bot; the first-run pane above points at making one.
1057
+ -->
978
1058
  <form
1059
+ v-if="hasBot"
979
1060
  class="composer"
980
1061
  :class="{ 'composer-busy': isRunning }"
981
1062
  @submit.prevent="sendMessage"
@@ -1038,13 +1119,7 @@ function handleComposerKeydown(event: KeyboardEvent): void {
1038
1119
  ref="composerInput"
1039
1120
  v-model="draft"
1040
1121
  aria-label="Message"
1041
- :placeholder="
1042
- isConnecting
1043
- ? 'Connecting…'
1044
- : !state.modelReady
1045
- ? 'Model unavailable'
1046
- : `Message ${botName}`
1047
- "
1122
+ :placeholder="composerPlaceholder"
1048
1123
  :disabled="isConnecting || !state.modelReady"
1049
1124
  rows="1"
1050
1125
  role="combobox"
@@ -826,6 +826,84 @@ describe("Bot selection", () => {
826
826
  },
827
827
  );
828
828
 
829
+ /**
830
+ * The composer placeholder and the not-ready line in `FrockBotApp.vue` are
831
+ * `state.modelLabel`, so a genuinely broken binding has to reach that label
832
+ * as the resolver's own repairable sentence rather than a flat "Model
833
+ * unavailable" the User cannot act on.
834
+ */
835
+ test("labels a Bot whose model Connection is disabled with the resolver's failure", async () => {
836
+ let provided: Ref<FrockBotWebData> | undefined;
837
+ const bot = initializeBotSettingsV1("broken-model-bot");
838
+ const user: UserSettingsViewV1 = {
839
+ schemaVersion: 1,
840
+ revision: 1,
841
+ profile: { name: "User" },
842
+ packages: [
843
+ { packageId: "model-provider", version: "0.0.1", state: "installed" },
844
+ ],
845
+ connections: [
846
+ {
847
+ connectionId: "model-connection",
848
+ packageId: "model-provider",
849
+ connectionTypeId: "model-account",
850
+ displayName: "Work",
851
+ state: "disabled",
852
+ providerType: "model-provider",
853
+ safeMetadata: {},
854
+ },
855
+ ],
856
+ platformModel: {
857
+ connectionId: "model-connection",
858
+ providerModelId: "model-id",
859
+ },
860
+ };
861
+ await shellClientPlugin({
862
+ transport: {
863
+ turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
864
+ readConfiguration: (query) =>
865
+ Promise.resolve(query.type === "user/get" ? user : bot),
866
+ },
867
+ slot: () => () => {},
868
+ inject: () => {
869
+ throw new Error("unexpected client provider injection");
870
+ },
871
+ provide: (_key, value) => {
872
+ provided = value as Ref<FrockBotWebData>;
873
+ return () => {};
874
+ },
875
+ });
876
+ if (!provided) throw new Error("shell data was not provided");
877
+ provided.value.activeBotId = bot.botId;
878
+ provided.value.pluginCatalog = [
879
+ {
880
+ packageId: "model-provider",
881
+ displayName: "Model provider",
882
+ version: "0.0.1",
883
+ capabilities: [
884
+ { id: "models", kind: "model", connectionTypes: ["model-account"] },
885
+ ],
886
+ connectionTypes: [
887
+ {
888
+ id: "model-account",
889
+ displayName: "Model account",
890
+ allowMultiple: false,
891
+ authorizationKind: "api-key",
892
+ capabilities: ["models"],
893
+ },
894
+ ],
895
+ settings: [],
896
+ },
897
+ ];
898
+
899
+ await provided.value.loadBotSettings();
900
+
901
+ expect(provided.value.modelReady).toBe(false);
902
+ expect(provided.value.modelLabel).toBe(
903
+ 'Connection "model-connection" is disabled; enable or reconnect it',
904
+ );
905
+ });
906
+
829
907
  test("saves Bot-scoped Package settings through the generic command", async () => {
830
908
  type PackageSettingsWebData = FrockBotWebData & {
831
909
  saveBotPackageSettings(
@@ -1769,14 +1847,18 @@ describe("hosted Stop", () => {
1769
1847
  status: "reconciliation-required",
1770
1848
  message:
1771
1849
  "Stop accepted; reconciling the provider outcome before cancelling.",
1772
- canResume: false,
1850
+ // A parked Turn is offered the resolve control, Stop included: hiding
1851
+ // it there hid it in exactly the case Stop creates.
1852
+ canResume: true,
1773
1853
  });
1774
1854
 
1775
1855
  await provided.value.stopRun();
1776
1856
  expect(provided.value.activeRun).toBeUndefined();
1777
1857
  expect(provided.value.activeRunId).toBeUndefined();
1858
+ // The Turn keeps whatever it had already said; the notice is the line
1859
+ // that says why it ends where it does.
1778
1860
  expect(provided.value.messages[1]).toMatchObject({
1779
- text: "Stopped by an authenticated Stop command.",
1861
+ notice: "Stopped by an authenticated Stop command.",
1780
1862
  status: "aborted",
1781
1863
  });
1782
1864
 
@@ -1833,12 +1915,15 @@ describe("hosted Stop", () => {
1833
1915
 
1834
1916
  await provided.value.stopRun();
1835
1917
 
1836
- expect(lookups).toBe(1);
1918
+ // Two, and deliberately: a client that finds itself holding an active run
1919
+ // it did not start now observes it (a reload mid-Turn, a second tab), and
1920
+ // Stop then observes the settlement it asked for.
1921
+ expect(lookups).toBe(2);
1837
1922
  expect(provided.value.activeRun).toBeUndefined();
1838
1923
  expect(provided.value.activeRunId).toBeUndefined();
1839
1924
  expect(provided.value.messages.at(-1)).toMatchObject({
1840
1925
  runId: "run-1",
1841
- text: "Stopped by an authenticated Stop command.",
1926
+ notice: "Stopped by an authenticated Stop command.",
1842
1927
  status: "aborted",
1843
1928
  });
1844
1929
  });
@@ -2819,7 +2904,7 @@ describe("a message sent while a Turn is running", () => {
2819
2904
  // The superseded Turn keeps the quiet treatment a stopped one gets.
2820
2905
  expect(state.messages[1]).toMatchObject({
2821
2906
  status: "aborted",
2822
- text: "Interrupted by your next message.",
2907
+ notice: "Interrupted by your next message.",
2823
2908
  });
2824
2909
  });
2825
2910
 
@@ -53,7 +53,10 @@ import {
53
53
  import { MCP_OAUTH_CONNECTION_TYPE_ID } from "@frockbot/plugin-mcp/agent";
54
54
  import { decodeStartConnectionResultV1 } from "@frockbot/connection-core";
55
55
  import { decodeClientSkillCatalogV1 } from "../skill-protocol.js";
56
- import { decodeClientTurnV1 } from "../run-protocol.js";
56
+ import {
57
+ ClientTurnRefusedErrorV1,
58
+ decodeClientTurnV1,
59
+ } from "../run-protocol.js";
57
60
  import {
58
61
  decodeApprovalDecisionReceiptV1,
59
62
  decodeApprovalListViewV1,
@@ -244,7 +247,11 @@ function activeRunView(run: ClientRun): WebActiveRun | undefined {
244
247
  : (run.recovery?.message ??
245
248
  run.failure ??
246
249
  "This Turn requires provider reconciliation before it can continue."),
247
- canResume: !run.stopRequestedAt && run.recovery?.action === "resume",
250
+ // Offered whenever the run is parked, Stop included. Hiding it there
251
+ // hid it in exactly the case Stop creates: a Turn that was stopped
252
+ // while the model was mid-answer parks, and the person was left with a
253
+ // banner and no way to act on it.
254
+ canResume: run.recovery?.action === "resume",
248
255
  };
249
256
  }
250
257
  return undefined;
@@ -287,7 +294,8 @@ function assistantMessage(
287
294
  id: `${run.runId}:assistant`,
288
295
  runId: run.runId,
289
296
  role: "assistant",
290
- text: run.failure ?? "Interrupted by your next message.",
297
+ text: run.responseText ?? "",
298
+ notice: run.failure ?? "Interrupted by your next message.",
291
299
  status: "aborted",
292
300
  tools: toolsFrom(run.events),
293
301
  sends: sendsFrom(run.events),
@@ -314,7 +322,8 @@ function assistantMessage(
314
322
  id: `${run.runId}:assistant`,
315
323
  runId: run.runId,
316
324
  role: "assistant",
317
- text: run.failure ?? "Stopped by an authenticated Stop command.",
325
+ text: run.responseText ?? "",
326
+ notice: run.failure ?? "Stopped by an authenticated Stop command.",
318
327
  status: "aborted",
319
328
  tools: toolsFrom(run.events),
320
329
  sends: sendsFrom(run.events),
@@ -1088,14 +1097,18 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
1088
1097
  function updateModelLabel(): void {
1089
1098
  const bot = web.value.botSettings;
1090
1099
  const user = web.value.userSettings;
1091
- if (!bot || !user) {
1100
+ if (!user) {
1092
1101
  web.value.modelSource = "none";
1093
1102
  web.value.modelReady = false;
1094
1103
  web.value.modelLabel = modelRuntimeLabel({ source: "none" });
1095
1104
  return;
1096
1105
  }
1097
1106
  const effective = resolveEffectiveBotModelV1({
1098
- bot: toRaw(bot),
1107
+ // Before the first Bot exists — and in the window before a selected
1108
+ // Bot's settings arrive — the account's own effective model is still
1109
+ // the truth. An empty Bot scope simply declines to override it, so the
1110
+ // shell reports the account model instead of claiming it is unavailable.
1111
+ bot: bot ? toRaw(bot) : { packageValues: {} },
1099
1112
  user: toRaw(user),
1100
1113
  packages: toRaw(web.value.pluginCatalog).map((pkg) => ({
1101
1114
  packageId: pkg.packageId,
@@ -2292,6 +2305,12 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2292
2305
  const observed = web.value.activeRunId;
2293
2306
  const supersedes = observed ? { runId: observed } : {};
2294
2307
  web.value.activeRunId = pendingRunId;
2308
+ // Stop is offered for the whole of the Turn the User just started, not
2309
+ // only from the moment a projection happens to arrive. A send that
2310
+ // supersedes a running Turn is the exception: the Turn Stop targets is
2311
+ // still the one executing, and this one is queued behind it. The durable
2312
+ // projection corrects both the instant it arrives.
2313
+ if (!observed) web.value.runningRunId = pendingRunId;
2295
2314
  web.value.error = undefined;
2296
2315
  web.value.messages.push(
2297
2316
  {
@@ -2376,6 +2395,15 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2376
2395
  web.value.activeBotId !== botId
2377
2396
  )
2378
2397
  return { accepted: true, runId: pendingRunId };
2398
+ // A refusal is a normal answer, not an uncertain send: the Bot
2399
+ // declined and said why. Show that, drop the optimistic bubbles, and
2400
+ // let the composer give the person their text back — fencing a run
2401
+ // that was never admitted only threw the reason away.
2402
+ if (error instanceof ClientTurnRefusedErrorV1) {
2403
+ removeMessages(web.value.messages, pendingRunId);
2404
+ web.value.error = error.refusal.error;
2405
+ return { accepted: false, error: error.refusal.error };
2406
+ }
2379
2407
  const aborted =
2380
2408
  error instanceof DOMException && error.name === "AbortError";
2381
2409
  replaceMessage(web.value.messages, pendingRunId, {
@@ -2431,6 +2459,14 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2431
2459
  ) {
2432
2460
  web.value.activeRunId = undefined;
2433
2461
  }
2462
+ // The optimistic Stop target goes with it: a Turn nobody is running is
2463
+ // not a Turn anybody can stop.
2464
+ if (
2465
+ web.value.runningRunId === pendingRunId &&
2466
+ web.value.activeRunId !== pendingRunId
2467
+ ) {
2468
+ web.value.runningRunId = undefined;
2469
+ }
2434
2470
  }
2435
2471
  },
2436
2472
  async resumeRun(runId: string): Promise<void> {
@@ -2602,6 +2638,70 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2602
2638
  },
2603
2639
  );
2604
2640
 
2641
+ /*
2642
+ * A running Turn reaches every browser that is looking, not only the one
2643
+ * holding its POST.
2644
+ *
2645
+ * The reply to `POST /api/bots/:bot/turns` is one client's copy of a Turn.
2646
+ * A reload, a second tab, or a dropped request has no such copy, so the
2647
+ * transcript would sit on a spinner until somebody reloaded again. Two
2648
+ * seams close that: the Bot's state channel pushes a `runs` invalidation
2649
+ * whenever the durable run records move, and — for any client that has no
2650
+ * socket — the run is polled to its terminal state. Both end in the same
2651
+ * `GET /api/bots/:bot/turns` projection, so neither invents client state
2652
+ * and the one-bubble-per-send contract is untouched.
2653
+ */
2654
+ let stopRunChannel: (() => void) | undefined;
2655
+ const stopRunChannelWatch = watch(
2656
+ () => web.value.activeBotId,
2657
+ (botId) => {
2658
+ stopRunChannel?.();
2659
+ stopRunChannel = undefined;
2660
+ if (!botId || !ctx.transport.watchBotState) return;
2661
+ const generation = selectionGeneration;
2662
+ stopRunChannel = ctx.transport.watchBotState(botId, {
2663
+ async invalidate(topic) {
2664
+ // A reset carries no topic and means "read everything again".
2665
+ if (topic !== undefined && topic !== "runs") return;
2666
+ if (
2667
+ generation !== selectionGeneration ||
2668
+ web.value.activeBotId !== botId
2669
+ )
2670
+ return;
2671
+ await deliverNotifications(botId, generation);
2672
+ },
2673
+ status() {
2674
+ // The channel's health is not the transcript's: an unavailable
2675
+ // socket falls back to the observation below, which is what a
2676
+ // client without one uses anyway.
2677
+ },
2678
+ });
2679
+ },
2680
+ { immediate: true },
2681
+ );
2682
+
2683
+ const stopRunObservation = watch(
2684
+ () => [web.value.activeBotId, web.value.activeRunId] as const,
2685
+ ([botId, runId]) => {
2686
+ // The send path owns the run it started: its POST is the observation,
2687
+ // and `stopRun` starts its own. This is for every other way a client
2688
+ // finds itself watching a Turn it is not holding open.
2689
+ if (!botId || !runId || activeRequest || runObserver) return;
2690
+ const generation = selectionGeneration;
2691
+ const observer = new AbortController();
2692
+ runObserver = observer;
2693
+ void observeRunUntilTerminal(botId, runId, generation, observer.signal)
2694
+ .catch(() => {
2695
+ // `observeRunUntilTerminal` reports its own failures; a rejection
2696
+ // here would be an unhandled one.
2697
+ })
2698
+ .finally(() => {
2699
+ if (runObserver === observer) runObserver = undefined;
2700
+ });
2701
+ },
2702
+ { immediate: true },
2703
+ );
2704
+
2605
2705
  return [
2606
2706
  ctx.provide(clientSurfaceRegistryKey, surfaces),
2607
2707
  // The shared client projection is updated by the contracts lane. This cast
@@ -2620,6 +2720,9 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2620
2720
  () => {
2621
2721
  stopEntrySync();
2622
2722
  stopRunFollow();
2723
+ stopRunChannelWatch();
2724
+ stopRunChannel?.();
2725
+ stopRunObservation();
2623
2726
  stopSourceFollow();
2624
2727
  for (const dispose of entryDisposers.splice(0).toReversed()) dispose();
2625
2728
  activeRequest?.abort();
@@ -2640,6 +2743,13 @@ function replaceMessage(
2640
2743
  if (index >= 0) messages[index] = replacement;
2641
2744
  }
2642
2745
 
2746
+ /** Takes back both optimistic lines of a send the Bot never admitted. */
2747
+ function removeMessages(messages: WebChatMessage[], runId: string): void {
2748
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
2749
+ if (messages[index]?.runId === runId) messages.splice(index, 1);
2750
+ }
2751
+ }
2752
+
2643
2753
  export default shellClientPlugin;
2644
2754
 
2645
2755
  /**
@@ -0,0 +1,122 @@
1
+ import { plugin } from "bun";
2
+ import { expect, test } from "bun:test";
3
+ import type { UserSettingsViewV1 } from "@frockbot/configuration-core";
4
+ import type { Ref } from "vue";
5
+ import type { FrockBotWebData } from "../shared.js";
6
+
7
+ // Bun has no single-file-component loader; the shell's Vue modules stand in as
8
+ // empty components, exactly as `index.test.ts` does.
9
+ plugin({
10
+ name: "shell-client-vue-no-bot-loader",
11
+ setup(build) {
12
+ build.onLoad({ filter: /\.vue$/ }, () => ({
13
+ contents: "export default {};",
14
+ loader: "js",
15
+ }));
16
+ },
17
+ });
18
+
19
+ const { shellClientPlugin } = await import("./index.js");
20
+
21
+ /**
22
+ * A first-run account: the platform model resolves against a ready ambient
23
+ * Flock AI Connection whose Catalog is fresh, and no Bot has been created yet.
24
+ * The account's model is available, so the shell must not tell the User it is
25
+ * unavailable before they have made their first Bot.
26
+ */
27
+ test("does not report the account model unavailable before a Bot exists", async () => {
28
+ const user: UserSettingsViewV1 = {
29
+ schemaVersion: 1,
30
+ revision: 3,
31
+ profile: { name: "FrockBot user" },
32
+ packages: [
33
+ { packageId: "provider-flock-ai", version: "0.0.1", state: "installed" },
34
+ ],
35
+ connections: [
36
+ {
37
+ connectionId: "flock-ai-ambient",
38
+ packageId: "provider-flock-ai",
39
+ connectionTypeId: "flock-ai-account",
40
+ displayName: "Flock AI",
41
+ state: "ready",
42
+ providerType: "flock-ai",
43
+ safeMetadata: {},
44
+ modelCatalog: {
45
+ schemaVersion: 1,
46
+ generation: "flock-ai-static-v1",
47
+ state: "fresh",
48
+ models: [
49
+ {
50
+ providerModelId: "@flock/auto",
51
+ displayName: "Auto (recommended)",
52
+ capabilities: { tools: true, vision: false, reasoning: true },
53
+ source: "discovered",
54
+ },
55
+ ],
56
+ },
57
+ },
58
+ ],
59
+ platformModel: {
60
+ connectionId: "flock-ai-ambient",
61
+ providerModelId: "@flock/auto",
62
+ },
63
+ };
64
+
65
+ let provided: Ref<FrockBotWebData> | undefined;
66
+ await shellClientPlugin({
67
+ transport: {
68
+ turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
69
+ readApplicationManifest: () =>
70
+ Promise.resolve({
71
+ schemaVersion: 1,
72
+ deployment: { userId: "development", applicationHash: "hash-1" },
73
+ applicationHash: "hash-1",
74
+ packages: [
75
+ {
76
+ id: "provider-flock-ai",
77
+ displayName: "Flock AI",
78
+ version: "0.0.1",
79
+ contributions: ["backend", "runtime"],
80
+ configuration: {
81
+ settings: [],
82
+ connectionTypes: [
83
+ {
84
+ id: "flock-ai-account",
85
+ displayName: "Flock AI",
86
+ allowMultiple: false,
87
+ authorization: { kind: "ambient-native" },
88
+ capabilities: ["flock-ai-models"],
89
+ },
90
+ ],
91
+ capabilities: [
92
+ {
93
+ id: "flock-ai-models",
94
+ kind: "model",
95
+ connectionTypes: ["flock-ai-account"],
96
+ admission: { turnTypes: ["chat"] },
97
+ },
98
+ ],
99
+ },
100
+ },
101
+ ],
102
+ }),
103
+ readConfiguration: () => Promise.resolve(user),
104
+ },
105
+ slot: () => () => {},
106
+ inject: () => {
107
+ throw new Error("unexpected client provider injection");
108
+ },
109
+ provide: (_key, value) => {
110
+ provided = value as Ref<FrockBotWebData>;
111
+ return () => {};
112
+ },
113
+ });
114
+ if (!provided) throw new Error("shell data was not provided");
115
+
116
+ await provided.value.loadPluginCatalog();
117
+
118
+ // No Bot has been created, so `activeBotId` is unset.
119
+ expect(provided.value.activeBotId).toBeUndefined();
120
+ expect(provided.value.modelLabel).toBe("Auto (recommended) · Flock AI");
121
+ expect(provided.value.modelReady).toBe(true);
122
+ });
@@ -290,6 +290,17 @@
290
290
  text-align: center;
291
291
  }
292
292
 
293
+ /*
294
+ * Why a Turn ends where it does — stopped, or replaced by a later message.
295
+ * Quiet, and under the answer rather than instead of it: the words the Bot
296
+ * already said are the Turn's content, and this is only its last line.
297
+ */
298
+ .message-notice {
299
+ margin: 2px 0 0;
300
+ color: var(--frock-text-muted);
301
+ font-size: var(--frock-text-xs);
302
+ }
303
+
293
304
  /* An assistant Turn is its avatar and, once there is text, its bubble. */
294
305
  .message-assistant {
295
306
  flex-direction: row;
@@ -411,6 +422,56 @@
411
422
  width: min(640px, 84%);
412
423
  }
413
424
 
425
+ /*
426
+ * Tool calls. Quieter than anything the Bot said: the User is watching work
427
+ * happen, not reading a message, so a chip carries the name and its state and
428
+ * opens in place to what the tool returned.
429
+ */
430
+
431
+ .message-tools {
432
+ display: flex;
433
+ flex-direction: column;
434
+ gap: 6px;
435
+ width: min(640px, 84%);
436
+ }
437
+
438
+ .tool-chip {
439
+ display: flex;
440
+ flex-wrap: wrap;
441
+ gap: 8px;
442
+ align-items: baseline;
443
+ padding: 6px 10px;
444
+ font: inherit;
445
+ color: var(--frock-text-muted);
446
+ text-align: left;
447
+ cursor: pointer;
448
+ background: var(--frock-surface);
449
+ border: 1px solid var(--frock-border);
450
+ border-radius: 10px;
451
+ }
452
+
453
+ .tool-chip-name {
454
+ color: var(--frock-text);
455
+ font-weight: 600;
456
+ }
457
+
458
+ .tool-chip-status {
459
+ flex: 1 1 auto;
460
+ font-size: var(--frock-text-xs);
461
+ }
462
+
463
+ .tool-chip-failed .tool-chip-status {
464
+ color: var(--frock-danger-text);
465
+ }
466
+
467
+ .tool-chip-result {
468
+ flex: 1 0 100%;
469
+ max-height: 12em;
470
+ overflow: auto;
471
+ white-space: pre-wrap;
472
+ word-break: break-word;
473
+ }
474
+
414
475
  /*
415
476
  * Dispatched subagents. A chip is deliberately quiet — a subagent is work the
416
477
  * Bot delegated, not something it said — and opens in place to the summary the
@@ -22,6 +22,7 @@ import {
22
22
  projectClientRunLookupV1,
23
23
  projectClientRunListV1,
24
24
  projectClientRunV1,
25
+ projectClientRunOrDegradedV1,
25
26
  projectClientTurnV1,
26
27
  } from "./run-protocol.js";
27
28
 
@@ -1357,6 +1358,78 @@ describe("dispatched subagents in the run projection", () => {
1357
1358
  expect(() => decodeClientRunPageV1(tampered)).toThrow();
1358
1359
  });
1359
1360
 
1361
+ test("a run whose record cannot be read degrades instead of failing the list", () => {
1362
+ // One badly written record — a resolve that wrote a shape the record does
1363
+ // not allow — used to answer 500 for the whole transcript, for good.
1364
+ const broken = {
1365
+ ...storedRun([], "running"),
1366
+ status: "reconciliation-required",
1367
+ phase: "executing",
1368
+ } as unknown as StoredRun;
1369
+ expect(() => projectClientRunV1(broken)).toThrow();
1370
+
1371
+ const degraded = projectClientRunOrDegradedV1(broken);
1372
+ expect(degraded).toMatchObject({
1373
+ runId: "run-events",
1374
+ status: "failed",
1375
+ outcome: { type: "failed" },
1376
+ });
1377
+ // And the degraded row is itself a valid projection, so the page decodes.
1378
+ expect(
1379
+ decodeClientRunPageV1(
1380
+ createClientRunListV1([degraded], { truncated: false }),
1381
+ ).runs,
1382
+ ).toHaveLength(1);
1383
+ });
1384
+
1385
+ test("an interrupted Turn keeps the text it had already streamed", () => {
1386
+ const streamed: SessionEvent[] = [
1387
+ event({
1388
+ type: "assistant/chunk",
1389
+ seq: 0,
1390
+ timestamp,
1391
+ turn: 1,
1392
+ step: 1,
1393
+ requestId: "request-1",
1394
+ text: "The three things to know are",
1395
+ }),
1396
+ event({
1397
+ type: "assistant/chunk",
1398
+ seq: 1,
1399
+ timestamp,
1400
+ turn: 1,
1401
+ step: 1,
1402
+ requestId: "request-1",
1403
+ text: " first, that",
1404
+ }),
1405
+ ];
1406
+
1407
+ for (const status of ["cancelled", "superseded"] as const) {
1408
+ const projected = projectClientRunV1({
1409
+ ...storedRun(streamed, status),
1410
+ ...(status === "superseded"
1411
+ ? {
1412
+ supersededAt: "2026-08-28T00:00:05.000Z",
1413
+ supersededBy: "run-next",
1414
+ }
1415
+ : {}),
1416
+ });
1417
+ expect(projected.outcome).toMatchObject({
1418
+ type: status,
1419
+ text: "The three things to know are first, that",
1420
+ });
1421
+ // And it survives the wire: the client reads it as the Turn's text, with
1422
+ // the notice kept separately as the line that says why it stops there.
1423
+ const decoded = decodeClientRunPageV1(
1424
+ createClientRunListV1([projected], { truncated: false }),
1425
+ ).runs[0];
1426
+ expect(decoded?.responseText).toBe(
1427
+ "The three things to know are first, that",
1428
+ );
1429
+ expect(decoded?.failure).toBeDefined();
1430
+ }
1431
+ });
1432
+
1360
1433
  test("refuses a chip whose background flag is not a boolean", () => {
1361
1434
  const page = createClientRunListV1(
1362
1435
  [projectClientRunV1(storedRun([dispatched]))],
@@ -52,6 +52,63 @@ export type ClientRunStatusV1 =
52
52
  const CANCELLED_RUN_MESSAGE = "Stopped by an authenticated Stop command.";
53
53
  const SUPERSEDED_RUN_MESSAGE = "Interrupted by your next message.";
54
54
 
55
+ /**
56
+ * Why the Bot declined to admit a Turn. A refusal is an ordinary answer — the
57
+ * Bot is busy with a Turn this command did not ask to replace, is holding an
58
+ * effect only a User can settle, or the command was fenced or already used —
59
+ * so the client shows the reason and keeps the person's text rather than
60
+ * treating it as a failure of the send.
61
+ */
62
+ export type ClientTurnRefusalReasonV1 =
63
+ "busy" | "reconciliation-required" | "fenced" | "duplicate";
64
+
65
+ /** The versioned body a refused Turn answers with, decoded by the client. */
66
+ export interface ClientTurnRefusalV1 {
67
+ schemaVersion: 1;
68
+ status: "refused";
69
+ reason: ClientTurnRefusalReasonV1;
70
+ error: string;
71
+ }
72
+
73
+ const TURN_REFUSAL_REASONS_V1: readonly ClientTurnRefusalReasonV1[] = [
74
+ "busy",
75
+ "reconciliation-required",
76
+ "fenced",
77
+ "duplicate",
78
+ ];
79
+
80
+ /** The refusal a response body carries, or `undefined` when it carries none. */
81
+ export function decodeClientTurnRefusalV1(
82
+ value: unknown,
83
+ ): ClientTurnRefusalV1 | undefined {
84
+ if (typeof value !== "object" || value === null) return undefined;
85
+ const body = value as Record<string, unknown>;
86
+ if (body.schemaVersion !== 1 || body.status !== "refused") return undefined;
87
+ if (typeof body.error !== "string") return undefined;
88
+ const reason = TURN_REFUSAL_REASONS_V1.find(
89
+ (candidate) => candidate === body.reason,
90
+ );
91
+ if (!reason) return undefined;
92
+ return {
93
+ schemaVersion: 1,
94
+ status: "refused",
95
+ reason,
96
+ error: wireString(body, "error", MAX_FAILURE_BYTES, "turn refusal"),
97
+ };
98
+ }
99
+
100
+ /**
101
+ * A refusal, as an error, because that is how a transport reports a non-2xx.
102
+ * The reason survives on the error so the client can tell "the Bot said no"
103
+ * from "the send may or may not have happened".
104
+ */
105
+ export class ClientTurnRefusedErrorV1 extends Error {
106
+ constructor(readonly refusal: ClientTurnRefusalV1) {
107
+ super(refusal.error);
108
+ this.name = "ClientTurnRefusedErrorV1";
109
+ }
110
+ }
111
+
55
112
  export type ClientRunEventV1 =
56
113
  | {
57
114
  type: "run/events-truncated";
@@ -123,8 +180,13 @@ export interface ClientDynamicToolCallInputV1 {
123
180
  export type ClientRunOutcomeV1 =
124
181
  | { type: "completed"; text: string }
125
182
  | { type: "failed"; message: string }
126
- | { type: "cancelled"; message: string }
127
- | { type: "superseded"; message: string };
183
+ /**
184
+ * A Turn a Stop or a later message ended keeps what it had already said:
185
+ * `text` is that partial answer, and `message` is the line saying why it
186
+ * ends where it does (ADR 0024).
187
+ */
188
+ | { type: "cancelled"; message: string; text?: string }
189
+ | { type: "superseded"; message: string; text?: string };
128
190
 
129
191
  export interface ClientRunRecoveryV1 {
130
192
  action: "resume";
@@ -565,6 +627,33 @@ function visibleEvents(
565
627
  return projection;
566
628
  }
567
629
 
630
+ /**
631
+ * What an interrupted Turn had already said, read back out of its journal.
632
+ *
633
+ * The kernel records a Turn's answer as it streams, so a Turn stopped or
634
+ * superseded mid-sentence still holds every word it sent. It never reached a
635
+ * `responseText`, because it never completed — but the partial answer is a
636
+ * fact about what the person watched arrive, not a claim that the Turn
637
+ * succeeded, and the thread keeps it instead of replacing it with a notice.
638
+ */
639
+ function interruptedOutcomeTextV1(run: StoredRun): { text?: string } {
640
+ let requestId: string | undefined;
641
+ let text = run.responseText ?? "";
642
+ for (const event of run.events) {
643
+ if (event.type === "assistant/chunk") {
644
+ if (event.requestId !== requestId) {
645
+ requestId = event.requestId;
646
+ text = "";
647
+ }
648
+ text += event.text;
649
+ } else if (event.type === "assistant/message") {
650
+ requestId = event.requestId;
651
+ text = event.text;
652
+ }
653
+ }
654
+ return text ? { text: truncateWireString(text, MAX_OUTCOME_BYTES) } : {};
655
+ }
656
+
568
657
  function runStatus(run: StoredRun): ClientRunStatusV1 {
569
658
  return requireStoredRunV1(run).status;
570
659
  }
@@ -589,11 +678,13 @@ export function projectClientRunV1(run: StoredRun): ClientRunV1 {
589
678
  ? ({
590
679
  type: "cancelled",
591
680
  message: CANCELLED_RUN_MESSAGE,
681
+ ...interruptedOutcomeTextV1(run),
592
682
  } satisfies ClientRunOutcomeV1)
593
683
  : status === "superseded"
594
684
  ? ({
595
685
  type: "superseded",
596
686
  message: SUPERSEDED_RUN_MESSAGE,
687
+ ...interruptedOutcomeTextV1(run),
597
688
  } satisfies ClientRunOutcomeV1)
598
689
  : undefined;
599
690
  const recovery =
@@ -695,6 +786,39 @@ export function projectClientTurnV1(result: BotTurnCompletion): ClientTurnV1 {
695
786
  };
696
787
  }
697
788
 
789
+ /**
790
+ * One stored run on the wire, degraded rather than thrown when the record
791
+ * cannot be read.
792
+ *
793
+ * A single unreadable run — an older shape, or one a bug wrote badly — used to
794
+ * fail the whole transcript: `GET /turns` answered 500 for every request after
795
+ * it, and the person's entire conversation disappeared behind one bad row. The
796
+ * transcript keeps its shape and says which Turn it could not read.
797
+ */
798
+ export function projectClientRunOrDegradedV1(run: StoredRun): ClientRunV1 {
799
+ try {
800
+ return projectClientRunV1(run);
801
+ } catch {
802
+ const admittedAt =
803
+ typeof run.acceptedAt === "string" &&
804
+ Number.isFinite(Date.parse(run.acceptedAt))
805
+ ? run.acceptedAt
806
+ : new Date(0).toISOString();
807
+ return {
808
+ schemaVersion: 2,
809
+ runId: truncate(String(run.runId ?? "unknown"), MAX_RUN_ID_LENGTH),
810
+ admittedAt,
811
+ input: typeof run.input === "string" ? run.input : "",
812
+ status: "failed",
813
+ events: [],
814
+ outcome: {
815
+ type: "failed",
816
+ message: "This Turn's record could not be read.",
817
+ },
818
+ };
819
+ }
820
+ }
821
+
698
822
  export function projectClientRunListV1(
699
823
  runs: readonly StoredRun[],
700
824
  ): ClientRunListV1 {
@@ -1004,22 +1128,34 @@ function decodeOutcome(
1004
1128
  };
1005
1129
  }
1006
1130
  if (outcome.type === "cancelled" && runStatus === "cancelled") {
1007
- exactKeys(outcome, ["type", "message"], "run.outcome");
1131
+ exactKeys(outcome, ["type", "message", "text"], "run.outcome");
1008
1132
  return {
1009
1133
  type: "cancelled",
1010
1134
  message: wireString(outcome, "message", MAX_FAILURE_BYTES, "run.outcome"),
1135
+ ...decodeInterruptedTextV1(outcome),
1011
1136
  };
1012
1137
  }
1013
1138
  if (outcome.type === "superseded" && runStatus === "superseded") {
1014
- exactKeys(outcome, ["type", "message"], "run.outcome");
1139
+ exactKeys(outcome, ["type", "message", "text"], "run.outcome");
1015
1140
  return {
1016
1141
  type: "superseded",
1017
1142
  message: wireString(outcome, "message", MAX_FAILURE_BYTES, "run.outcome"),
1143
+ ...decodeInterruptedTextV1(outcome),
1018
1144
  };
1019
1145
  }
1020
1146
  throw new Error("run.outcome does not match run.status");
1021
1147
  }
1022
1148
 
1149
+ /** The partial answer an interrupted Turn kept, when it said anything. */
1150
+ function decodeInterruptedTextV1(outcome: Record<string, unknown>): {
1151
+ text?: string;
1152
+ } {
1153
+ if (outcome.text === undefined) return {};
1154
+ return {
1155
+ text: wireString(outcome, "text", MAX_OUTCOME_BYTES, "run.outcome"),
1156
+ };
1157
+ }
1158
+
1023
1159
  function decodeRecovery(
1024
1160
  value: unknown,
1025
1161
  runStatus: ClientRunStatusV1,
@@ -1114,8 +1250,12 @@ function decodeRun(value: unknown): ClientRun {
1114
1250
  ...(run.queued === true ? { queued: true as const } : {}),
1115
1251
  ...(outcome?.type === "completed" ? { responseText: outcome.text } : {}),
1116
1252
  ...(outcome?.type === "failed" ? { failure: outcome.message } : {}),
1117
- ...(outcome?.type === "cancelled" ? { failure: outcome.message } : {}),
1118
- ...(outcome?.type === "superseded" ? { failure: outcome.message } : {}),
1253
+ ...(outcome?.type === "cancelled" || outcome?.type === "superseded"
1254
+ ? {
1255
+ failure: outcome.message,
1256
+ ...(outcome.text ? { responseText: outcome.text } : {}),
1257
+ }
1258
+ : {}),
1119
1259
  ...(recovery ? { failure: recovery.message, recovery } : {}),
1120
1260
  };
1121
1261
  }
package/src/shared.ts CHANGED
@@ -101,6 +101,12 @@ export interface WebChatMessage {
101
101
  * reached, not a state the User has to understand.
102
102
  */
103
103
  pending?: boolean;
104
+ /**
105
+ * A line under the bubble saying why the Turn ends where it does — it was
106
+ * stopped, or a later message took its place. The text above it is what the
107
+ * Bot had already said, which it keeps (ADR 0024).
108
+ */
109
+ notice?: string;
104
110
  tools: WebToolActivity[];
105
111
  /** The typed payloads this Turn sent to the user, oldest first. */
106
112
  sends: WebSendPayload[];