@frockbot/plugin-shell 0.1.3 → 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.
Files changed (41) hide show
  1. package/frockbot.json +4 -0
  2. package/package.json +30 -28
  3. package/src/agent.ts +10 -13
  4. package/src/backend-authoring.test.ts +356 -2
  5. package/src/backend-authoring.ts +585 -20
  6. package/src/backend-composition-input.test.ts +65 -0
  7. package/src/backend-composition-input.ts +58 -0
  8. package/src/backend-composition.ts +23 -5
  9. package/src/backend-configuration.test.ts +549 -1468
  10. package/src/backend-contracts.test.ts +28 -0
  11. package/src/backend-contracts.ts +3 -1
  12. package/src/backend-execution.ts +0 -4
  13. package/src/backend-iframe-ui.test.ts +131 -0
  14. package/src/backend-image.test.ts +8 -8
  15. package/src/backend-image.ts +13 -13
  16. package/src/backend-isolate.test.ts +230 -89
  17. package/src/backend-isolate.ts +103 -200
  18. package/src/backend-package-catalog.test.ts +451 -0
  19. package/src/backend-package-catalog.ts +923 -0
  20. package/src/backend-recovery-integration.test.ts +17 -67
  21. package/src/backend-routines.ts +1 -1
  22. package/src/backend-runner-iframe.test.ts +74 -0
  23. package/src/backend-runner.ts +192 -0
  24. package/src/backend.ts +1198 -1781
  25. package/src/client/FrockBotApp.vue +44 -73
  26. package/src/client/PackageIframeHost.vue +218 -0
  27. package/src/client/PackageIframeSettings.vue +52 -0
  28. package/src/client/SendPayloadView.vue +0 -60
  29. package/src/client/index.test.ts +439 -380
  30. package/src/client/index.ts +163 -257
  31. package/src/client/model-presentation.test.ts +21 -8
  32. package/src/client/model-presentation.ts +14 -7
  33. package/src/client/package-iframe-host-message.test.ts +41 -0
  34. package/src/client/package-iframe-host-message.ts +27 -0
  35. package/src/client/styles.css +0 -27
  36. package/src/composition-views.ts +54 -0
  37. package/src/settings-links.test.ts +2 -10
  38. package/src/settings-links.ts +1 -13
  39. package/src/shared.ts +10 -13
  40. package/src/backend-assignment.test.ts +0 -161
  41. package/src/backend-assignment.ts +0 -274
@@ -1,38 +1,42 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type {
3
+ IsolateConnectionV1,
3
4
  LlmStreamEvent,
4
5
  NormalizedModelRequest,
5
6
  } from "@frockbot/kernel-contracts";
6
7
  import {
7
8
  createIsolateCapabilityHost,
9
+ isolateBindingDigestV1,
8
10
  isolateModelEventStreamV1,
9
11
  ISOLATE_MODEL_FAILURE_MESSAGE,
10
12
  ISOLATE_MODEL_REQUEST_PREFIX,
11
- matchingModelAssignmentV1,
12
- type IsolateAssignmentV1,
13
+ matchesAdmittedConnectionV1,
14
+ matchingModelBindingV1,
13
15
  type IsolateModelBindingV1,
14
16
  type IsolateModelRequestRecordV1,
15
17
  } from "./backend-isolate.ts";
16
18
 
17
- const ASSIGNMENT: IsolateAssignmentV1 = {
18
- assignmentId: "model-assignment",
19
- packageId: "provider-ollama-cloud",
20
- capabilityId: "ollama-cloud-models",
21
- kind: "model",
22
- connectionId: "connection-1",
23
- providerModelId: "glm-5.3-flash:cloud",
24
- };
19
+ const CONNECTIONS: IsolateConnectionV1[] = [
20
+ {
21
+ connectionId: "connection-1",
22
+ packageId: "provider-ollama-cloud",
23
+ connectionTypeId: "ollama-cloud-account",
24
+ displayName: "Work",
25
+ generation: "generation-1",
26
+ safeMetadata: { region: "au" },
27
+ },
28
+ ];
25
29
 
