@frockbot/plugin-shell 0.0.0 → 0.1.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.
Files changed (73) hide show
  1. package/frockbot.json +68 -0
  2. package/package.json +87 -6
  3. package/src/agent.test.ts +372 -0
  4. package/src/agent.ts +335 -0
  5. package/src/approvals.test.ts +224 -0
  6. package/src/approvals.ts +530 -0
  7. package/src/backend-assignment.test.ts +161 -0
  8. package/src/backend-assignment.ts +274 -0
  9. package/src/backend-authoring.test.ts +518 -0
  10. package/src/backend-authoring.ts +531 -0
  11. package/src/backend-bot-identity.test.ts +215 -0
  12. package/src/backend-completion.test.ts +289 -0
  13. package/src/backend-completion.ts +95 -0
  14. package/src/backend-composition.ts +242 -0
  15. package/src/backend-computer.ts +76 -0
  16. package/src/backend-configuration.test.ts +1757 -0
  17. package/src/backend-contracts.test.ts +189 -0
  18. package/src/backend-contracts.ts +44 -0
  19. package/src/backend-debug.test.ts +202 -0
  20. package/src/backend-execution.ts +55 -0
  21. package/src/backend-flock.ts +96 -0
  22. package/src/backend-image.test.ts +115 -0
  23. package/src/backend-image.ts +180 -0
  24. package/src/backend-isolate.test.ts +238 -0
  25. package/src/backend-isolate.ts +409 -0
  26. package/src/backend-machine.ts +144 -0
  27. package/src/backend-memory.ts +89 -0
  28. package/src/backend-recovery-integration.test.ts +1575 -0
  29. package/src/backend-recovery.ts +106 -0
  30. package/src/backend-routines.ts +375 -0
  31. package/src/backend-runner.ts +251 -0
  32. package/src/backend-skills.test.ts +126 -0
  33. package/src/backend-skills.ts +198 -0
  34. package/src/backend-stop.test.ts +356 -0
  35. package/src/backend-subagents.ts +459 -0
  36. package/src/backend.ts +6035 -0
  37. package/src/client/FrockBotApp.vue +1026 -0
  38. package/src/client/SendPayloadView.vue +337 -0
  39. package/src/client/composer-draft.test.ts +31 -0
  40. package/src/client/composer-draft.ts +35 -0
  41. package/src/client/cordis-client-shim.d.ts +15 -0
  42. package/src/client/index.test.ts +2548 -0
  43. package/src/client/index.ts +2346 -0
  44. package/src/client/model-presentation.test.ts +35 -0
  45. package/src/client/model-presentation.ts +19 -0
  46. package/src/client/notify.test.ts +89 -0
  47. package/src/client/notify.ts +101 -0
  48. package/src/client/skill-invocation.test.ts +143 -0
  49. package/src/client/skill-invocation.ts +175 -0
  50. package/src/client/styles.css +1043 -0
  51. package/src/composition-views.ts +118 -0
  52. package/src/debug-protocol.test.ts +80 -0
  53. package/src/debug-protocol.ts +165 -0
  54. package/src/env.d.ts +10 -0
  55. package/src/history.test.ts +163 -0
  56. package/src/history.ts +108 -0
  57. package/src/host.ts +20 -0
  58. package/src/index.ts +2 -0
  59. package/src/manifest.ts +3 -0
  60. package/src/run-cursor.ts +28 -0
  61. package/src/run-protocol.test.ts +1281 -0
  62. package/src/run-protocol.ts +1417 -0
  63. package/src/settings-links.test.ts +106 -0
  64. package/src/settings-links.ts +289 -0
  65. package/src/shared.ts +338 -0
  66. package/src/skill-protocol.ts +117 -0
  67. package/src/terminal-records.test.ts +217 -0
  68. package/src/terminal-records.ts +150 -0
  69. package/src/unread.test.ts +362 -0
  70. package/src/unread.ts +675 -0
  71. package/tsconfig.json +18 -0
  72. package/vite.config.ts +32 -0
  73. package/README.md +0 -3
