@frockbot/plugin-shell 0.3.2 → 0.3.3

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.
@@ -0,0 +1,377 @@
1
+ /**
2
+ * What a superseded Turn does to the durable state the Shell owns: how its
3
+ * unsettled effects are classified, what it leaves for the Turn that replaced
4
+ * it, and what a Stop arriving afterwards is allowed to touch.
5
+ */
6
+ import { describe, expect, test } from "bun:test";
7
+ import type { SessionEvent } from "@frockbot/kernel-contracts";
8
+ import {
9
+ initializeBotSettingsV1,
10
+ type UserSettingsViewV1,
11
+ } from "@frockbot/configuration-core";
12
+ import {
13
+ createShellBotBackendContribution,
14
+ type ShellBotBackendHost,
15
+ } from "./backend.js";
16
+ import {
17
+ botTurnCommandFingerprintV1,
18
+ type StoredRun,
19
+ } from "./backend-contracts.js";
20
+ import { planInterruptedRunRecoveryV1 } from "./backend-recovery.js";
21
+ import { projectClientRunV1 } from "./run-protocol.js";
22
+ import { TaskStore } from "@frockbot/plugin-subagents/store";
23
+
24
+ class MemoryStorage {
25
+ readonly values = new Map<string, unknown>();
26
+ alarmAt: number | undefined;
27
+
28
+ get<T>(key: string): Promise<T | undefined> {
29
+ return Promise.resolve(
30
+ structuredClone(this.values.get(key)) as T | undefined,
31
+ );
32
+ }
33
+
34
+ put(key: string | Record<string, unknown>, value?: unknown): Promise<void> {
35
+ if (typeof key === "string") this.values.set(key, structuredClone(value));
36
+ else {
37
+ for (const [entry, item] of Object.entries(key)) {
38
+ this.values.set(entry, structuredClone(item));
39
+ }
40
+ }
41
+ return Promise.resolve();
42
+ }
43
+
44
+ delete(key: string): Promise<boolean> {
45
+ return Promise.resolve(this.values.delete(key));
46
+ }
47
+
48
+ list<T>(options: { prefix?: string }): Promise<Map<string, T>> {
49
+ return Promise.resolve(
50
+ new Map(
51
+ [...this.values.entries()].filter(([key]) =>
52
+ key.startsWith(options.prefix ?? ""),
53
+ ) as Array<[string, T]>,
54
+ ),
55
+ );
56
+ }
57
+
58
+ transaction<T>(callback: (storage: MemoryStorage) => Promise<T>): Promise<T> {
59
+ return callback(this);
60
+ }
61
+
62
+ setAlarm(timestamp: number): Promise<void> {
63
+ this.alarmAt = timestamp;
64
+ return Promise.resolve();
65
+ }
66
+
67
+ deleteAlarm(): Promise<void> {
68
+ this.alarmAt = undefined;
69
+ return Promise.resolve();
70
+ }
71
+ }
72
+
73
+ const user: UserSettingsViewV1 = {
74
+ schemaVersion: 1,
75
+ revision: 0,
76
+ profile: { name: "User" },
77
+ packages: [],
78
+ connections: [],
79
+ };
80
+
81
+ function host(storage: MemoryStorage): ShellBotBackendHost {
82
+ return {
83
+ state: { storage } as unknown as DurableObjectState,
84
+ env: {
85
+ USER_CONFIGURATIONS: {
86
+ idFromName: () => "user-id",
87
+ get: () => ({ readConfiguration: () => Promise.resolve(user) }),
88
+ },
89
+ } as unknown as ShellBotBackendHost["env"],
90
+ };
91
+ }
92
+
93
+ const identity = { userId: "user-1", botId: "primary" };
94
+ const turn = {
95
+ ...identity,
96
+ runId: "run-1",
97
+ sessionId: "user-1:primary",
98
+ acceptedAt: "2026-09-03T00:00:00.000Z",
99
+ text: "hello",
100
+ };
101
+ const timestamp = "2026-09-03T00:00:01.000Z";
102
+
103
+ function events(...inputs: Record<string, unknown>[]): SessionEvent[] {
104
+ return inputs.map(
105
+ (event, seq) => ({ ...event, seq, timestamp }) as SessionEvent,
106
+ );
107
+ }
108
+
109
+ /** A Turn that had dispatched a model request and asked a tool to run. */
110
+ function toolIntentEvents(): SessionEvent[] {
111
+ return events(
112
+ { type: "session/created", createdAt: timestamp },
113
+ { type: "turn/start", turn: 1 },
114
+ { type: "step/start", turn: 1, step: 1 },
115
+ {
116
+ type: "model/request",
117
+ turn: 1,
118
+ step: 1,
119
+ request: {
120
+ requestId: "request-1",
121
+ provider: "foundation",
122
+ model: "foundation-model",
123
+ system: "system",
124
+ messages: [{ role: "user", content: "hello" }],
125
+ tools: [],
126
+ },
127
+ },
128
+ {
129
+ type: "assistant/message",
130
+ turn: 1,
131
+ step: 1,
132
+ requestId: "request-1",
133
+ text: "on it",
134
+ toolCalls: [{ id: "provider-call", name: "effect", input: {} }],
135
+ },
136
+ {
137
+ type: "tool/call",
138
+ turn: 1,
139
+ step: 1,
140
+ occurrenceId: "tool:1:1:0",
141
+ name: "effect",
142
+ input: {},
143
+ },
144
+ );
145
+ }
146
+
147
+ function storedRun(overrides: Partial<StoredRun> = {}): StoredRun {
148
+ return {
149
+ runId: turn.runId,
150
+ commandFingerprint: botTurnCommandFingerprintV1(turn),
151
+ sessionId: turn.sessionId,
152
+ acceptedAt: turn.acceptedAt,
153
+ input: turn.text,
154
+ events: [],
155
+ effectAdmissions: [],
156
+ status: "running",
157
+ phase: "executing",
158
+ compositionGenerationId: "generation-1",
159
+ configurationSnapshot: initializeBotSettingsV1(identity.botId),
160
+ previousEventCount: 0,
161
+ ...overrides,
162
+ } as StoredRun;
163
+ }
164
+
165
+ async function fixture(run: StoredRun = storedRun()): Promise<{
166
+ storage: MemoryStorage;
167
+ contribution: ReturnType<typeof createShellBotBackendContribution>;
168
+ }> {
169
+ const storage = new MemoryStorage();
170
+ const contribution = createShellBotBackendContribution(host(storage));
171
+ await contribution.materializeSettings(identity, { name: "Primary" });
172
+ storage.values.set(`run:${run.runId}`, structuredClone(run));
173
+ storage.values.set("active-run", run.runId);
174
+ return { storage, contribution };
175
+ }
176
+
177
+ describe("a superseded run settles its effects exactly as a stopped one does", () => {
178
+ test("a tool effect that was never admitted is interrupted, never re-run", () => {
179
+ const run = storedRun({
180
+ events: toolIntentEvents(),
181
+ supersededAt: timestamp,
182
+ supersededBy: "run-2",
183
+ effectAdmissions: [
184
+ { kind: "model", effectId: "request-1", outcome: "admitted" },
185
+ { kind: "tool", effectId: "tool:1:1:0", outcome: "fenced" },
186
+ ],
187
+ });
188
+
189
+ const plan = planInterruptedRunRecoveryV1(run, run.events);
190
+
191
+ expect(plan.kind).toBe("cancel");
192
+ if (plan.kind !== "cancel") throw new Error("expected cancellation");
193
+ expect(
194
+ plan.events.find((event) => event.type === "tool/result"),
195
+ ).toMatchObject({ status: "interrupted", isError: true });
196
+ expect(plan.events.at(-1)).toMatchObject({
197
+ type: "turn/end",
198
+ outcome: "interrupted",
199
+ });
200
+ });
201
+
202
+ test("a tool effect that was admitted reconciles rather than settling", () => {
203
+ const run = storedRun({
204
+ events: toolIntentEvents(),
205
+ supersededAt: timestamp,
206
+ supersededBy: "run-2",
207
+ effectAdmissions: [
208
+ { kind: "model", effectId: "request-1", outcome: "admitted" },
209
+ { kind: "tool", effectId: "tool:1:1:0", outcome: "admitted" },
210
+ ],
211
+ });
212
+
213
+ // Identical to Stop: an effect that may already have run is retrieved, not
214
+ // assumed away, and the Turn that replaced it waits for the answer.
215
+ expect(planInterruptedRunRecoveryV1(run, run.events)).toEqual({
216
+ kind: "reconcile",
217
+ });
218
+ });
219
+
220
+ test("a run carrying neither intent is refused a plan", () => {
221
+ expect(() =>
222
+ planInterruptedRunRecoveryV1(
223
+ storedRun({ events: toolIntentEvents() }),
224
+ toolIntentEvents(),
225
+ ),
226
+ ).toThrow(`run "${turn.runId}" has no durable stop or supersede intent`);
227
+ });
228
+ });
229
+
230
+ describe("Stop and supersede on the same Turn", () => {
231
+ test("Stop still records its intent on a superseded Turn", async () => {
232
+ const { storage, contribution } = await fixture(
233
+ storedRun({
234
+ events: toolIntentEvents(),
235
+ supersededAt: timestamp,
236
+ supersededBy: "run-2",
237
+ }),
238
+ );
239
+
240
+ const receipt = await contribution.stopRun(identity, {
241
+ schemaVersion: 1,
242
+ action: "stop",
243
+ commandId: "stop-1",
244
+ runId: turn.runId,
245
+ });
246
+
247
+ expect(receipt.run.stopRequestedAt).toBeString();
248
+ const stored = storage.values.get(`run:${turn.runId}`) as StoredRun;
249
+ expect(stored.stopRequestedAt).toBeString();
250
+ // Both intents stand. Stop is the outcome the settlement writes, because
251
+ // the User asked for this Turn to stop and a later message does not turn
252
+ // their cancellation into something else.
253
+ expect(stored.supersededAt).toBe(timestamp);
254
+ });
255
+
256
+ test("Stop leaves a Turn already admitted as the next one alone", async () => {
257
+ const { storage, contribution } = await fixture(
258
+ storedRun({ events: toolIntentEvents(), supersededAt: timestamp }),
259
+ );
260
+ const queued = storedRun({
261
+ runId: "run-2",
262
+ acceptedAt: "2026-09-03T00:00:02.000Z",
263
+ input: "second",
264
+ phase: "queued",
265
+ previousEventCount: 0,
266
+ });
267
+ storage.values.set("run:run-2", structuredClone(queued));
268
+ storage.values.set("pending-run", "run-2");
269
+
270
+ await contribution.stopRun(identity, {
271
+ schemaVersion: 1,
272
+ action: "stop",
273
+ commandId: "stop-1",
274
+ runId: turn.runId,
275
+ });
276
+
277
+ expect(storage.values.get("pending-run")).toBe("run-2");
278
+ expect((storage.values.get("run:run-2") as StoredRun).status).toBe(
279
+ "running",
280
+ );
281
+ });
282
+ });
283
+
284
+ describe("background work outlives the Turn that dispatched it", () => {
285
+ test("a subagent of a superseded Turn still settles, and is recorded", async () => {
286
+ const { storage, contribution } = await fixture(
287
+ storedRun({
288
+ events: [
289
+ ...toolIntentEvents(),
290
+ ...events({
291
+ type: "task/dispatched",
292
+ turn: 1,
293
+ step: 1,
294
+ occurrenceId: "tool:1:1:0",
295
+ taskId: "tk-1",
296
+ taskType: "executor",
297
+ description: "Read the release notes",
298
+ model: "foundation/foundation-model",
299
+ background: true,
300
+ }),
301
+ ],
302
+ supersededAt: timestamp,
303
+ supersededBy: "run-2",
304
+ }),
305
+ );
306
+ const tasks = new TaskStore(
307
+ storage as unknown as ConstructorParameters<typeof TaskStore>[0],
308
+ );
309
+ const admitted = await tasks.admit({
310
+ taskId: "tk-1",
311
+ type: "executor",
312
+ description: "Read the release notes",
313
+ promptDigest: "a".repeat(64),
314
+ model: {
315
+ binding: {
316
+ packageId: "provider-foundation",
317
+ capabilityId: "foundation",
318
+ connectionId: "cn-1",
319
+ provider: "foundation",
320
+ providerModelId: "foundation-model",
321
+ },
322
+ slug: "provider-foundation/foundation-model",
323
+ },
324
+ compositionGenerationId: "generation-1",
325
+ background: true,
326
+ attachments: [],
327
+ dispatch: {
328
+ runId: turn.runId,
329
+ turnId: turn.runId,
330
+ sessionId: turn.sessionId,
331
+ },
332
+ now: new Date(timestamp),
333
+ });
334
+ expect(admitted.status).toBe("admitted");
335
+
336
+ // The parent Turn was superseded; nothing asked the child to stop, and its
337
+ // settlement is written exactly as it would have been.
338
+ const settled = await contribution.settleTask(identity, "tk-1", {
339
+ status: "completed",
340
+ settledAt: "2026-09-03T00:00:09.000Z",
341
+ summary: "The notes mention two breaking changes.",
342
+ });
343
+
344
+ expect(settled.status).toBe("settled");
345
+ expect(await tasks.read("tk-1")).toMatchObject({
346
+ taskId: "tk-1",
347
+ status: "completed",
348
+ });
349
+ });
350
+ });
351
+
352
+ describe("the projection tells the three states apart", () => {
353
+ test("queued, running, and superseded each project distinctly", () => {
354
+ const queued = projectClientRunV1(
355
+ storedRun({ runId: "run-2", phase: "queued", input: "second" }),
356
+ );
357
+ expect(queued).toMatchObject({ status: "running", queued: true });
358
+
359
+ const running = projectClientRunV1(storedRun({ phase: "executing" }));
360
+ expect(running.status).toBe("running");
361
+ expect(running.queued).toBeUndefined();
362
+
363
+ const superseded = projectClientRunV1(
364
+ storedRun({
365
+ status: "superseded",
366
+ supersededAt: timestamp,
367
+ supersededBy: "run-2",
368
+ events: [],
369
+ }),
370
+ );
371
+ expect(superseded).toMatchObject({
372
+ status: "superseded",
373
+ outcome: { type: "superseded" },
374
+ });
375
+ expect(superseded.queued).toBeUndefined();
376
+ });
377
+ });
package/src/backend.ts CHANGED
@@ -119,7 +119,7 @@ import {
119
119
  eventsForFailedRun,
120
120
  latestModelRequestJournalState,
121
121
  planBotRunRecovery,
122
- planStoppedRunRecovery,
122
+ planInterruptedRunRecoveryV1,
123
123
  } from "./backend-recovery.js";
