@frockbot/plugin-shell 0.1.4 → 0.2.0

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/frockbot.json CHANGED
@@ -25,6 +25,10 @@
25
25
  {
26
26
  "slot": "authenticated-root",
27
27
  "order": 10000
28
+ },
29
+ {
30
+ "slot": "frockbot.bot-settings-sections",
31
+ "order": 10000
28
32
  }
29
33
  ],
30
34
  "outlets": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-shell",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -16,6 +16,7 @@
16
16
  "./backend-image": "./src/backend-image.ts",
17
17
  "./backend-isolate": "./src/backend-isolate.ts",
18
18
  "./backend-memory": "./src/backend-memory.ts",
19
+ "./backend-package-catalog": "./src/backend-package-catalog.ts",
19
20
  "./backend-recovery": "./src/backend-recovery.ts",
20
21
  "./backend-runner": "./src/backend-runner.ts",
21
22
  "./backend-skills": "./src/backend-skills.ts",
@@ -45,38 +46,39 @@
45
46
  },
46
47
  "dependencies": {
47
48
  "@cordisjs/plugin-webui": "0.8.2",
48
- "@frockbot/agent-runtime": "0.1.4",
49
- "@frockbot/application-foundation": "0.1.4",
50
- "@frockbot/catalog-core": "0.1.4",
51
- "@frockbot/client-core": "0.1.4",
52
- "@frockbot/client-ui": "0.1.4",
53
- "@frockbot/computer-core": "0.1.4",
54
- "@frockbot/computer-host-protocol": "0.1.4",
55
- "@frockbot/configuration-core": "0.1.4",
56
- "@frockbot/connection-core": "0.1.4",
57
- "@frockbot/kernel-agent-loop": "0.1.4",
58
- "@frockbot/kernel-composition": "0.1.4",
59
- "@frockbot/kernel-contracts": "0.1.4",
60
- "@frockbot/kernel-do": "0.1.4",
61
- "@frockbot/machine-protocol": "0.1.4",
62
- "@frockbot/plugin-authoring": "0.1.4",
63
- "@frockbot/plugin-bot-template": "0.1.4",
64
- "@frockbot/plugin-flock": "0.1.4",
65
- "@frockbot/plugin-image": "0.1.4",
66
- "@frockbot/plugin-machine-messages": "0.1.4",
67
- "@frockbot/plugin-mcp": "0.1.4",
68
- "@frockbot/plugin-memory": "0.1.4",
69
- "@frockbot/plugin-routines": "0.1.4",
70
- "@frockbot/plugin-skills": "0.1.4",
71
- "@frockbot/plugin-subagents": "0.1.4",
72
- "@frockbot/plugin-user-machine": "0.1.4",
73
- "@frockbot/protocol": "0.1.4",
49
+ "@frockbot/agent-runtime": "0.2.0",
50
+ "@frockbot/application-foundation": "0.2.0",
51
+ "@frockbot/catalog-core": "0.2.0",
52
+ "@frockbot/client-core": "0.2.0",
53
+ "@frockbot/client-ui": "0.2.0",
54
+ "@frockbot/computer-core": "0.2.0",
55
+ "@frockbot/computer-host-protocol": "0.2.0",
56
+ "@frockbot/configuration-core": "0.2.0",
57
+ "@frockbot/connection-core": "0.2.0",
58
+ "@frockbot/kernel-agent-loop": "0.2.0",
59
+ "@frockbot/kernel-composition": "0.2.0",
60
+ "@frockbot/kernel-contracts": "0.2.0",
61
+ "@frockbot/kernel-do": "0.2.0",
62
+ "@frockbot/machine-protocol": "0.2.0",
63
+ "@frockbot/plugin-authoring": "0.2.0",
64
+ "@frockbot/plugin-package-catalog": "0.2.0",
65
+ "@frockbot/plugin-bot-template": "0.2.0",
66
+ "@frockbot/plugin-flock": "0.2.0",
67
+ "@frockbot/plugin-image": "0.2.0",
68
+ "@frockbot/plugin-machine-messages": "0.2.0",
69
+ "@frockbot/plugin-mcp": "0.2.0",
70
+ "@frockbot/plugin-memory": "0.2.0",
71
+ "@frockbot/plugin-routines": "0.2.0",
72
+ "@frockbot/plugin-skills": "0.2.0",
73
+ "@frockbot/plugin-subagents": "0.2.0",
74
+ "@frockbot/plugin-user-machine": "0.2.0",
75
+ "@frockbot/protocol": "0.2.0",
74
76
  "cordis": "4.0.0-rc.8",
75
77
  "vue": "3.5.41"
76
78
  },