26
30
  const BINDING: IsolateModelBindingV1 = {
27
- assignmentId: "model-assignment",
28
- packageId: "provider-ollama-cloud",
29
- capabilityId: "ollama-cloud-models",
30
31
  connectionId: "connection-1",
32
+ packageId: "provider-ollama-cloud",
31
33
  provider: "ollama-cloud",
32
34
  providerModelId: "glm-5.3-flash:cloud",
33
35
  connectionGeneration: "generation-1",
34
36
  };
35
37
 
38
+ const IDENTITY = { userId: "user-1", botId: "bot-1" } as const;
39
+
36
40
  function request(
37
41
  overrides: Partial<NormalizedModelRequest> = {},
38
42
  ): NormalizedModelRequest {
@@ -43,6 +47,10 @@ function request(
43
47
  system: "",
44
48
  messages: [{ role: "user", content: "hello" }],
45
49
  tools: [],
50
+ modelBinding: {
51
+ connectionId: BINDING.connectionId,
52
+ connectionGeneration: BINDING.connectionGeneration,
53
+ },
46
54
  ...overrides,
47
55
  };
48
56
  }
@@ -55,7 +63,6 @@ function memoryStorage() {
55
63
  values.set(key, structuredClone(value));
56
64
  return Promise.resolve();
57
65
  },
58
- get: <T>(key: string) => Promise.resolve(values.get(key) as T | undefined),
59
66
  list: <T>(options: { prefix: string }) =>
