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