@frockbot/plugin-shell 0.3.1 → 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.
Files changed (44) hide show
  1. package/package.json +32 -29
  2. package/src/agent.test.ts +15 -3
  3. package/src/backend-applets.test.ts +581 -0
  4. package/src/backend-applets.ts +959 -0
  5. package/src/backend-authoring.test.ts +61 -19
  6. package/src/backend-authoring.ts +66 -27
  7. package/src/backend-completion.ts +4 -2
  8. package/src/backend-composition.ts +64 -0
  9. package/src/backend-computer.test.ts +128 -0
  10. package/src/backend-computer.ts +81 -0
  11. package/src/backend-configuration.test.ts +8 -1
  12. package/src/backend-iframe-ui.test.ts +29 -12
  13. package/src/backend-isolate.ts +31 -5
  14. package/src/backend-package-catalog.test.ts +13 -8
  15. package/src/backend-package-catalog.ts +8 -6
  16. package/src/backend-recovery-integration.test.ts +23 -0
  17. package/src/backend-recovery.ts +20 -12
  18. package/src/backend-runner-iframe.test.ts +10 -1
  19. package/src/backend-runner.ts +9 -2
  20. package/src/backend-stop.test.ts +6 -6
  21. package/src/backend-supersede.test.ts +377 -0
  22. package/src/backend.ts +567 -13
  23. package/src/client/AppletCanvas.vue +679 -0
  24. package/src/client/FrockBotApp.vue +195 -21
  25. package/src/client/PackageEntryTrigger.vue +77 -0
  26. package/src/client/PackageIframeHost.vue +148 -47
  27. package/src/client/PackageIframeSettings.vue +8 -6
  28. package/src/client/PackageSurfacePage.vue +39 -0
  29. package/src/client/applets-client.test.ts +204 -0
  30. package/src/client/applets-client.ts +139 -0
  31. package/src/client/applets-state.ts +64 -0
  32. package/src/client/index.test.ts +221 -7
  33. package/src/client/index.ts +398 -6
  34. package/src/client/package-iframe-entries.test.ts +122 -0
  35. package/src/client/package-iframe-entries.ts +112 -0
  36. package/src/client/package-iframe-host-message.test.ts +3 -3
  37. package/src/client/package-iframe-host-message.ts +3 -3
  38. package/src/client/styles.css +118 -1
  39. package/src/composition-views.ts +31 -6
  40. package/src/run-protocol.test.ts +92 -0
  41. package/src/run-protocol.ts +193 -17
  42. package/src/shared.ts +70 -0
  43. package/src/terminal-records.test.ts +52 -1
  44. package/src/terminal-records.ts +48 -0
@@ -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
+ });