@@ -0,0 +1,189 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { initializeBotSettingsV1 } from "@frockbot/configuration-core";
3
+ import { requireStoredRunV1, type StoredRun } from "./backend-contracts.js";
4
+
5
+ function storedRun(): StoredRun {
6
+ return {
7
+ runId: "run-1",
8
+ commandFingerprint: "fingerprint",
9
+ sessionId: "user:primary",
10
+ acceptedAt: "2026-08-29T00:00:00.000Z",
11
+ input: "continue",
12
+ events: [],
13
+ effectAdmissions: [],
14
+ status: "running",
15
+ phase: "admitted",
16
+ compositionGenerationId: "test-composition-generation",
17
+ configurationSnapshot: initializeBotSettingsV1("primary"),
18
+ previousEventCount: 0,
19
+ };
20
+ }
21
+
22
+ describe("StoredRun durable contract", () => {
23
+ test("uses the public run identifier grammar", () => {
24
+ expect(() =>
25
+ requireStoredRunV1({ ...storedRun(), runId: "run:1" }),
26
+ ).toThrow("invalid runId");
27
+ });
28
+
29
+ test("rejects compatibility statuses and invalid status fields", () => {
30
+ expect(() =>
31
+ requireStoredRunV1({ ...storedRun(), status: "interrupted" }),
32
+ ).toThrow("valid status");
33
+ expect(() =>
34
+ requireStoredRunV1({
35
+ ...storedRun(),
36
+ status: "failed",
37
+ phase: "executing",
38
+ failure: "",
39
+ }),
40
+ ).toThrow("invalid failure");
41
+ expect(() =>
42
+ requireStoredRunV1({ ...storedRun(), responseText: "unexpected" }),
43
+ ).toThrow("completion fields");
44
+ });
45
+
46
+ test("strictly bounds exact durable effect admission outcomes", () => {
47
+ expect(
48
+ requireStoredRunV1({
49
+ ...storedRun(),
50
+ effectAdmissions: [
51
+ { kind: "model", effectId: "request-1", outcome: "fenced" },
52
+ { kind: "tool", effectId: "tool:1:1:0", outcome: "admitted" },
53
+ ],
54
+ }).effectAdmissions,
55
+ ).toEqual([
56
+ { kind: "model", effectId: "request-1", outcome: "fenced" },
57
+ { kind: "tool", effectId: "tool:1:1:0", outcome: "admitted" },
58
+ ]);
59
+ expect(() =>
60
+ requireStoredRunV1({
61
+ ...storedRun(),
62
+ effectAdmissions: [
63
+ {
64
+ kind: "model",
65
+ effectId: "request-1",
66
+ outcome: "fenced",
67
+ extra: true,
68
+ },
69
+ ],
70
+ }),
71
+ ).toThrow("invalid effect admission fields");
72
+ expect(() =>
73
+ requireStoredRunV1({
74
+ ...storedRun(),
75
+ effectAdmissions: [
76
+ { kind: "model", effectId: "same", outcome: "fenced" },
77
+ { kind: "tool", effectId: "same", outcome: "admitted" },
78
+ ],
79
+ }),
80
+ ).toThrow("colliding effect admissions");
81
+ expect(() =>
82
+ requireStoredRunV1({
83
+ ...storedRun(),
84
+ effectAdmissions: Array.from({ length: 257 }, (_, index) => ({
85
+ kind: "model",
86
+ effectId: `request-${index}`,
87
+ outcome: "admitted",
88
+ })),
89
+ }),
90
+ ).toThrow("invalid effect admissions");
91
+ expect(() =>
92
+ requireStoredRunV1({
93
+ ...storedRun(),
94
+ effectAdmissions: [
95
+ { kind: "model", effectId: "🧪".repeat(129), outcome: "admitted" },
96
+ ],
97
+ }),
98
+ ).toThrow("invalid effect admission id");
99
+ const symbolEntry = {
100
+ kind: "model",
101
+ effectId: "request-1",
102
+ outcome: "admitted",
103
+ [Symbol("hidden")]: true,
104
+ };
105
+ expect(() =>
106
+ requireStoredRunV1({
107
+ ...storedRun(),
108
+ effectAdmissions: [symbolEntry],
109
+ }),
110
+ ).toThrow("invalid effect admission fields");
111
+ });
112
+
113
+ test("rejects hidden and symbol top-level durable fields", () => {
114
+ const hidden = { ...storedRun() };
115
+ Object.defineProperty(hidden, "future", {
116
+ value: true,
117
+ enumerable: false,
118
+ });
119
+ expect(() => requireStoredRunV1(hidden)).toThrow("invalid fields");
120
+
121
+ const symbol = { ...storedRun(), [Symbol("future")]: true };
122
+ expect(() => requireStoredRunV1(symbol)).toThrow("invalid fields");
123
+ });
124
+
125
+ test("keeps Stop intent orthogonal and required for cancellation", () => {
126
+ expect(
127
+ requireStoredRunV1({
128
+ ...storedRun(),
129
+ phase: "executing",
130
+ stopRequestedAt: "2026-08-29T00:00:05.000Z",
131
+ }),
132
+ ).toMatchObject({
133
+ status: "running",
134
+ phase: "executing",
135
+ stopRequestedAt: "2026-08-29T00:00:05.000Z",
136
+ });
137
+ expect(() =>
138
+ requireStoredRunV1({
139
+ ...storedRun(),
140
+ status: "cancelled",
141
+ phase: "executing",
142
+ }),
143
+ ).toThrow("no durable stop intent");
144
+ expect(() =>
145
+ requireStoredRunV1({
146
+ ...storedRun(),
147
+ status: "cancelled",
148
+ phase: "executing",
149
+ stopRequestedAt: "whenever",
150
+ }),
151
+ ).toThrow("invalid stopRequestedAt");
152
+ expect(() =>
153
+ requireStoredRunV1({
154
+ ...storedRun(),
155
+ status: "cancelled",
156
+ phase: "executing",
157
+ stopRequestedAt: "2026-08-29T00:00:05.000Z",
158
+ failure: "stopped",
159
+ }),
160
+ ).toThrow("invalid failure fields");
161
+ expect(() =>
162
+ requireStoredRunV1({
163
+ ...storedRun(),
164
+ status: "reconciliation-required",
165
+ phase: "executing",
166
+ failure: "uncertain",
167
+ }),
168
+ ).toThrow("inconsistent recovery state");
169
+ });
170
+
171
+ test("accepts completed output up to the public wire byte limit", () => {
172
+ expect(
173
+ requireStoredRunV1({
174
+ ...storedRun(),
175
+ status: "completed",
176
+ phase: "executing",
177
+ responseText: "x".repeat(64_000),
178
+ }).responseText,
179
+ ).toHaveLength(64_000);
180
+ expect(() =>
181
+ requireStoredRunV1({
182
+ ...storedRun(),
183
+ status: "completed",
184
+ phase: "executing",
185
+ responseText: "🧪".repeat(16_001),
186
+ }),
187
+ ).toThrow("invalid responseText");
188
+ });
189
+ });
@@ -0,0 +1,44 @@
1
+ // The Bot Durable Object's run records are kernel authority; this module binds
2
+ // the kernel codec to the Shell Package's configuration snapshot decoder.
3
+ import {
4
+ createStoredRunCodecV1,
5
+ type StoredRunCodecV1,
6
+ type StoredRunV1,
7
+ } from "@frockbot/kernel-do";
8
+ import {
9
+ decodeBotSettingsViewV1,
10
+ isPublicIdentifier,
11
+ type BotSettingsViewV1,
12
+ } from "@frockbot/configuration-core";
13
+
14
+ export {
15
+ botStopCommandFingerprintV1,
16
+ botTurnCommandFingerprintV1,
17
+ type BotNotificationIntent,
18
+ type BotStopCommand,
19
+ type BotTurnCommand,
20
+ type BotTurnCompletion,
21
+ type StoredEffectAdmission,
22
+ type StoredEffectAdmissionOutcome,
23
+ type StoredRunPhase,
24
+ type StoredRunStatus,
25
+ } from "@frockbot/kernel-do";
26
+
27
+ export type StoredRun = StoredRunV1<BotSettingsViewV1>;
28
+
29
+ export function decodeRunIdV1(value: unknown): string {
30
+ if (!isPublicIdentifier(value)) {
31
+ throw new Error("runId is invalid");
32
+ }
33
+ return value;
34
+ }
35
+
36
+ export const storedRunCodecV1: StoredRunCodecV1<BotSettingsViewV1> =
37
+ createStoredRunCodecV1<BotSettingsViewV1>({
38
+ decodeRunId: decodeRunIdV1,
39
+ decodeConfigurationSnapshot: decodeBotSettingsViewV1,
40
+ });
41
+
42
+ export function requireStoredRunV1(input: unknown): StoredRun {
43
+ return storedRunCodecV1.require(input);
44
+ }
@@ -0,0 +1,202 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { initializeBotSettingsV1 } from "@frockbot/configuration-core";
3
+ import { createShellBotBackendContribution } from "./backend.js";
4
+ import type { StoredRun } from "./backend-contracts.js";
5
+
6
+ class MemoryStorage {
7
+ readonly values = new Map<string, unknown>();
8
+ alarmAt: number | undefined;
9
+
10
+ get<T>(key: string): Promise<T | undefined> {
11
+ return Promise.resolve(this.values.get(key) as T | undefined);
12
+ }
13
+
14
+ put(key: string | Record<string, unknown>, value?: unknown): Promise<void> {
15
+ if (typeof key === "string") this.values.set(key, structuredClone(value));
16
+ else {
17
+ for (const [entry, item] of Object.entries(key)) {
18
+ this.values.set(entry, structuredClone(item));
19
+ }
20
+ }
21
+ return Promise.resolve();
22
+ }
23
+
24
+ delete(key: string): Promise<boolean> {
25
+ return Promise.resolve(this.values.delete(key));
26
+ }
27
+
28
+ list<T>(options: {
29
+ prefix?: string;
30
+ end?: string;
31
+ reverse?: boolean;
32
+ limit?: number;
33
+ }): Promise<Map<string, T>> {
34
+ const entries = [...this.values.entries()]
35
+ .filter(
36
+ ([key]) =>
37
+ key.startsWith(options.prefix ?? "") &&
38
+ (options.end === undefined || key < options.end),
39
+ )
40
+ .sort(([left], [right]) => left.localeCompare(right));
41
+ if (options.reverse) entries.reverse();
42
+ return Promise.resolve(
43
+ new Map(entries.slice(0, options.limit) as Array<[string, T]>),
44
+ );
45
+ }
46
+
47
+ transaction<T>(callback: (storage: MemoryStorage) => Promise<T>): Promise<T> {
48
+ return callback(this);
49
+ }
50
+
51
+ setAlarm(scheduledTime: number): Promise<void> {
52
+ this.alarmAt = scheduledTime;
53
+ return Promise.resolve();
54
+ }
55
+
56
+ deleteAlarm(): Promise<void> {
57
+ this.alarmAt = undefined;
58
+ return Promise.resolve();
59
+ }
60
+ }
61
+
62
+ const IDENTITY = { userId: "user-1", botId: "primary" };
63
+
64
+ function storedRun(overrides: Partial<StoredRun> = {}): StoredRun {
65
+ return {
66
+ runId: "run-1",
67
+ commandFingerprint: "fingerprint",
68
+ sessionId: "user:primary",
69
+ acceptedAt: "2026-08-28T00:00:00.000Z",
70
+ input: "hello",
71
+ events: [],
72
+ effectAdmissions: [],
73
+ status: "running",
74
+ phase: "executing",
75
+ compositionGenerationId: "test-composition-generation",
76
+ configurationSnapshot: initializeBotSettingsV1("primary"),
77
+ previousEventCount: 0,
78
+ ...overrides,
79
+ } as StoredRun;
80
+ }
81
+
82
+ function contributionOver(storage: MemoryStorage) {
83
+ return createShellBotBackendContribution({
84
+ state: { storage } as unknown as DurableObjectState,
85
+ env: {} as never,
86
+ });
87
+ }
88
+
89
+ describe("Bot debug snapshot", () => {
90
+ test("reports the wedged active run with the events the client view hides", async () => {
91
+ const storage = new MemoryStorage();
92
+ const run = storedRun({
93
+ events: [
94
+ { type: "turn/start", turn: 1, seq: 1, timestamp: IDENTITY.userId },
95
+ {
96
+ type: "tool/call",
97
+ turn: 1,
98
+ step: 1,
99
+ occurrenceId: "call-1",
100
+ name: "shell",
101
+ input: { command: "ls" },
102
+ seq: 2,
103
+ timestamp: "2026-08-28T00:00:01.000Z",
104
+ },
105
+ ] as StoredRun["events"],
106
+ });
107
+ await storage.put({
108
+ identity: IDENTITY,
109
+ "active-run": run.runId,
110
+ [`run:${run.runId}`]: run,
111
+ [`run-index:${run.acceptedAt}:${run.runId}`]: run.runId,
112
+ });
113
+
114
+ const snapshot = await contributionOver(storage).debugSnapshot(IDENTITY, {
115
+ schemaVersion: 1,
116
+ events: true,
117
+ });
118
+
119
+ expect(snapshot.activeRunId).toBe("run-1");
120
+ expect(snapshot.runs).toHaveLength(1);
121
+ expect(snapshot.runs[0]).toMatchObject({
122
+ runId: "run-1",
123
+ status: "running",
124
+ phase: "executing",
125
+ eventCount: 2,
126
+ });
127
+ // The client projection drops `input`; the operator view is the reason
128
+ // this surface exists.
129
+ expect(snapshot.runs[0]!.events).toContainEqual(
130
+ expect.objectContaining({ name: "shell", input: { command: "ls" } }),
131
+ );
132
+ });
133
+
134
+ test("carries the failure of a failed run", async () => {
135
+ const storage = new MemoryStorage();
136
+ const run = storedRun({
137
+ runId: "run-failed",
138
+ status: "failed",
139
+ failure: "model connection is unavailable",
140
+ });
141
+ await storage.put({
142
+ identity: IDENTITY,
143
+ [`run:${run.runId}`]: run,
144
+ [`run-index:${run.acceptedAt}:${run.runId}`]: run.runId,
145
+ });
146
+
147
+ const snapshot = await contributionOver(storage).debugSnapshot(IDENTITY);
148
+
149
+ expect(snapshot.runs[0]).toMatchObject({
150
+ runId: "run-failed",
151
+ status: "failed",
152
+ failure: "model connection is unavailable",
153
+ });
154
+ expect(snapshot.activeRunId).toBeUndefined();
155
+ });
156
+
157
+ test("includes an active run older than the page it would otherwise fall off", async () => {
158
+ const storage = new MemoryStorage();
159
+ const stale = storedRun({
160
+ runId: "run-stale",
161
+ acceptedAt: "2026-08-27T00:00:00.000Z",
162
+ });
163
+ const recent = storedRun({
164
+ runId: "run-recent",
165
+ acceptedAt: "2026-08-29T00:00:00.000Z",
166
+ status: "completed",
167
+ phase: "admitted",
168
+ responseText: "done",
169
+ });
170
+ await storage.put({
171
+ identity: IDENTITY,
172
+ "active-run": stale.runId,
173
+ [`run:${stale.runId}`]: stale,
174
+ [`run:${recent.runId}`]: recent,
175
+ [`run-index:${stale.acceptedAt}:${stale.runId}`]: stale.runId,
176
+ [`run-index:${recent.acceptedAt}:${recent.runId}`]: recent.runId,
177
+ });
178
+
179
+ const snapshot = await contributionOver(storage).debugSnapshot(IDENTITY, {
180
+ schemaVersion: 1,
181
+ limit: 1,
182
+ });
183
+
184
+ expect(snapshot.runs.map((run) => run.runId)).toContain("run-stale");
185
+ });
186
+
187
+ test("does not disturb the run it is looking at", async () => {
188
+ const storage = new MemoryStorage();
189
+ const run = storedRun({ runId: "run-wedged" });
190
+ await storage.put({
191
+ identity: IDENTITY,
192
+ "active-run": run.runId,
193
+ [`run:${run.runId}`]: run,
194
+ [`run-index:${run.acceptedAt}:${run.runId}`]: run.runId,
195
+ });
196
+
197
+ await contributionOver(storage).debugSnapshot(IDENTITY);
198
+
199
+ expect(await storage.get<string>("active-run")).toBe("run-wedged");
200
+ expect(await storage.get<StoredRun>(`run:${run.runId}`)).toEqual(run);
201
+ });
202
+ });
@@ -0,0 +1,55 @@
1
+ import type { AgentEffectAdmission } from "@frockbot/kernel-agent-loop/agent";
2
+ import type {
3
+ PersistSessionEvents,
4
+ SessionEvent,
5
+ } from "@frockbot/kernel-contracts";
6
+ import type {
7
+ BotExecutionPlanV1,
8
+ BotSettingsViewV1,
9
+ ConnectionView,
10
+ } from "@frockbot/configuration-core";
11
+ import type { BotTurnCommand, BotTurnCompletion } from "./backend-contracts.js";
12
+
13
+ export interface BotResidentProjection {
14
+ generation: number;
15
+ userId: string;
16
+ botId: string;
17
+ settings: BotSettingsViewV1;
18
+ executionPlan: BotExecutionPlanV1;
19
+ systemPromptSection: string;
20
+ authorizeConnection(
21
+ assignment: BotSettingsViewV1["assignments"][number],
22
+ ): Promise<ConnectionView>;
23
+ }
24
+
25
+ export interface BotResidentTurnExecution {
26
+ botId: string;
27
+ command: BotTurnCommand;
28
+ previousEvents: readonly SessionEvent[];
29
+ persistSessionEvents: PersistSessionEvents;
30
+ /**
31
+ * Runs after the exact resident handle is addressable but before Agent input
32
+ * or recovery is activated. False means durable state fenced execution.
33
+ */
34
+ beforeStart(): Promise<boolean>;
35
+ /** Serializes each new provider/tool effect against durable Stop intent. */
36
+ admitEffect(effect: AgentEffectAdmission): Promise<boolean>;
37
+ resume?: boolean;
38
+ }
39
+
40
+ /** Narrow cancellation request bound to one exact resident run. */
41
+ export interface BotResidentCancellation {
42
+ botId: string;
43
+ sessionId: string;
44
+ runId: string;
45
+ reason: "user";
46
+ }
47
+
48
+ /** The Bot host's sole resident Agent-runtime seam. */
49
+ export interface BotResidentExecution {
50
+ project(projection: BotResidentProjection): Promise<void>;
51
+ execute(execution: BotResidentTurnExecution): Promise<BotTurnCompletion>;
52
+ /** Signals the resident Agent; true only when that exact run was signalled. */
53
+ cancel(cancellation: BotResidentCancellation): Promise<boolean>;
54
+ generation(): number | undefined;
55
+ }
@@ -0,0 +1,96 @@
1
+ // The Bot Durable Object's half of the Bot self-management seam.
2
+ //
3
+ // The Flock Package offers a Bot two tools over its own identity and its
4
+ // User's flock. This module decides, for one admitted Turn, what provenance
5
+ // those writes record and which authorities they may reach. It implements
6
+ // neither: the profile write is the Bot Durable Object's own configuration
7
+ // command, and the create is the User Durable Object's `bot/create` — the
8
+ // same two paths the hosted client drives.
9
+ //
10
+ // AUTHORITY. "Self-modification never widens authority." The host handed to
11
+ // the Package exposes exactly four calls, all of them things the User's own
12
+ // surfaces already do, and the `botId` on every one of them is fixed here
13
+ // rather than taken from the model's arguments. A Bot cannot address another
14
+ // Bot's settings through this seam because the seam never accepts a target.
15
+ //
16
+ // HIBERNATION. Nothing here reaches the Computer registry, a Computer
17
+ // provider, or a Sprite: identity is Durable Object state, so self-management
18
+ // works while the Computer is hibernated and does not wake it.
19
+ import type {
20
+ BotSettingsViewV1,
21
+ ConfigurationCommandV1,
22
+ OperationReceiptV1,
23
+ } from "@frockbot/configuration-core";
24
+ import type {
25
+ BotDirectoryViewV1,
26
+ CreateBotCommandV1,
27
+ FlockReceiptV1,
28
+ FlockSelfRuntimeHostV1,
29
+ } from "@frockbot/plugin-flock/agent";
30
+
31
+ /** The Bot and User whose identity a Turn may change. */
32
+ export interface BotSelfManagementIdentity {
33
+ userId: string;
34
+ botId: string;
35
+ }
36
+
37
+ /** The run, Turn, and Session a self-management write records. */
38
+ export interface BotSelfManagementTurn {
39
+ runId: string;
40
+ turnId: string;
41
+ sessionId: string;
42
+ }
43
+
44
+ /**
45
+ * The authorities this seam borrows, supplied by the Durable Object that owns
46
+ * them. Named as its own type so each one is an explicit grant rather than a
47
+ * reach into an environment.
48
+ */
49
+ export interface BotSelfManagementAuthorities {
50
+ readSettings(identity: BotSelfManagementIdentity): Promise<BotSettingsViewV1>;
51
+ executeConfiguration(
52
+ identity: BotSelfManagementIdentity,
53
+ command: Extract<ConfigurationCommandV1, { botId: string }>,
54
+ ): Promise<OperationReceiptV1>;
55
+ listBots(userId: string): Promise<BotDirectoryViewV1>;
56
+ createBot(
57
+ userId: string,
58
+ command: CreateBotCommandV1,
59
+ ): Promise<FlockReceiptV1>;
60
+ }
61
+
62
+ /**
63
+ * The self-management seam one admitted Turn runs under. There is no
64
+ * `undefined` case: identity is Durable Object state that is always present,
65
+ * unlike a Workspace surface a host may not have bound.
66
+ */
67
+ export function createBotSelfManagementHost(
68
+ identity: BotSelfManagementIdentity,
69
+ turn: BotSelfManagementTurn,
70
+ authorities: BotSelfManagementAuthorities,
71
+ ): FlockSelfRuntimeHostV1 {
72
+ const owner = { userId: identity.userId, botId: identity.botId };
73
+ return {
74
+ owner,
75
+ // A Bot changes itself only inside a Turn whose Session and Turn its
76
+ // provenance names — the same rule Memory, Skills and Package authoring
77
+ // follow.
78
+ writer: {
79
+ kind: "bot",
80
+ botId: identity.botId,
81
+ sessionId: turn.sessionId,
82
+ turnId: turn.turnId,
83
+ },
84
+ readSelf: () => authorities.readSettings(identity),
85
+ commandSelf: (command) => {
86
+ // The target is this Bot, decided here. A command aimed anywhere else
87
+ // never reaches an authority.
88
+ if (command.botId !== identity.botId) {
89
+ throw new Error("a Bot may only change its own configuration");
90
+ }
91
+ return authorities.executeConfiguration(identity, command);
92
+ },
93
+ listBots: () => authorities.listBots(identity.userId),
94
+ createBot: (command) => authorities.createBot(identity.userId, command),
95
+ };
96
+ }
@@ -0,0 +1,115 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ FakeImageWorkspace,
4
+ fakePngBytesV1,
5
+ } from "@frockbot/plugin-image/testing";
6
+ import {
7
+ createBotImageHost,
8
+ createWorkersAiImageModelV1,
9
+ decodeWorkersAiImageV1,
10
+ } from "./backend-image.ts";
11
+
12
+ const IDENTITY = { userId: "user-1", botId: "bot-1" };
13
+ const TURN = { runId: "run-9", turnId: "turn-4", sessionId: "user-1:bot-1" };
14
+
15
+ function base64(bytes: Uint8Array): string {
16
+ return btoa(String.fromCharCode(...bytes));
17
+ }
18
+
19
+ describe("the Bot image seam", () => {
20
+ test("mounts nothing when the Workspace file surface is unbound", () => {
21
+ expect(createBotImageHost(IDENTITY, TURN, { AI: { run: () => {} } })).toBe(
22
+ undefined,
23
+ );
24
+ });
25
+
26
+ test("mounts with no model when Workers AI is unbound, so the refusal is visible", () => {
27
+ const workspace = new FakeImageWorkspace();
28
+ const host = createBotImageHost(IDENTITY, TURN, {
29
+ WORKSPACE_FILES: workspace,
30
+ });
31
+ expect(host).toBeDefined();
32
+ expect(host?.model).toBeUndefined();
33
+ expect(host?.files).toBe(workspace);
34
+ // The seam reaches the Workspace and nothing else: no Computer is opened
35
+ // to build it, so a hibernated Computer changes none of this.
36
+ expect(workspace.calls).toEqual([]);
37
+ });
38
+
39
+ test("binds the Bot's provenance and the adapted model when both are present", () => {
40
+ const workspace = new FakeImageWorkspace();
41
+ const host = createBotImageHost(IDENTITY, TURN, {
42
+ WORKSPACE_FILES: workspace,
43
+ AI: { run: () => Promise.resolve({ image: "" }) },
44
+ });
45
+ expect(host?.owner).toEqual(IDENTITY);
46
+ expect(host?.writer).toEqual({
47
+ sessionId: "user-1:bot-1",
48
+ turnId: "turn-4",
49
+ runId: "run-9",
50
+ });
51
+ expect(host?.model).toBeDefined();
52
+ });
53
+ });
54
+
55
+ describe("normalizing what Workers AI answered", () => {
56
+ const png = fakePngBytesV1(64, 32);
57
+
58
+ test("accepts the base64 envelope the FLUX models answer", async () => {
59
+ const buffer = await decodeWorkersAiImageV1({ image: base64(png) });
60
+ expect([...new Uint8Array(buffer)]).toEqual([...png]);
61
+ });
62
+
63
+ test("accepts the binary stream the Stable Diffusion models answer", async () => {
64
+ const stream = new ReadableStream<Uint8Array>({
65
+ start(controller) {
66
+ controller.enqueue(png.slice(0, 8));
67
+ controller.enqueue(png.slice(8));
68
+ controller.close();
69
+ },
70
+ });
71
+ expect([...new Uint8Array(await decodeWorkersAiImageV1(stream))]).toEqual([
72
+ ...png,
73
+ ]);
74
+ });
75
+
76
+ test("accepts a raw buffer or view", async () => {
77
+ expect([
78
+ ...new Uint8Array(await decodeWorkersAiImageV1(png.slice().buffer)),
79
+ ]).toEqual([...png]);
80
+ expect([...new Uint8Array(await decodeWorkersAiImageV1(png))]).toEqual([
81
+ ...png,
82
+ ]);
83
+ });
84
+
85
+ test("refuses anything else rather than storing it", async () => {
86
+ for (const answer of [undefined, null, 7, "hello", { image: 3 }, {}]) {
87
+ await expect(decodeWorkersAiImageV1(answer)).rejects.toThrow(
88
+ "not an image",
89
+ );
90
+ }
91
+ });
92
+
93
+ test("passes the requested size through to the binding", async () => {
94
+ const calls: Array<[string, Record<string, unknown>]> = [];
95
+ const model = createWorkersAiImageModelV1({
96
+ run: (name, input) => {
97
+ calls.push([name, input]);
98
+ return Promise.resolve({ image: base64(png) });
99
+ },
100
+ });
101
+
102
+ await model.run("@cf/black-forest-labs/flux-1-schnell", {
103
+ prompt: "a red barn",
104
+ width: 512,
105
+ height: 512,
106
+ });
107
+
108
+ expect(calls).toEqual([
109
+ [
110
+ "@cf/black-forest-labs/flux-1-schnell",
111
+ { prompt: "a red barn", width: 512, height: 512 },
112
+ ],
113
+ ]);
114
+ });
115
+ });