124
124
  import {
125
125
  bootstrapCompositionGeneration,
@@ -379,7 +379,10 @@ import {
379
379
  projectPackageIframeCompositionV1,
380
380
  } from "./composition-views.js";
381
381
  import { executeBotTurn, executeDirectToolTurn } from "./backend-runner.js";
382
- import { shellTerminalRecordsV1 } from "./terminal-records.js";
382
+ import {
383
+ shellTerminalRecordsV1,
384
+ supersededTurnRecordsV1,
385
+ } from "./terminal-records.js";
383
386
  import {
384
387
  CLIENT_RUN_LIST_MAX_BYTES,
385
388
  CLIENT_RUN_PAGE_LIMIT,
@@ -472,7 +475,10 @@ interface StoredStopReceipt {
472
475
 
473
476
  function isTerminalStoredRunStatus(status: StoredRunStatus): boolean {
474
477
  return (
475
- status === "completed" || status === "failed" || status === "cancelled"
478
+ status === "completed" ||
479
+ status === "failed" ||
480
+ status === "cancelled" ||
481
+ status === "superseded"
476
482
  );
477
483
  }
478
484
 
@@ -678,7 +684,8 @@ export class ShellBotBackendContribution {
678
684
  subagentRole?: string;
679
685
  mounted: ShellMountedComposition;
680
686
  signal: AbortSignal;
681
- cancel(): void;
687
+ /** `detail` is recorded on the Turn's `turn/end`, never interpreted. */
688
+ cancel(detail?: string): void;
682
689
  }
683
690
  | undefined;
684
691
  /**
@@ -762,6 +769,9 @@ export class ShellBotBackendContribution {
762
769
  notification: (snapshot, result) =>
763
770
  this.createNotification(snapshot, result),
764
771
  terminalRecords: (input) => this.terminalPackageRecords(input),
772
+ supersededRecords: (input) => this.supersededPackageRecords(input),
773
+ interruptTurn: (runId, reason) =>
774
+ this.interruptActiveTurn(runId, reason),
765
775
  scheduledDeadlines: (transaction) =>
766
776
  this.scheduledDeadlines(transaction),
767
777
  scheduledWorkInFlight: () =>
@@ -1639,9 +1649,9 @@ export class ShellBotBackendContribution {
1639
1649
  : {}),
1640
1650
  mounted: activation.mounted,
1641
1651
  signal: controller.signal,
1642
- cancel: () => {
1652
+ cancel: (detail?: string) => {
1643
1653
  controller.abort("user");
1644
- activation.mounted.runtime.agent.agent.cancel("user");
1654
+ activation.mounted.runtime.agent.agent.cancel("user", detail);
1645
1655
  },
1646
1656
  };
1647
1657
  this.activeTurn = active;
@@ -1750,6 +1760,7 @@ export class ShellBotBackendContribution {
1750
1760
  private cancelActiveTurn(cancellation: {
1751
1761
  sessionId: string;
1752
1762
  runId: string;
1763
+ detail?: string;
1753
1764
  }): boolean {
1754
1765
  const active = this.activeTurn;
1755
1766
  if (
@@ -1759,10 +1770,24 @@ export class ShellBotBackendContribution {
1759
1770
  ) {
1760
1771
  return false;
1761
1772
  }
1762
- active.cancel();
1773
+ active.cancel(cancellation.detail);
1763
1774
  return true;
1764
1775
  }
1765
1776
 
1777
+ /**
1778
+ * The kernel's advisory interrupt, bound to this object's resident Agent.
1779
+ *
1780
+ * It runs only after the durable intent that justifies it is written, and it
1781
+ * changes nothing durable itself: a Turn whose Agent is no longer resident
1782
+ * is stopped by the effect fence on its next external effect instead, which
1783
+ * is the same outcome by a slower road.
1784
+ */
1785
+ private interruptActiveTurn(runId: string, reason: string): void {
1786
+ const active = this.activeTurn;
1787
+ if (!active || active.runId !== runId) return;
1788
+ active.cancel(reason);
1789
+ }
1790
+
1766
1791
  /** The visible half of failing closed, through the Bot's notifications. */
1767
1792
  private async recordCompositionFailureNotification(
1768
1793
  settings: BotSettingsViewV1,
@@ -5296,6 +5321,27 @@ export class ShellBotBackendContribution {
5296
5321
  });
5297
5322
  }
5298
5323
 
5324
+ /**
5325
+ * What a superseded Turn leaves for the Turn that replaced it.
5326
+ *
5327
+ * One durable input, drained once by the next conversational Turn. The
5328
+ * session log already carries what the Turn sent and what its tools
5329
+ * returned; this is the part that is not in the log — that it was cut off,
5330
+ * that nothing in flight completed, and that a subagent it dispatched is
5331
+ * still working. "A firing's outcome is delivered to the Bot's next
5332
+ * conversational Turn as durable input" and a superseded Turn's is too.
5333
+ */
5334
+ private supersededPackageRecords(input: {
5335
+ run: StoredRun;
5336
+ read<T>(key: string): Promise<T | undefined>;
5337
+ }): Promise<Record<string, unknown>> {
5338
+ return supersededTurnRecordsV1({
5339
+ run: input.run,
5340
+ now: new Date().toISOString(),
5341
+ read: input.read,
5342
+ });
5343
+ }
5344
+
5299
5345
  async alarm(): Promise<void> {
5300
5346
  // One alarm: the kernel defers while work is in flight, settles Package
5301
5347
  // scheduled work, and recovers the active run. A run left
@@ -5953,7 +5999,12 @@ export class ShellBotBackendContribution {
5953
5999
  `effect admission "${effect.effectId}" does not match durable intent`,
5954
6000
  );
5955
6001
  }
5956
- const outcome = run.stopRequestedAt ? "fenced" : "admitted";
6002
+ // Supersede fences exactly as Stop does. It is what makes an interrupt
6003
+ // durable rather than advisory: a Turn whose Agent never got the signal
6004
+ // — because the object was evicted and resumed — still starts no new
6005
+ // provider call or tool effect once the intent is recorded.
6006
+ const outcome =
6007
+ run.stopRequestedAt || run.supersededAt ? "fenced" : "admitted";
5957
6008
  const next = requireStoredRunV1({
5958
6009
  ...run,
5959
6010
  effectAdmissions: [
@@ -270,16 +270,26 @@ const macDesktop =
270
270
  const botName = computed(
271
271
  () => state.value.botSettings?.profile.name ?? "Barebones",
272
272
  );
273
- const isRunning = computed(() => Boolean(state.value.activeRunId));
273
+ /** A Turn is executing. The composer stays open; only Stop depends on this. */
274
+ const isRunning = computed(() => Boolean(state.value.runningRunId));
274
275
  const isConnecting = computed(() => state.value.connection !== "ready");
276
+ /**
277
+ * Sending while the Bot is working is the point: the message supersedes the
278
+ * running Turn. So the only things that close the composer are the ones that
279
+ * would make any message impossible.
280
+ */
275
281
  const canSend = computed(
276
282
  () =>
277
283
  state.value.connection === "ready" &&
278
284
  state.value.modelReady &&
279
285
  Boolean(state.value.activeBotId) &&
280
- !isRunning.value &&
281
286
  draft.value.trim().length > 0,
282
287
  );
288
+ /**
289
+ * Stop takes the button only while there is nothing to send. The moment the
290
+ * User has typed something, sending it is what they mean by interrupting.
291
+ */
292
+ const showStop = computed(() => isRunning.value && !canSend.value);
283
293
 
284
294
  /*
285
295
  * Tool activity is internal to the Turn. A Turn that produced only tool calls
@@ -295,8 +305,15 @@ function attachmentsOf(message: WebChatMessage): WebToolAttachment[] {
295
305
  }
296
306
 
297
307
  function iframeEntriesFor(tool: WebToolActivity) {
298
- const slot = `frockbot.tool-result:${tool.name}`;
308
+ const separator = tool.name.indexOf("/");
309
+ const namespace = separator < 0 ? undefined : tool.name.slice(0, separator);
310
+ const toolName = separator < 0 ? tool.name : tool.name.slice(separator + 1);
311
+ const slot = `frockbot.tool-result:${toolName}`;
299
312
  return (state.value.packageUi?.contributions ?? [])
313
+ .filter(
314
+ (contribution) =>
315
+ namespace === undefined || contribution.packageId === namespace,
316
+ )
300
317
  .flatMap((contribution) =>
301
318
  contribution.pages.flatMap((page) =>
302
319
  page.mounts
@@ -797,7 +814,10 @@ function handleComposerKeydown(event: KeyboardEvent): void {
797
814
  :id="turnAnchors.get(message.id)"
798
815
  :key="message.id"
799
816
  class="message"
800
- :class="`message-${message.role}`"
817
+ :class="[
818
+ `message-${message.role}`,
819
+ { 'message-pending': message.pending },
820
+ ]"
801
821
  >
802
822
  <p v-if="message.role === 'system'" class="message-system-line">
803
823
  {{ message.text }}
@@ -1025,7 +1045,7 @@ function handleComposerKeydown(event: KeyboardEvent): void {
1025
1045
  ? 'Model unavailable'
1026
1046
  : `Message ${botName}`
1027
1047
  "
1028
- :disabled="isConnecting || !state.modelReady || isRunning"
1048
+ :disabled="isConnecting || !state.modelReady"
1029
1049
  rows="1"
1030
1050
  role="combobox"
1031
1051
  :aria-expanded="skillPopoverOpen"
@@ -1038,7 +1058,7 @@ function handleComposerKeydown(event: KeyboardEvent): void {
1038
1058
  />
1039
1059
  </div>
1040
1060
  <UiIconButton
1041
- v-if="isRunning"
1061
+ v-if="showStop"
1042
1062
  class="stop-button"
1043
1063
  icon="stop"
1044
1064
  label="Stop generating"