60
67
  Promise.resolve(
61
68
  new Map(
@@ -69,123 +76,259 @@ function memoryStorage() {
69
76
 
70
77
  function host(
71
78
  options: {
79
+ packageId?: string;
72
80
  binding?: IsolateModelBindingV1;
81
+ withoutModel?: boolean;
73
82
  stream?: (request: NormalizedModelRequest) => AsyncIterable<LlmStreamEvent>;
74
83
  } = {},
75
84
  ) {
76
85
  const storage = memoryStorage();
77
86
  const forwarded: NormalizedModelRequest[] = [];
78
87
  let minted = 0;
79
- const binding = options.binding ?? BINDING;
80
88
  return {
81
89
  storage,
82
90
  forwarded,
83
91
  host: createIsolateCapabilityHost({
84
92
  storage,
85
93
  botId: "bot-1",
86
- packageId: "bot-authored",
87
- generationId: "generation-1",
88
- assignments: [ASSIGNMENT],
89
- modelBinding: binding,
90
- modelPath: {
91
- stream: (value) => {
92
- forwarded.push(structuredClone(value));
93
- return (
94
- options.stream?.(value) ??
95
- (async function* () {
96
- yield { type: "text-delta", text: "hi" } as LlmStreamEvent;
97
- })()
98
- );
99
- },
100
- },
94
+ packageId: options.packageId ?? "bot-authored",
95
+ generationId: "composition-1",
96
+ connections: CONNECTIONS,
97
+ ...(!options.withoutModel
98
+ ? { modelBinding: options.binding ?? BINDING }
99
+ : {}),
100
+ ...(!options.withoutModel
101
+ ? {
102
+ modelPath: {
103
+ stream: (value: NormalizedModelRequest) => {
104
+ forwarded.push(structuredClone(value));
105
+ return (
106
+ options.stream?.(value) ??
107
+ (async function* () {
108
+ yield { type: "text-delta", text: "hi" } as LlmStreamEvent;
109
+ })()
110
+ );
111
+ },
112
+ },
113
+ }
114
+ : {}),
115
+ memory: true,
116
+ workspace: true,
101
117
  newId: () => `minted-${(minted += 1)}`,
102
118
  now: () => new Date("2026-08-31T00:00:00.000Z"),
103
119
  }),
104
120
  };
105
121
  }
106
122
 
107
- describe("an isolate model request is bound to its Assignment", () => {
108
- test("matches only the exact assigned provider and model", () => {
109
- expect(
110
- matchingModelAssignmentV1([ASSIGNMENT], BINDING, request()),
111
- ).toMatchObject({ assignmentId: "model-assignment" });
112
- expect(
113
- matchingModelAssignmentV1(
114
- [ASSIGNMENT],
115
- BINDING,
116
- request({ provider: "foundation" }),
117
- ),
118
- ).toBeUndefined();
123
+ describe("per-Bot isolate authority", () => {
124
+ test("two Packages of one Bot see the same Connections and capabilities", async () => {
125
+ const left = await host({ packageId: "package-left" }).host.list();
126
+ const right = await host({ packageId: "package-right" }).host.list();
127
+ expect(left).toEqual(right);
128
+ expect(left).toMatchObject({
129
+ status: "available",
130
+ connections: [
131
+ { connectionId: "connection-1", generation: "generation-1" },
132
+ ],
133
+ tools: true,
134
+ memory: true,
135
+ workspace: true,
136
+ notify: true,
137
+ schedule: true,
138
+ });
139
+ });
140
+
141
+ test("matches only the Bot's configured provider and model", () => {
142
+ expect(matchingModelBindingV1(BINDING, request())).toEqual(BINDING);
119
143
  expect(
120
- matchingModelAssignmentV1(
121
- [ASSIGNMENT],
122
- BINDING,
123
- request({ model: "some-other-model" }),
124
- ),
144
+ matchingModelBindingV1(BINDING, request({ provider: "foundation" })),
125
145
  ).toBeUndefined();
126
- // No durable binding at all: an enabled Assignment on its own authorizes
127
- // nothing.
128
146
  expect(
129
- matchingModelAssignmentV1([ASSIGNMENT], undefined, request()),
147
+ matchingModelBindingV1(BINDING, request({ model: "other" })),
130
148
  ).toBeUndefined();
131
- });
132
-
133
- test("ignores a Bot-supplied model binding", () => {
134
149
  expect(
135
- matchingModelAssignmentV1(
136
- [ASSIGNMENT],
150
+ matchingModelBindingV1(
137
151
  BINDING,
138
152
  request({
139
- provider: "foundation",
140
- model: "deterministic-v1",
141
- modelBinding: { connectionId: "connection-1" },
153
+ modelBinding: {
154
+ connectionId: BINDING.connectionId,
155
+ connectionGeneration: "generation-2",
156
+ },
142
157
  }),
143
158
  ),
144
159
  ).toBeUndefined();
160
+ expect(matchingModelBindingV1(undefined, request())).toBeUndefined();
145
161
  });
146
162
 
147
- test("a request for an unassigned provider is a pending decision", async () => {
148
- const subject = host();
149
- const outcome = await subject.host.invokeModel(
150
- request({ provider: "foundation", model: "deterministic-v1" }),
151
- );
152
-
153
- expect(outcome).toMatchObject({ status: "pending-user-decision" });
163
+ test("a Bot with no configured model gets unavailable, never a pending decision", async () => {
164
+ const subject = host({ withoutModel: true });
165
+ await expect(subject.host.invokeModel(request())).resolves.toEqual({
166
+ status: "unavailable",
167
+ reason: "this Bot has no configured model",
168
+ });
154
169
  expect(await subject.host.recordedModelRequests()).toHaveLength(0);
155
170
  expect(subject.forwarded).toHaveLength(0);
156
171
  });
157
172
 
158
- test("forwards the authority's binding, never the Bot's", async () => {
173
+ test("a request that does not match the configured model is unavailable", async () => {
159
174
  const subject = host();
160
- await subject.host.invokeModel(
161
- request({
162
- modelBinding: {
163
- connectionId: "another-users-connection",
164
- connectionGeneration: "forged",
165
- },
175
+ await expect(
176
+ subject.host.invokeModel(
177
+ request({ provider: "foundation", model: "deterministic-v1" }),
178
+ ),
179
+ ).resolves.toEqual({
180
+ status: "unavailable",
181
+ reason: "the request does not match this Bot's configured model",
182
+ });
183
+ expect(await subject.host.recordedModelRequests()).toHaveLength(0);
184
+ });
185
+
186
+ test("refuses a model binding outside the admitted snapshot", async () => {
187
+ const subject = host();
188
+ await expect(
189
+ subject.host.invokeModel(
190
+ request({
191
+ modelBinding: {
192
+ connectionId: "forged-connection",
193
+ connectionGeneration: "forged-generation",
194
+ },
195
+ }),
196
+ ),
197
+ ).resolves.toEqual({
198
+ status: "unavailable",
199
+ reason: "the request does not match this Bot's configured model",
200
+ });
201
+ expect(subject.forwarded).toHaveLength(0);
202
+ });
203
+
204
+ test("an old isolate cannot receive a newly added or regenerated Connection", () => {
205
+ const lease = {
206
+ status: "available" as const,
207
+ leaseId: "lease-1",
208
+ connectionId: "connection-1",
209
+ generation: "generation-1",
210
+ expiresAt: "2026-08-31T00:05:00.000Z",
211
+ };
212
+ expect(matchesAdmittedConnectionV1(CONNECTIONS[0], lease)).toBe(true);
213
+ expect(matchesAdmittedConnectionV1(undefined, lease)).toBe(false);
214
+ expect(
215
+ matchesAdmittedConnectionV1(CONNECTIONS[0], {
216
+ ...lease,
217
+ generation: "generation-2",
218
+ }),
219
+ ).toBe(false);
220
+ });
221
+ });
222
+
223
+ describe("isolate binding digest", () => {
224
+ test("is order-independent for the same Bot authority", async () => {
225
+ const another = {
226
+ ...CONNECTIONS[0]!,
227
+ connectionId: "connection-2",
228
+ generation: "generation-2",
229
+ };
230
+ const input = {
231
+ model: BINDING,
232
+ compositionGenerationId: "composition-1",
233
+ };
234
+ await expect(
235
+ isolateBindingDigestV1({
236
+ ...IDENTITY,
237
+ ...input,
238
+ connections: [CONNECTIONS[0]!, another],
239
+ }),
240
+ ).resolves.toBe(
241
+ await isolateBindingDigestV1({
242
+ ...IDENTITY,
243
+ ...input,
244
+ connections: [another, CONNECTIONS[0]!],
166
245
  }),
167
246
  );
247
+ });
168
248
 
169
- expect(subject.forwarded[0]?.modelBinding).toEqual({
170
- connectionId: "connection-1",
171
- connectionGeneration: "generation-1",
249
+ test("changes with User and Bot identity", async () => {
250
+ const input = {
251
+ connections: CONNECTIONS,
252
+ model: BINDING,
253
+ compositionGenerationId: "composition-1",
254
+ };
255
+ const base = await isolateBindingDigestV1({ ...IDENTITY, ...input });
256
+ const otherUser = await isolateBindingDigestV1({
257
+ userId: "user-2",
258
+ botId: IDENTITY.botId,
259
+ ...input,
260
+ });
261
+ const otherBot = await isolateBindingDigestV1({
262
+ userId: IDENTITY.userId,
263
+ botId: "bot-2",
264
+ ...input,
172
265
  });
266
+
267
+ expect(otherUser).not.toBe(base);
268
+ expect(otherBot).not.toBe(base);
269
+ });
270
+
271
+ test("changes when a Connection is added, removed, or regenerated", async () => {
272
+ const base = await isolateBindingDigestV1({
273
+ ...IDENTITY,
274
+ connections: CONNECTIONS,
275
+ model: BINDING,
276
+ compositionGenerationId: "composition-1",
277
+ });
278
+ const added = await isolateBindingDigestV1({
279
+ ...IDENTITY,
280
+ connections: [
281
+ ...CONNECTIONS,
282
+ { ...CONNECTIONS[0]!, connectionId: "connection-2" },
283
+ ],
284
+ model: BINDING,
285
+ compositionGenerationId: "composition-1",
286
+ });
287
+ const removed = await isolateBindingDigestV1({
288
+ ...IDENTITY,
289
+ connections: [],
290
+ model: BINDING,
291
+ compositionGenerationId: "composition-1",
292
+ });
293
+ const regenerated = await isolateBindingDigestV1({
294
+ ...IDENTITY,
295
+ connections: [{ ...CONNECTIONS[0]!, generation: "generation-2" }],
296
+ model: BINDING,
297
+ compositionGenerationId: "composition-1",
298
+ });
299
+ expect(new Set([base, added, removed, regenerated]).size).toBe(4);
300
+ });
301
+
302
+ test("changes with the model binding and Composition generation", async () => {
303
+ const base = await isolateBindingDigestV1({
304
+ ...IDENTITY,
305
+ connections: CONNECTIONS,
306
+ model: BINDING,
307
+ compositionGenerationId: "composition-1",
308
+ });
309
+ const model = await isolateBindingDigestV1({
310
+ ...IDENTITY,
311
+ connections: CONNECTIONS,
312
+ model: { ...BINDING, providerModelId: "other" },
313
+ compositionGenerationId: "composition-1",
314
+ });
315
+ const composition = await isolateBindingDigestV1({
316
+ ...IDENTITY,
317
+ connections: CONNECTIONS,
318
+ model: BINDING,
319
+ compositionGenerationId: "composition-2",
320
+ });
321
+ expect(new Set([base, model, composition]).size).toBe(3);
173
322
  });
174
323
  });
175
324
 
176
325
  describe("the isolate model request record", () => {
177
- test("two invocations reusing one Bot request id produce two records", async () => {
326
+ test("two invocations reusing one request id produce two records", async () => {
178
327
  const subject = host();
179
-
180
328
  await subject.host.invokeModel(request());
181
329
  await subject.host.invokeModel(request());
182
-
183
330
  const recorded = await subject.host.recordedModelRequests();
184
331
  expect(recorded).toHaveLength(2);
185
- expect(recorded.map((entry) => entry.requestId)).toEqual([
186
- "request-1",
187
- "request-1",
188
- ]);
189
332
  expect(new Set(recorded.map((entry) => entry.recordId)).size).toBe(2);
190
333
  expect(
191
334
  [...subject.storage.values.keys()].filter((key) =>
@@ -194,23 +337,22 @@ describe("the isolate model request record", () => {
194
337
  ).toHaveLength(2);
195
338
  });
196
339
 
197
- test("refuses an unbounded Bot request id", async () => {
198
- const subject = host();
340
+ test("refuses an unbounded request id", async () => {
199
341
  await expect(
200
- subject.host.invokeModel(request({ requestId: "r".repeat(257) })),
342
+ host().host.invokeModel(request({ requestId: "r".repeat(257) })),
201
343
  ).rejects.toThrow("requestId is not bounded");
202
344
  });
203
345
 
204
- test("records the request that was forwarded", async () => {
205
- const subject = host();
346
+ test("records the request that was forwarded and attributes the Package", async () => {
347
+ const subject = host({ packageId: "bot-authored" });
206
348
  await subject.host.invokeModel(request());
207
349
  const recorded = (await subject.host.recordedModelRequests())[0] as
208
350
  IsolateModelRequestRecordV1 | undefined;
351
+ expect(recorded?.packageId).toBe("bot-authored");
209
352
  expect(recorded?.request.modelBinding).toEqual({
210
353
  connectionId: "connection-1",
211
354
  connectionGeneration: "generation-1",
212
355
  });
213
- expect(recorded?.capabilityId).toBe("ollama-cloud-models");
214
356
  });
215
357
  });
216
358
 
@@ -226,7 +368,6 @@ describe("provider errors crossing into Bot code", () => {
226
368
  );
227
369
  const reader = stream.getReader();
228
370
  await reader.read();
229
-
230
371
  const failure = await reader.read().then(
231
372
  () => undefined,
232
373
  (error: unknown) => (error instanceof Error ? error.message : ""),