@frockbot/plugin-shell 0.3.4 → 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.4",
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.4",
51
- "@frockbot/application-foundation": "0.3.4",
52
- "@frockbot/catalog-core": "0.3.4",
53
- "@frockbot/client-core": "0.3.4",
54
- "@frockbot/client-ui": "0.3.4",
55
- "@frockbot/computer-core": "0.3.4",
56
- "@frockbot/computer-host-protocol": "0.3.4",
57
- "@frockbot/configuration-core": "0.3.4",
58
- "@frockbot/connection-core": "0.3.4",
59
- "@frockbot/kernel-agent-loop": "0.3.4",
60
- "@frockbot/kernel-composition": "0.3.4",
61
- "@frockbot/kernel-contracts": "0.3.4",
62
- "@frockbot/kernel-do": "0.3.4",
63
- "@frockbot/machine-protocol": "0.3.4",
64
- "@frockbot/plugin-applets": "0.3.4",
65
- "@frockbot/plugin-authoring": "0.3.4",
66
- "@frockbot/plugin-bot-template": "0.3.4",
67
- "@frockbot/plugin-computer": "0.3.4",
68
- "@frockbot/plugin-flock": "0.3.4",
69
- "@frockbot/plugin-image": "0.3.4",
70
- "@frockbot/plugin-machine-messages": "0.3.4",
71
- "@frockbot/plugin-mcp": "0.3.4",
72
- "@frockbot/plugin-memory": "0.3.4",
73
- "@frockbot/plugin-package-catalog": "0.3.4",
74
- "@frockbot/plugin-routines": "0.3.4",
75
- "@frockbot/plugin-skills": "0.3.4",
76
- "@frockbot/plugin-subagents": "0.3.4",
77
- "@frockbot/plugin-user-machine": "0.3.4",
78
- "@frockbot/protocol": "0.3.4",
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.4",
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 },
@@ -405,6 +405,32 @@ function taskChipsOf(message: WebChatMessage): Array<{
405
405
  });
406
406
  }
407
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
+
408
434
  /** Which chips the User has opened. Local, and per chip. */
409
435
  const expandedTasks = ref(new Set<string>());
410
436
 
@@ -431,6 +457,7 @@ function isVisible(message: WebChatMessage): boolean {
431
457
  // visible act was dispatching a subagent.
432
458
  return (
433
459
  message.text.length > 0 ||
460
+ (message.notice?.length ?? 0) > 0 ||
434
461
  message.tools.some((tool) => iframeEntriesFor(tool).length > 0) ||
435
462
  message.sends.length > 0 ||
436
463
  (message.tasks?.length ?? 0) > 0 ||
@@ -859,6 +886,13 @@ function handleComposerKeydown(event: KeyboardEvent): void {
859
886
  <div v-if="message.text" class="message-bubble">
860
887
  <UiMarkdown :text="message.text" />
861
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>
862
896
  <template v-for="tool in message.tools" :key="tool.id">
863
897
  <PackageIframeHost
864
898
  v-for="entry in iframeEntriesFor(tool)"
@@ -906,6 +940,33 @@ function handleComposerKeydown(event: KeyboardEvent): void {
906
940
  :send="send"
907
941
  />
908
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>
909
970
  <!--
910
971
  The subagents this Turn dispatched. The child's own Session is
911
972
  never in this transcript, so the chip is the whole of what the
@@ -1847,14 +1847,18 @@ describe("hosted Stop", () => {
1847
1847
  status: "reconciliation-required",
1848
1848
  message:
1849
1849
  "Stop accepted; reconciling the provider outcome before cancelling.",
1850
- 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,
1851
1853
  });
1852
1854
 
1853
1855
  await provided.value.stopRun();
1854
1856
  expect(provided.value.activeRun).toBeUndefined();
1855
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.
1856
1860
  expect(provided.value.messages[1]).toMatchObject({
1857
- text: "Stopped by an authenticated Stop command.",
1861
+ notice: "Stopped by an authenticated Stop command.",
1858
1862
  status: "aborted",
1859
1863
  });
1860
1864
 
@@ -1911,12 +1915,15 @@ describe("hosted Stop", () => {
1911
1915
 
1912
1916
  await provided.value.stopRun();
1913
1917
 
1914
- 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);
1915
1922
  expect(provided.value.activeRun).toBeUndefined();
1916
1923
  expect(provided.value.activeRunId).toBeUndefined();
1917
1924
  expect(provided.value.messages.at(-1)).toMatchObject({
1918
1925
  runId: "run-1",
1919
- text: "Stopped by an authenticated Stop command.",
1926
+ notice: "Stopped by an authenticated Stop command.",
1920
1927
  status: "aborted",
1921
1928
  });
1922
1929
  });
@@ -2897,7 +2904,7 @@ describe("a message sent while a Turn is running", () => {
2897
2904
  // The superseded Turn keeps the quiet treatment a stopped one gets.
2898
2905
  expect(state.messages[1]).toMatchObject({
2899
2906
  status: "aborted",
2900
- text: "Interrupted by your next message.",
2907
+ notice: "Interrupted by your next message.",
2901
2908
  });
2902
2909
  });
2903
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),
@@ -2296,6 +2305,12 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2296
2305
  const observed = web.value.activeRunId;
2297
2306
  const supersedes = observed ? { runId: observed } : {};
2298
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;
2299
2314
  web.value.error = undefined;
2300
2315
  web.value.messages.push(
2301
2316
  {
@@ -2380,6 +2395,15 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2380
2395
  web.value.activeBotId !== botId
2381
2396
  )
2382
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
+ }
2383
2407
  const aborted =
2384
2408
  error instanceof DOMException && error.name === "AbortError";
2385
2409
  replaceMessage(web.value.messages, pendingRunId, {
@@ -2435,6 +2459,14 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2435
2459
  ) {
2436
2460
  web.value.activeRunId = undefined;
2437
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
+ }
2438
2470
  }
2439
2471
  },
2440
2472
  async resumeRun(runId: string): Promise<void> {
@@ -2606,6 +2638,70 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2606
2638
  },
2607
2639
  );
2608
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
+
2609
2705
  return [
2610
2706
  ctx.provide(clientSurfaceRegistryKey, surfaces),
2611
2707
  // The shared client projection is updated by the contracts lane. This cast
@@ -2624,6 +2720,9 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
2624
2720
  () => {
2625
2721
  stopEntrySync();
2626
2722
  stopRunFollow();
2723
+ stopRunChannelWatch();
2724
+ stopRunChannel?.();
2725
+ stopRunObservation();
2627
2726
  stopSourceFollow();
2628
2727
  for (const dispose of entryDisposers.splice(0).toReversed()) dispose();
2629
2728
  activeRequest?.abort();
@@ -2644,6 +2743,13 @@ function replaceMessage(
2644
2743
  if (index >= 0) messages[index] = replacement;
2645
2744
  }
2646
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
+
2647
2753
  export default shellClientPlugin;
2648
2754
 
2649
2755
  /**
@@ -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[];