@frockbot/plugin-shell 0.1.4 → 0.2.1

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,65 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ compositionFailureDurableInputV1,
4
+ compositionFailureTurnTextV1,
5
+ } from "./backend-composition-input.ts";
6
+
7
+ describe("Composition failure durable Bot input", () => {
8
+ test("includes the authored Package, phase, diagnostics, and quarantine", () => {
9
+ const text = compositionFailureDurableInputV1({
10
+ attemptedGenerationId: "generation-broken",
11
+ generation: {
12
+ schemaVersion: 1,
13
+ generationId: "generation-broken",
14
+ artifactSetHash: "a".repeat(64),
15
+ createdAt: "2026-09-02T00:00:00.000Z",
16
+ origin: {
17
+ kind: "bot-authored",
18
+ runId: "run-1",
19
+ sessionId: "session-1",
20
+ turnId: "turn-1",
21
+ },
22
+ members: [
23
+ {
24
+ packageId: "weather-lookup",
25
+ specifier: "bot-authored:weather-lookup",
26
+ version: "0.0.1",
27
+ manifestHash: "b".repeat(64),
28
+ provenance: {
29
+ kind: "bot",
30
+ packageId: "weather-lookup",
31
+ version: "0.0.1",
32
+ botId: "bot-1",
33
+ sessionId: "session-1",
34
+ turnId: "turn-1",
35
+ runId: "run-1",
36
+ authoredAt: "2026-09-02T00:00:00.000Z",
37
+ },
38
+ },
39
+ ],
40
+ status: "quarantined",
41
+ },
42
+ failure: {
43
+ generationId: "generation-broken",
44
+ attempt: 3,
45
+ at: "2026-09-02T00:01:00.000Z",
46
+ phase: "health",
47
+ message: "declared tools do not match",
48
+ diagnostics: ["declared=weather_lookup", "reported=other"],
49
+ },
50
+ quarantined: true,
51
+ });
52
+
53
+ expect(text).toContain("Generation: generation-broken");
54
+ expect(text).toContain("Package: weather-lookup");
55
+ expect(text).toContain("Phase: health");
56
+ expect(text).toContain("- reported=other");
57
+ expect(text).toContain("Status: quarantined");
58
+ expect(
59
+ compositionFailureTurnTextV1("please continue", {
60
+ attemptedGenerationId: "generation-broken",
61
+ quarantined: false,
62
+ }),
63
+ ).toEndWith("\n\nplease continue");
64
+ });
65
+ });
@@ -0,0 +1,58 @@
1
+ import type { CompositionFailureV1 } from "@frockbot/kernel-composition/activation";
2
+ import type { CompositionGenerationV1 } from "@frockbot/kernel-composition/generation";
3
+
4
+ /**
5
+ * Model-visible durable input for the Turn that fell back from a broken
6
+ * Composition. The caller places this exact text in `user/message`, from
7
+ * which the exact recorded `model/request` is reconstructed.
8
+ */
9
+ export function compositionFailureDurableInputV1(input: {
10
+ attemptedGenerationId: string;
11
+ generation?: CompositionGenerationV1;
12
+ failure?: CompositionFailureV1;
13
+ quarantined: boolean;
14
+ }): string {
15
+ const origin = input.generation?.origin;
16
+ const authoredPackageId =
17
+ origin?.kind === "bot-authored"
18
+ ? input.generation?.members.find(
19
+ (member) =>
20
+ member.provenance.kind === "bot" &&
21
+ member.provenance.runId === origin.runId,
22
+ )?.packageId
23
+ : undefined;
24
+ const failure = input.failure;
25
+ const packageId =
26
+ authoredPackageId ??
27
+ failure?.message.match(/package "([^"]+)"/i)?.[1] ??
28
+ (input.generation?.members.filter(
29
+ (member) => member.provenance.kind === "bot",
30
+ ).length === 1
31
+ ? input.generation.members.find(
32
+ (member) => member.provenance.kind === "bot",
33
+ )?.packageId
34
+ : undefined);
35
+ const diagnostics = failure?.diagnostics.length
36
+ ? failure.diagnostics.map((entry) => `- ${entry}`).join("\n")
37
+ : "- none";
38
+ return [
39
+ "[Durable Package activation failure]",
40
+ `Generation: ${input.attemptedGenerationId}`,
41
+ `Package: ${packageId ?? "unknown"}`,
42
+ `Phase: ${failure?.phase ?? "resolve"}`,
43
+ `Message: ${failure?.message ?? "This quarantined generation remains unavailable."}`,
44
+ "Diagnostics:",
45
+ diagnostics,
46
+ input.quarantined
47
+ ? "Status: quarantined after repeated activation failures; the last working Package setup was mounted. Author a repair or use package_undo."
48
+ : "Status: activation failed; the last working Package setup was mounted for this Turn. Author a repair or use package_undo.",
49
+ ].join("\n");
50
+ }
51
+
52
+ /** The exact command text handed to the Agent loop on a fallback Turn. */
53
+ export function compositionFailureTurnTextV1(
54
+ ordinaryInput: string,
55
+ failure: Parameters<typeof compositionFailureDurableInputV1>[0],
56
+ ): string {
57
+ return `${compositionFailureDurableInputV1(failure)}\n\n${ordinaryInput}`;
58
+ }
@@ -65,6 +65,8 @@ export interface ShellIsolateMountOptions {
65
65
  turnId: string;
66
66
  loader: BotIsolateLoader;
67
67
  artifacts: BotIsolateArtifactStore;
68
+ /** Reads the exact manifest whose hash the member records. */
69
+ manifestFor(member: CompositionMemberV1): Promise<unknown>;
68
70
  /**
69
71
  * Mints the loopback `CAPABILITIES` service binding for one Package —
70
72
  * `ctx.exports.BotCapabilities({ props })` in the Durable Object.
@@ -177,12 +179,27 @@ export function createShellCompositionHost(
177
179
  loader: isolate.loader,
178
180
  artifacts: isolate.artifacts,
179
181
  tools: runtime.root.tools,
182
+ loop: runtime.root,
180
183
  userId: isolate.userId,
181
184
  botId: options.botId,
182
185
  sessionId: options.sessionId,
183
186
  runId: isolate.runId,
184
187
  turnId: isolate.turnId,
185
188
  generationId: generation.generationId,
189
+ turnType: options.turnType ?? "chat",
190
+ ...(options.subagentRole === undefined
191
+ ? {}
192
+ : { subagentRole: options.subagentRole }),
193
+ recordHookFailure: async (failure) => {
194
+ const session = runtime.root.sessions.get(options.sessionId);
195
+ if (!session) {
196
+ throw new Error(
197
+ `session "${options.sessionId}" is unavailable for hook failure recording`,
198
+ );
199
+ }
200
+ session.append({ type: "package/hook-failed", ...failure });
201
+ await session.flush();
202
+ },
186
203
  capabilities: isolate.capabilitiesFor(member),
187
204
  compatibilityDate: isolate.compatibilityDate,
188
205
  bindingDigest: isolate.bindingDigest,
@@ -192,8 +209,9 @@ export function createShellCompositionHost(
192
209
  : { deadlineMs: isolate.deadlineMs }),
193
210
  });
194
211
  // Mount and health-check are one guarded phase (Worker Loader spike).
212
+ const storedManifest = await isolate.manifestFor(member);
195
213
  const prepared = await host.prepare(
196
- botIsolatePackageDescriptorV1(member),
214
+ await botIsolatePackageDescriptorV1(member, storedManifest),
197
215
  );
198
216
  if (!prepared) {
199
217
  failures.push({
@@ -7,6 +7,7 @@ import type {
7
7
  } from "@frockbot/configuration-core";
8
8
  import type { PackageSettingDefinition } from "@frockbot/kernel-composition";
9
9
  import { createShellBotBackendContribution } from "./backend.js";
10
+ import { createIsolateCapabilityHost } from "./backend-isolate.js";
10
11
 
11
12
  class MemoryStorage {
12
13
  readonly values = new Map<string, unknown>();
@@ -218,6 +219,58 @@ function request(command: BotConfigurationCommandV1) {
218
219
  }
219
220
 
220
221
  describe("Bot configuration admission", () => {
222
+ test("reads old Bot settings purely and writes the migrated shape on the next command", async () => {
223
+ const storage = new MemoryStorage();
224
+ // Literal durable shape from eb0283edcce5daea976a21a9f6a6414bedc6e2bc,
225
+ // the first parent of PR #134's merge commit.
226
+ const historical = {
227
+ schemaVersion: 1,
228
+ botId: "primary",
229
+ revision: 4,
230
+ profile: { name: "Primary" },
231
+ notifications: { enabled: true },
232
+ assignments: [],
233
+ assignmentOperations: [],
234
+ model: {
235
+ connectionId: "ollama-1",
236
+ providerModelId: "glm-5.3-flash:cloud",
237
+ },
238
+ };
239
+ await storage.put({
240
+ identity: { userId: "user-1", botId: "primary" },
241
+ "bot-configuration": historical,
242
+ });
243
+ const contribution = host(storage, configuredUser);
244
+ const identity = { userId: "user-1", botId: "primary" };
245
+
246
+ await expect(contribution.getSettings(identity)).resolves.toMatchObject({
247
+ revision: 4,
248
+ packageValues: {},
249
+ });
250
+ expect(await storage.get<unknown>("bot-configuration")).toEqual(historical);
251
+
252
+ await contribution.executeConfiguration(
253
+ request({
254
+ schemaVersion: 1,
255
+ type: "bot/update-profile",
256
+ commandId: "migrate-bot-settings",
257
+ botId: "primary",
258
+ expectedRevision: 4,
259
+ profile: { name: "Migrated Bot" },
260
+ }),
261
+ );
262
+ const written =
263
+ await storage.get<Record<string, unknown>>("bot-configuration");
264
+ expect(written).toMatchObject({
265
+ revision: 5,
266
+ profile: { name: "Migrated Bot" },
267
+ packageValues: {},
268
+ });
269
+ expect(written).not.toHaveProperty("assignments");
270
+ expect(written).not.toHaveProperty("assignmentOperations");
271
+ expect(written).not.toHaveProperty("model");
272
+ });
273
+
221
274
  test("rejects an unmaterialized Bot without writing durable state", async () => {
222
275
  const storage = new MemoryStorage();
223
276
  const contribution = host(storage, configuredUser);
@@ -414,6 +467,65 @@ describe("Bot configuration admission", () => {
414
467
  });
415
468
 
416
469
  describe("generic per-Turn model resolution", () => {
470
+ test("projects the platform model into an otherwise unconfigured Bot's isolate list", async () => {
471
+ const storage = new MemoryStorage();
472
+ const contribution = host(storage, configuredUser);
473
+ const identity = { userId: "user-1", botId: "primary" };
474
+ const settings = await contribution.materializeSettings(identity, {
475
+ name: "Primary",
476
+ });
477
+ const snapshot = await (
478
+ contribution as unknown as {
479
+ isolateAuthoritySnapshot(
480
+ identity: { userId: string; botId: string },
481
+ settings: BotSettingsViewV1,
482
+ ): Promise<{
483
+ connections: Array<{
484
+ connectionId: string;
485
+ packageId: string;
486
+ connectionTypeId: string;
487
+ displayName: string;
488
+ generation: string;
489
+ safeMetadata: Record<string, unknown>;
490
+ }>;
491
+ model?: {
492
+ connectionId: string;
493
+ packageId: string;
494
+ provider: string;
495
+ providerModelId: string;
496
+ connectionGeneration: string;
497
+ catalogGeneration?: string;
498
+ };
499
+ memory: boolean;
500
+ workspace: boolean;
501
+ }>;
502
+ }
503
+ ).isolateAuthoritySnapshot(identity, settings);
504
+ const listed = await createIsolateCapabilityHost({
505
+ storage: {
506
+ put: () => Promise.resolve(),
507
+ list: () => Promise.resolve(new Map()),
508
+ },
509
+ botId: identity.botId,
510
+ packageId: "bot-authored",
511
+ generationId: "composition-1",
512
+ connections: snapshot.connections,
513
+ ...(snapshot.model ? { modelBinding: snapshot.model } : {}),
514
+ memory: snapshot.memory,
515
+ workspace: snapshot.workspace,
516
+ }).list();
517
+
518
+ expect(listed.model).toEqual({
519
+ connectionId: "flock-ai-ambient",
520
+ packageId: "provider-flock-ai",
521
+ provider: "flock-ai",
522
+ providerModelId: "@flock/auto",
523
+ connectionGeneration: "foundation-generation-1",
524
+ catalogGeneration: "catalog-1",
525
+ });
526
+ expect(Object.hasOwn(settings, "model")).toBe(false);
527
+ });
528
+
417
529
  test("runs on the platform model without creating a per-Bot model record", async () => {
418
530
  const storage = new MemoryStorage();
419
531
  const contribution = host(storage, configuredUser);
@@ -429,11 +541,65 @@ describe("generic per-Turn model resolution", () => {
429
541
  });
430
542
 
431
543
  expect(result.text).toBe("Cordis runtime: hello");
544
+ const durableEvents = storage.values.get("latest-events") as
545
+ Array<{ seq: number }> | undefined;
546
+ expect(durableEvents?.length).toBeGreaterThan(0);
547
+ expect(durableEvents?.map((event) => event.seq)).toEqual(
548
+ durableEvents?.map((_, index) => index),
549
+ );
432
550
  const settings = await contribution.getSettings(identity);
433
551
  expect(settings).toMatchObject({ revision: 0, packageValues: {} });
434
552
  expect(Object.hasOwn(settings, "model")).toBe(false);
435
553
  });
436
554
 
555
+ test("a Connection disabled after admission is unavailable and records a visible failure", async () => {
556
+ const storage = new MemoryStorage();
557
+ const user = configuredUser();
558
+ const contribution = host(storage, () => user);
559
+ const identity = { userId: "user-1", botId: "primary" };
560
+ await contribution.materializeSettings(identity, { name: "Primary" });
561
+ (
562
+ contribution as unknown as {
563
+ activeTurn: unknown;
564
+ }
565
+ ).activeTurn = {
566
+ runId: "run-1",
567
+ sessionId: "session-1",
568
+ turnId: "turn-1",
569
+ generationId: "composition-1",
570
+ mounted: {
571
+ generation: {
572
+ members: [
573
+ { packageId: "bot-authored", artifact: { contentHash: "hash" } },
574
+ ],
575
+ },
576
+ },
577
+ };
578
+ user.connections[0] = { ...user.connections[0]!, state: "disabled" };
579
+
580
+ await expect(
581
+ contribution.isolateConnection({
582
+ ...identity,
583
+ runId: "run-1",
584
+ sessionId: "session-1",
585
+ turnId: "turn-1",
586
+ packageId: "bot-authored",
587
+ generationId: "composition-1",
588
+ request: "flock-ai-ambient",
589
+ }),
590
+ ).resolves.toEqual({
591
+ status: "unavailable",
592
+ reason: "the Connection is unavailable",
593
+ });
594
+ await expect(contribution.listNotifications()).resolves.toEqual([
595
+ expect.objectContaining({
596
+ notificationId:
597
+ "package-connection-unavailable:run-1:bot-authored:flock-ai-ambient",
598
+ title: "Connection unavailable",
599
+ }),
600
+ ]);
601
+ });
602
+
437
603
  test("uses an enabled Bot-scoped model value and preserves it while disabled", async () => {
438
604
  const storage = new MemoryStorage();
439
605
  let user = configuredUser();
@@ -20,6 +20,34 @@ function storedRun(): StoredRun {
20
20
  }
21
21
 
22
22
  describe("StoredRun durable contract", () => {
23
+ test("migrates a historical configuration snapshot before strict decoding", () => {
24
+ // Literal Bot settings shape from eb0283edcce5daea976a21a9f6a6414bedc6e2bc.
25
+ const decoded = requireStoredRunV1({
26
+ ...storedRun(),
27
+ configurationSnapshot: {
28
+ schemaVersion: 1,
29
+ botId: "primary",
30
+ revision: 4,
31
+ profile: { name: "Primary" },
32
+ notifications: { enabled: true },
33
+ assignments: [],
34
+ assignmentOperations: [],
35
+ model: {
36
+ connectionId: "ollama-1",
37
+ providerModelId: "glm-5.3-flash:cloud",
38
+ },
39
+ },
40
+ });
41
+ expect(decoded.configurationSnapshot).toEqual({
42
+ schemaVersion: 1,
43
+ botId: "primary",
44
+ revision: 4,
45
+ profile: { name: "Primary", description: undefined },
46
+ notifications: { enabled: true },
47
+ packageValues: {},
48
+ });
49
+ });
50
+
23
51
  test("uses the public run identifier grammar", () => {
24
52
  expect(() =>
25
53
  requireStoredRunV1({ ...storedRun(), runId: "run:1" }),
@@ -8,6 +8,7 @@ import {
8
8
  import {
9
9
  decodeBotSettingsViewV1,
10
10
  isPublicIdentifier,
11
+ migrateStoredBotSettingsV1,
11
12
  type BotSettingsViewV1,
12
13
  } from "@frockbot/configuration-core";
13
14
 
@@ -36,7 +37,8 @@ export function decodeRunIdV1(value: unknown): string {
36
37
  export const storedRunCodecV1: StoredRunCodecV1<BotSettingsViewV1> =
37
38
  createStoredRunCodecV1<BotSettingsViewV1>({
38
39
  decodeRunId: decodeRunIdV1,
39
- decodeConfigurationSnapshot: decodeBotSettingsViewV1,
40
+ decodeConfigurationSnapshot: (stored) =>
41
+ decodeBotSettingsViewV1(migrateStoredBotSettingsV1(stored)),
40
42
  });
41
43
 
42
44
  export function requireStoredRunV1(input: unknown): StoredRun {
@@ -0,0 +1,131 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { PackageIframeCompositionV1 } from "@frockbot/kernel-contracts";
3
+ import type { FrockBotManifest } from "@frockbot/kernel-composition";
4
+ import type { CompositionGenerationV1 } from "@frockbot/kernel-composition/generation";
5
+ import { requirePackageUiToolDeclarationV1 } from "./backend.js";
6
+ import { projectPackageIframeCompositionV1 } from "./composition-views.js";
7
+
8
+ describe("Package iframe server admission", () => {
9
+ const catalog: PackageIframeCompositionV1 = {
10
+ schemaVersion: 1,
11
+ botId: "bot",
12
+ generationId: "generation-1",
13
+ contributions: [
14
+ {
15
+ packageId: "weather-page",
16
+ displayName: "Weather page",
17
+ provenance: "Bot-authored",
18
+ artifact: {
19
+ contentHash: "a".repeat(64),
20
+ size: 123,
21
+ mediaType: "text/html",
22
+ bundlerVersion: "frockbot-inline-html@1",
23
+ },
24
+ mounts: [{ slot: "frockbot.tool-result:weather_lookup" }],
25
+ declaredTools: ["weather_lookup"],
26
+ },
27
+ ],
28
+ };
29
+
30
+ test("refuses an undeclared tool before admitting a durable Turn", () => {
31
+ expect(() =>
32
+ requirePackageUiToolDeclarationV1(catalog, {
33
+ generationId: "generation-1",
34
+ packageId: "weather-page",
35
+ name: "package_author",
36
+ }),
37
+ ).toThrow('did not declare tool "package_author"');
38
+ });
39
+
40
+ test("refuses a stale page after its Composition generation changed", () => {
41
+ expect(() =>
42
+ requirePackageUiToolDeclarationV1(catalog, {
43
+ generationId: "generation-old",
44
+ packageId: "weather-page",
45
+ name: "weather_lookup",
46
+ }),
47
+ ).toThrow('generation "generation-old"');
48
+ });
49
+
50
+ test("projects a Catalog member through the same manifest reader", async () => {
51
+ const manifestHash = "c".repeat(64);
52
+ const uiArtifact = {
53
+ contentHash: "d".repeat(64),
54
+ size: 42,
55
+ mediaType: "text/html" as const,
56
+ bundlerVersion: "frockbot-inline-html@1",
57
+ };
58
+ const manifest: FrockBotManifest = {
59
+ schemaVersion: 3,
60
+ id: "weather-page",
61
+ displayName: "Weather page",
62
+ version: "0.0.1",
63
+ compatibility: { frockbot: "*" },
64
+ dependencies: {},
65
+ contributions: {
66
+ runtime: { entry: "./package.js", host: "bot-isolate" },
67
+ client: {
68
+ kind: "iframe",
69
+ artifact: uiArtifact,
70
+ mounts: [{ slot: "frockbot.tool-result:weather_lookup" }],
71
+ },
72
+ },
73
+ tools: [
74
+ { name: "weather_lookup", description: "Looks up", inputSchema: {} },
75
+ ],
76
+ permissions: [],
77
+ };
78
+ const generation: CompositionGenerationV1 = {
79
+ schemaVersion: 1,
80
+ generationId: "generation-1",
81
+ artifactSetHash: "a".repeat(64),
82
+ createdAt: "2026-09-02T00:00:00.000Z",
83
+ origin: { kind: "bootstrap" },
84
+ members: [
85
+ {
86
+ packageId: "weather-page",
87
+ specifier: "catalog:weather-page",
88
+ version: "0.0.1",
89
+ manifestHash,
90
+ provenance: {
91
+ kind: "catalog",
92
+ packageId: "weather-page",
93
+ version: "0.0.1",
94
+ catalogId: "weather-page",
95
+ catalogGeneration: "catalog-1",
96
+ contentHash: "b".repeat(64),
97
+ },
98
+ artifact: {
99
+ contentHash: "b".repeat(64),
100
+ size: 512,
101
+ mediaType: "application/javascript",
102
+ bundlerVersion: "catalog-test@1",
103
+ },
104
+ },
105
+ ],
106
+ status: "active",
107
+ };
108
+ const requested: string[] = [];
109
+
110
+ const projected = await projectPackageIframeCompositionV1({
111
+ botId: "bot",
112
+ generation,
113
+ readMemberManifest: (member) => {
114
+ requested.push(member.manifestHash);
115
+ return Promise.resolve(manifest);
116
+ },
117
+ });
118
+
119
+ expect(requested).toEqual([manifestHash]);
120
+ expect(projected.contributions).toEqual([
121
+ {
122
+ packageId: "weather-page",
123
+ displayName: "Weather page",
124
+ provenance: "User-installed",
125
+ artifact: uiArtifact,
126
+ mounts: [{ slot: "frockbot.tool-result:weather_lookup" }],
127
+ declaredTools: ["weather_lookup"],
128
+ },
129
+ ]);
130
+ });
131
+ });