77
79
  "devDependencies": {
78
80
  "@cloudflare/workers-types": "latest",
79
- "@frockbot/plugin-tools": "0.1.4",
81
+ "@frockbot/plugin-tools": "0.2.0",
80
82
  "@types/bun": "1.3.6",
81
83
  "@types/node": "26.2.0",
82
84
  "@vitejs/plugin-vue": "6.0.8",
package/src/agent.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  // `widget` payload ends the Turn; row 53's `approval` payload is the only
10
10
  // other one that does, and for the same reason — the Bot has nothing left
11
11
  // to do until a person answers.
12
- // 3. One `agent/request` handler, which is where the transcript seam is: a
12
+ // 3. One `agent/message-window` handler, which is where the transcript seam is: a
13
13
  // chat Turn's request carries only chat Turns, and an automation Turn's
14
14
  // carries its own Turn and a pointer to the parent it may not read. See
15
15
  // `history.ts`.
@@ -120,8 +120,6 @@ function sendAcknowledgement(payload: SendToUserPayloadV1): string {
120
120
  return "Secret request sent to the user.";
121
121
  case "agent-card":
122
122
  return "Agent card sent to the user.";
123
- case "connect-card":
124
- return "Connect card sent to the user. Only they can complete the authorization; you have recorded the request, not granted it.";
125
123
  case "approval":
126
124
  // Deliberately not "requested permission": nothing has been granted, and
127
125
  // the Turn is over whatever the answer turns out to be.
@@ -137,7 +135,6 @@ const SEND_TO_USER_DESCRIPTION = [
137
135
  '{"type":"widget","widget":{"prompt":"…","helpText":"…","options":["…"],"allowCustom":false,"dismissOnMoveOn":false}}',
138
136
  '{"type":"secret-request","prompt":"…","secretName":"…"}',
139
137
  '{"type":"agent-card","agentId":"…","title":"…","body":"…"}',
140
- '{"type":"connect-card","connectionId":"…","title":"…","body":"…"}',
141
138
  '{"type":"approval","approvalId":"…","action":"…","rationale":"…","risk":"low|medium|high","expiresInSeconds":86400}',
142
139
  "A widget asks the user a question with 1 to 6 options and ends your Turn;",
143
140
  "their answer arrives as a new Turn. An approval asks the user to allow one",
@@ -313,18 +310,18 @@ export const shellAgentPlugin: Plugin.Function = (ctx) => {
313
310
  // Applied after the rest of the chain, so this Package has the last word on
314
311
  // what history a request carries — the one rule the visible transcript
315
312
  // rests on.
316
- ctx.on("agent/request", async (agent, _request, _signal, next) => {
317
- const proposed = await next();
318
- return {
319
- ...proposed,
320
- messages: turnScopedMessagesV1({
313
+ ctx.on(
314
+ "agent/message-window",
315
+ async (agent, _messages, _turn, _step, _signal, next) => {
316
+ const proposed = await next();
317
+ return turnScopedMessagesV1({
321
318
  events: agent.session.events,
322
- messages: proposed.messages,
319
+ messages: proposed,
323
320
  pointer: automationParentPointerV1,
324
321
  sessionId: agent.session.id,
325
- }),
326
- };
327
- }),
322
+ });
323
+ },
324
+ ),
328
325
  ];
329
326
  return () => {
330
327
  for (const dispose of disposers.toReversed()) dispose();
@@ -7,17 +7,23 @@ import {
7
7
  type CompositionGenerationV1,
8
8
  type CompositionMemberV1,
9
9
  } from "@frockbot/kernel-composition/generation";
10
+ import { decodeFrockBotManifest } from "@frockbot/kernel-composition";
10
11
  import type { PackageBundleResultV1 } from "@frockbot/kernel-contracts";
11
12
  import {
12
13
  artifactKey,
13
14
  authorshipArtifactKey,
14
15
  authorshipFailureKey,
15
16
  authorshipIntentKey,
17
+ authorshipManifestKey,
18
+ authorshipUndoIntentKey,
19
+ authorshipUndoOutcomeKey,
16
20
  AUTHORSHIP_FAILURE_PREFIX,
17
21
  type AuthoredArtifactRecordV1,
22
+ type AuthoredManifestRecordV1,
18
23
  type AuthoringFailureRecordV1,
19
24
  type AuthorPackageRequestV1,
20
25
  type AuthorshipIntentV1,
26
+ sha256HexV1,
21
27
  } from "@frockbot/plugin-authoring";
22
28
  import {
23
29
  createPackageAuthoringHost,
@@ -34,7 +40,7 @@ function requestFor(overrides: Partial<AuthorPackageRequestV1> = {}) {
34
40
  input: {
35
41
  packageId: "hello-world",
36
42
  displayName: "Hello world",
37
- tool: { name: "hello", description: "Says hi", inputSchema: {} },
43
+ tools: [{ name: "hello", description: "Says hi", inputSchema: {} }],
38
44
  source: SOURCE,
39
45
  },
40
46
  sourceHash: "a".repeat(64),
@@ -56,6 +62,12 @@ function memoryStorage() {
56
62
  }
57
63
  return Promise.resolve();
58
64
  },
65
+ list: <T>({ prefix }: { prefix: string }) =>
66
+ Promise.resolve(
67
+ new Map(
68
+ [...values.entries()].filter(([key]) => key.startsWith(prefix)),
69
+ ) as Map<string, T>,
70
+ ),
59
71
  };
60
72
  return storage;
61
73
  }
@@ -79,13 +91,28 @@ async function memoryComposition() {
79
91
  [bootstrap.generationId, bootstrap],
80
92
  ]);
81
93
  let current = bootstrap.generationId;
94
+ let lastKnownGood = bootstrap.generationId;
82
95
  const store: AuthoringCompositionStore & {
83
96
  generations: Map<string, CompositionGenerationV1>;
84
97
  currentId(): string;
98
+ activate(generationId: string): void;
99
+ fail(generationId: string): void;
85
100
  } = {
86
101
  generations,
87
102
  currentId: () => current,
88
103
  current: () => Promise.resolve(generations.get(current)!),
104
+ lastKnownGood: () => Promise.resolve(generations.get(lastKnownGood)!),
105
+ activate: (generationId) => {
106
+ const generation = generations.get(generationId)!;
107
+ generations.set(generationId, { ...generation, status: "active" });
108
+ current = generationId;
109
+ lastKnownGood = generationId;
110
+ },
111
+ fail: (generationId) => {
112
+ const generation = generations.get(generationId)!;
113
+ generations.set(generationId, { ...generation, status: "failed" });
114
+ current = generationId;
115
+ },
89
116
  read: (generationId) => Promise.resolve(generations.get(generationId)),
90
117
  propose: (generation, options) => {
91
118
  if (generations.has(generation.generationId)) {
@@ -96,6 +123,33 @@ async function memoryComposition() {
96
123
  return Promise.resolve();
97
124
  },
98
125
  retainedCount: () => Promise.resolve(generations.size),
126
+ list: ({ limit }) =>
127
+ Promise.resolve({
128
+ generations: [...generations.values()].reverse().slice(0, limit),
129
+ }),
130
+ revert: async (toGenerationId, origin, options) => {
131
+ const target = generations.get(toGenerationId);
132
+ if (!target) throw new Error("unknown target");
133
+ const createdAt = options?.createdAt ?? "2026-08-31T02:00:00.000Z";
134
+ const generation = decodeCompositionGenerationV1({
135
+ schemaVersion: 1,
136
+ generationId: compositionGenerationIdV1(
137
+ createdAt,
138
+ target.artifactSetHash,
139
+ ),
140
+ artifactSetHash: target.artifactSetHash,
141
+ parentGenerationId: current,
142
+ createdAt,
143
+ origin,
144
+ members: target.members,
145
+ status: "pending",
146
+ });
147
+ const existing = generations.get(generation.generationId);
148
+ if (existing) return existing;
149
+ generations.set(generation.generationId, generation);
150
+ current = generation.generationId;
151
+ return generation;
152
+ },
99
153
  };
100
154
  return store;
101
155
  }
@@ -160,12 +214,18 @@ describe("a Bot authoring a Package", () => {
160
214
  let storage: ReturnType<typeof memoryStorage>;
161
215
  let composition: Awaited<ReturnType<typeof memoryComposition>>;
162
216
  let written: string[];
217
+ let writtenSources: string[];
218
+ let writtenUi: string[];
219
+ let sourceContents: Map<string, string>;
163
220
  let reservations: unknown[];
164
221
 
165
222
  beforeEach(async () => {
166
223
  storage = memoryStorage();
167
224
  composition = await memoryComposition();
168
225
  written = [];
226
+ writtenSources = [];
227
+ writtenUi = [];
228
+ sourceContents = new Map();
169
229
  reservations = [];
170
230
  });
171
231
 
@@ -173,6 +233,7 @@ describe("a Bot authoring a Package", () => {
173
233
  bundler: ReturnType<typeof countingBundler>;
174
234
  quota?: (request: unknown) => unknown;
175
235
  runId?: string;
236
+ currentToolNames?: readonly string[];
176
237
  }) {
177
238
  let ids = 0;
178
239
  return createPackageAuthoringHost({
@@ -184,6 +245,17 @@ describe("a Bot authoring a Package", () => {
184
245
  written.push(contentHash);
185
246
  return Promise.resolve();
186
247
  },
248
+ putPackageSource: (sourceHash, source) => {
249
+ writtenSources.push(sourceHash);
250
+ sourceContents.set(sourceHash, source);
251
+ return Promise.resolve();
252
+ },
253
+ putPackageUiArtifact: (contentHash) => {
254
+ writtenUi.push(contentHash);
255
+ return Promise.resolve();
256
+ },
257
+ loadPackageSource: (sourceHash) =>
258
+ Promise.resolve(sourceContents.get(sourceHash)),
187
259
  headPackageArtifact: () => Promise.resolve(undefined),
188
260
  },
189
261
  quota: {
@@ -206,6 +278,7 @@ describe("a Bot authoring a Package", () => {
206
278
  runId: options.runId ?? "run-1",
207
279
  turnId: options.runId ?? "run-1",
208
280
  compatibilityDate: "2026-08-27",
281
+ currentToolNames: () => options.currentToolNames ?? [],
209
282
  now: () => new Date("2026-08-31T01:00:00.000Z"),
210
283
  newId: () => `id-${(ids += 1)}`,
211
284
  });
@@ -242,7 +315,93 @@ describe("a Bot authoring a Package", () => {
242
315
  authoredAt: "2026-08-31T01:00:00.000Z",
243
316
  });
244
317
  expect(artifact.r2Key).toBe(`packages/${"b".repeat(64)}.mjs`);
318
+ expect(artifact.sourceR2Key).toBe(`packages/${"a".repeat(64)}.ts`);
319
+ expect(artifact.sourceHash).toBe("a".repeat(64));
245
320
  expect(written).toEqual(["b".repeat(64)]);
321
+ expect(writtenSources).toEqual(["a".repeat(64)]);
322
+ const manifestRecord = storage.values.get(
323
+ authorshipManifestKey(artifact.manifestHash),
324
+ ) as AuthoredManifestRecordV1;
325
+ expect(decodeFrockBotManifest(manifestRecord.manifest)).toMatchObject({
326
+ id: "hello-world",
327
+ version: "0.0.1",
328
+ contributions: { runtime: { host: "bot-isolate" } },
329
+ tools: [{ name: "hello" }],
330
+ });
331
+ });
332
+
333
+ test("stores declared loop hooks in the authored manifest", async () => {
334
+ const bundler = countingBundler((effectId) => bundledResult(effectId));
335
+ const outcome = await host({ bundler }).author(
336
+ requestFor({
337
+ input: {
338
+ ...requestFor().input,
339
+ hooks: ["agent/tool-exposure", "tools/post-execute"],
340
+ },
341
+ }),
342
+ );
343
+ if (outcome.status !== "authored") throw new Error(outcome.reason);
344
+
345
+ const artifact = storage.values.get(
346
+ artifactKey(outcome.contentHash),
347
+ ) as AuthoredArtifactRecordV1;
348
+ const manifestRecord = storage.values.get(
349
+ authorshipManifestKey(artifact.manifestHash),
350
+ ) as AuthoredManifestRecordV1;
351
+
352
+ expect(manifestRecord.manifest).toMatchObject({
353
+ hooks: ["agent/tool-exposure", "tools/post-execute"],
354
+ });
355
+ });
356
+
357
+ test("stores the second immutable artifact and records it in the same manifest", async () => {
358
+ const html = "<!doctype html><h1>Hello</h1>";
359
+ const contentHash = await sha256HexV1(html);
360
+ const bundler = countingBundler((effectId) => ({
361
+ ...bundledResult(effectId),
362
+ uiArtifact: {
363
+ contentHash,
364
+ size: new TextEncoder().encode(html).byteLength,
365
+ mediaType: "text/html",
366
+ bundlerVersion: "frockbot-inline-html@1",
367
+ },
368
+ uiHtml: html,
369
+ }));
370
+ const request = requestFor();
371
+ const outcome = await host({ bundler }).author({
372
+ ...request,
373
+ input: {
374
+ ...request.input,
375
+ hooks: ["agent/tool-exposure", "tools/post-execute"],
376
+ ui: {
377
+ html,
378
+ mounts: [{ slot: "frockbot.tool-result:hello" }],
379
+ },
380
+ },
381
+ });
382
+ expect(outcome.status).toBe("authored");
383
+ expect(writtenUi).toEqual([contentHash]);
384
+ const artifact = storage.values.get(
385
+ artifactKey("b".repeat(64)),
386
+ ) as AuthoredArtifactRecordV1;
387
+ const stored = storage.values.get(
388
+ authorshipManifestKey(artifact.manifestHash),
389
+ ) as AuthoredManifestRecordV1;
390
+ const manifest = decodeFrockBotManifest(stored.manifest);
391
+ expect(manifest.contributions.client).toEqual({
392
+ kind: "iframe",
393
+ artifact: {
394
+ contentHash,
395
+ size: new TextEncoder().encode(html).byteLength,
396
+ mediaType: "text/html",
397
+ bundlerVersion: "frockbot-inline-html@1",
398
+ },
399
+ mounts: [{ slot: "frockbot.tool-result:hello" }],
400
+ });
401
+ expect(manifest.hooks).toEqual([
402
+ "agent/tool-exposure",
403
+ "tools/post-execute",
404
+ ]);
246
405
  });
247
406
 
248
407
  test("proposes a pending generation pinned for the next Turn", async () => {
@@ -287,7 +446,13 @@ describe("a Bot authoring a Package", () => {
287
446
  ),
288
447
  );
289
448
  const first = await host({ bundler }).author(requestFor());
290
- const second = await host({ bundler, runId: "run-2" }).author(
449
+ if (first.status !== "authored") throw new Error(first.reason);
450
+ composition.activate(first.generationId);
451
+ const second = await host({
452
+ bundler,
453
+ runId: "run-2",
454
+ currentToolNames: ["hello"],
455
+ }).author(
291
456
  requestFor({ effectId: "author-second", sourceHash: "d".repeat(64) }),
292
457
  );
293
458
  if (first.status !== "authored" || second.status !== "authored") {
@@ -318,6 +483,105 @@ describe("a Bot authoring a Package", () => {
318
483
  ).toBe(first.generationId);
319
484
  });
320
485
 
486
+ test("a failed pending generation is never inherited by later authoring", async () => {
487
+ const bundler = countingBundler((effectId) =>
488
+ bundledResult(
489
+ effectId,
490
+ effectId === "author-first" ? "b".repeat(64) : "c".repeat(64),
491
+ ),
492
+ );
493
+ const first = await host({ bundler, runId: "run-1" }).author(
494
+ requestFor({ effectId: "author-first" }),
495
+ );
496
+ if (first.status !== "authored") throw new Error(first.reason);
497
+ composition.fail(first.generationId);
498
+
499
+ const second = await host({ bundler, runId: "run-2" }).author(
500
+ requestFor({
501
+ effectId: "author-second",
502
+ sourceHash: "d".repeat(64),
503
+ input: {
504
+ ...requestFor().input,
505
+ packageId: "second-package",
506
+ tools: [
507
+ { name: "second_tool", description: "Second", inputSchema: {} },
508
+ ],
509
+ },
510
+ }),
511
+ );
512
+ if (second.status !== "authored") throw new Error(second.reason);
513
+ const generation = composition.generations.get(second.generationId)!;
514
+ expect(generation.parentGenerationId).toBe(
515
+ (await composition.lastKnownGood()).generationId,
516
+ );
517
+ expect(generation.members.map((member) => member.packageId)).toEqual([
518
+ "second-package",
519
+ "shell",
520
+ ]);
521
+ });
522
+
523
+ test("re-authoring the same package repairs a failed proposal from last-known-good", async () => {
524
+ const bundler = countingBundler((effectId) =>
525
+ bundledResult(
526
+ effectId,
527
+ effectId === "author-broken" ? "b".repeat(64) : "c".repeat(64),
528
+ ),
529
+ );
530
+ const broken = await host({ bundler, runId: "run-broken" }).author(
531
+ requestFor({ effectId: "author-broken" }),
532
+ );
533
+ if (broken.status !== "authored") throw new Error(broken.reason);
534
+ composition.fail(broken.generationId);
535
+
536
+ const repaired = await host({ bundler, runId: "run-repair" }).author(
537
+ requestFor({
538
+ effectId: "author-repair",
539
+ sourceHash: "d".repeat(64),
540
+ input: { ...requestFor().input, source: `${SOURCE}// repaired\n` },
541
+ }),
542
+ );
543
+
544
+ if (repaired.status !== "authored") throw new Error(repaired.reason);
545
+ const generation = composition.generations.get(repaired.generationId)!;
546
+ expect(generation.parentGenerationId).toBe(
547
+ (await composition.lastKnownGood()).generationId,
548
+ );
549
+ expect(
550
+ generation.members.find((member) => member.packageId === "hello-world")
551
+ ?.version,
552
+ ).toBe("0.0.2");
553
+ expect(generation.members).toHaveLength(2);
554
+ });
555
+
556
+ test("a registered tool-name collision is refused before quota or bundling", async () => {
557
+ const bundler = countingBundler((effectId) => bundledResult(effectId));
558
+ const outcome = await host({
559
+ bundler,
560
+ currentToolNames: ["memory_write", "package_author"],
561
+ }).author(
562
+ requestFor({
563
+ input: {
564
+ ...requestFor().input,
565
+ tools: [
566
+ {
567
+ name: "memory_write",
568
+ description: "Would collide",
569
+ inputSchema: {},
570
+ },
571
+ ],
572
+ },
573
+ }),
574
+ );
575
+
576
+ expect(outcome.status).toBe("refused");
577
+ if (outcome.status !== "refused") throw new Error("unreachable");
578
+ expect(outcome.reason).toContain(
579
+ 'Tool "memory_write" is already registered',
580
+ );
581
+ expect(reservations).toEqual([]);
582
+ expect(bundler.calls).toBe(0);
583
+ });
584
+
321
585
  test("shadowing a first-party Package is refused before any durable effect", async () => {
322
586
  const bundler = countingBundler((effectId) => bundledResult(effectId));
323
587
  const outcome = await host({ bundler }).author(
@@ -440,6 +704,7 @@ describe("a Bot authoring a Package", () => {
440
704
  packageId: "hello-world",
441
705
  version: "0.0.1",
442
706
  sourceHash: "a".repeat(64),
707
+ manifestHash: "f".repeat(64),
443
708
  sourceBytes: SOURCE.length,
444
709
  recordedAt: "2026-08-31T00:30:00.000Z",
445
710
  status: "recorded",
@@ -471,6 +736,7 @@ describe("a Bot authoring a Package", () => {
471
736
  packageId: "hello-world",
472
737
  version: "0.0.1",
473
738
  sourceHash: "a".repeat(64),
739
+ manifestHash: "f".repeat(64),
474
740
  sourceBytes: SOURCE.length,
475
741
  recordedAt: "2026-08-31T00:30:00.000Z",
476
742
  status: "recorded",
@@ -515,4 +781,92 @@ describe("a Bot authoring a Package", () => {
515
781
  expect(outcome.status).toBe("refused");
516
782
  expect(composition.generations.size).toBe(1);
517
783
  });
784
+
785
+ test("package undo records intent before an idempotent Bot-origin revert without moving last-known-good", async () => {
786
+ const bundler = countingBundler((effectId) => bundledResult(effectId));
787
+ const authored = await host({ bundler }).author(requestFor());
788
+ if (authored.status !== "authored") throw new Error("authoring failed");
789
+ composition.activate(authored.generationId);
790
+ const goodBeforeUndo = await composition.lastKnownGood();
791
+ const undoHost = host({ bundler, runId: "run-undo" });
792
+ const effectId = await undoHost.undoEffectIdFor({});
793
+ const request = {
794
+ input: {},
795
+ effectId,
796
+ sessionId: "user-1:bot-1",
797
+ position: { turn: 2, step: 1 },
798
+ } as const;
799
+
800
+ const first = await undoHost.undo(request);
801
+ const replay = await undoHost.undo(request);
802
+
803
+ expect(first).toEqual(replay);
804
+ expect(first.status).toBe("recorded");
805
+ if (first.status !== "recorded") throw new Error("undo was refused");
806
+ const reverted = composition.generations.get(first.generationId)!;
807
+ expect(reverted.origin).toEqual({
808
+ kind: "revert",
809
+ revertsTo: first.targetGenerationId,
810
+ botId: "bot-1",
811
+ runId: "run-undo",
812
+ turnId: "run-undo",
813
+ });
814
+ expect(reverted.members.map((member) => member.packageId)).toEqual([
815
+ "shell",
816
+ ]);
817
+ expect((await composition.lastKnownGood()).generationId).toBe(
818
+ goodBeforeUndo.generationId,
819
+ );
820
+ expect(storage.values.has(authorshipUndoIntentKey(effectId))).toBe(true);
821
+ expect(storage.values.has(authorshipUndoOutcomeKey(effectId))).toBe(true);
822
+ });
823
+
824
+ test("package_inspect_self returns generated contract, declared tools, stored source, and latest failure", async () => {
825
+ const bundler = countingBundler((effectId) => bundledResult(effectId));
826
+ const authored = await host({ bundler }).author(requestFor());
827
+ if (authored.status !== "authored") throw new Error("authoring failed");
828
+ composition.activate(authored.generationId);
829
+ const inspecting = host({
830
+ bundler,
831
+ runId: "run-inspect",
832
+ currentToolNames: ["hello", "memory_write"],
833
+ });
834
+ await inspecting.author(
835
+ requestFor({
836
+ effectId: "author-collision",
837
+ sourceHash: "c".repeat(64),
838
+ input: {
839
+ ...requestFor().input,
840
+ tools: [
841
+ {
842
+ name: "memory_write",
843
+ description: "collides",
844
+ inputSchema: {},
845
+ },
846
+ ],
847
+ },
848
+ }),
849
+ );
850
+
851
+ const view = await inspecting.inspectSelf();
852
+
853
+ expect(view.contextContract).toContain(
854
+ "interface BotPackageExecutionContextV1",
855
+ );
856
+ expect(view.contextContract).toContain("interface FrockBotIframeBridgeV1");
857
+ expect(view.contextContract).toContain("window.frockbot=");
858
+ expect(view.composition.members).toContainEqual(
859
+ expect.objectContaining({
860
+ packageId: "hello-world",
861
+ declaredTools: ["hello"],
862
+ source: SOURCE,
863
+ }),
864
+ );
865
+ expect(view.failures).toContainEqual(
866
+ expect.objectContaining({
867
+ packageId: "hello-world",
868
+ authoring: expect.objectContaining({ phase: "compose" }),
869
+ }),
870
+ );
871
+ });
518
872
  });