@frockbot/kernel-contracts 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.
@@ -0,0 +1,417 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ decodeIsolateAuthorityRequestV1,
4
+ decodeIsolateCapabilityFailureV1,
5
+ decodeIsolateCapabilityListV1,
6
+ decodeIsolateHealthV1,
7
+ decodeIsolateIdentityV1,
8
+ decodeIsolateModelEventV1,
9
+ decodeIsolateModelInvocationV1,
10
+ decodeIsolatePendingDecisionV1,
11
+ decodeIsolateToolDescriptorV1,
12
+ decodeIsolateToolInvocationV1,
13
+ decodeIsolateToolResultV1,
14
+ encodeIsolateModelEventLineV1,
15
+ isolateLoaderIdV1,
16
+ isolateToolSchemaV1,
17
+ ISOLATE_MAX_DEADLINE_MS,
18
+ } from "./isolate.js";
19
+
20
+ function descriptor(overrides: Record<string, unknown> = {}) {
21
+ return {
22
+ name: "reverse_text",
23
+ description: "Reverses text",
24
+ inputSchema: { type: "object" },
25
+ idempotent: true,
26
+ ...overrides,
27
+ };
28
+ }
29
+
30
+ function invocation(overrides: Record<string, unknown> = {}) {
31
+ return {
32
+ schemaVersion: 1 as const,
33
+ tool: "reverse_text",
34
+ input: { text: "ab" },
35
+ botId: "bot-1",
36
+ sessionId: "session-1",
37
+ runId: "run-1",
38
+ turnId: "turn-1",
39
+ generationId: "2026-08-31T00:00:00.000Z:0123456789abcdef",
40
+ deadlineMs: 5_000,
41
+ ...overrides,
42
+ };
43
+ }
44
+
45
+ describe("isolate tool descriptor v1", () => {
46
+ test("decodes an exact descriptor", () => {
47
+ expect(decodeIsolateToolDescriptorV1(descriptor())).toEqual({
48
+ name: "reverse_text",
49
+ description: "Reverses text",
50
+ inputSchema: { type: "object" },
51
+ idempotent: true,
52
+ });
53
+ });
54
+
55
+ test("rejects an unknown field", () => {
56
+ expect(() =>
57
+ decodeIsolateToolDescriptorV1({ ...descriptor(), extra: 1 }),
58
+ ).toThrow(/invalid fields/);
59
+ });
60
+
61
+ test("rejects a tool name the kernel would not accept", () => {
62
+ expect(() =>
63
+ decodeIsolateToolDescriptorV1(descriptor({ name: "Reverse-Text" })),
64
+ ).toThrow(/name is invalid/);
65
+ });
66
+
67
+ test("rejects a non-object input schema", () => {
68
+ expect(() =>
69
+ decodeIsolateToolDescriptorV1(descriptor({ inputSchema: [] })),
70
+ ).toThrow(/inputSchema must be an object/);
71
+ });
72
+
73
+ test("projects onto the kernel tool schema", () => {
74
+ expect(
75
+ isolateToolSchemaV1(decodeIsolateToolDescriptorV1(descriptor())),
76
+ ).toEqual({
77
+ name: "reverse_text",
78
+ description: "Reverses text",
79
+ inputSchema: { type: "object" },
80
+ });
81
+ });
82
+ });
83
+
84
+ describe("isolate tool invocation v1", () => {
85
+ test("decodes an exact invocation", () => {
86
+ expect(decodeIsolateToolInvocationV1(invocation())).toEqual(invocation());
87
+ });
88
+
89
+ test("rejects a missing field", () => {
90
+ const { runId: _runId, ...partial } = invocation();
91
+ expect(() => decodeIsolateToolInvocationV1(partial)).toThrow(
92
+ /invalid fields/,
93
+ );
94
+ });
95
+
96
+ test("rejects a deadline beyond the contract bound", () => {
97
+ expect(() =>
98
+ decodeIsolateToolInvocationV1(
99
+ invocation({ deadlineMs: ISOLATE_MAX_DEADLINE_MS + 1 }),
100
+ ),
101
+ ).toThrow(/deadlineMs is out of range/);
102
+ });
103
+
104
+ test("rejects a zero deadline", () => {
105
+ expect(() =>
106
+ decodeIsolateToolInvocationV1(invocation({ deadlineMs: 0 })),
107
+ ).toThrow(/deadlineMs is out of range/);
108
+ });
109
+
110
+ test("rejects input that is not JSON", () => {
111
+ expect(() =>
112
+ decodeIsolateToolInvocationV1(invocation({ input: { at: () => 1 } })),
113
+ ).toThrow(/must be JSON/);
114
+ });
115
+ });
116
+
117
+ describe("isolate tool result v1", () => {
118
+ test("decodes an empty successful result", () => {
119
+ expect(
120
+ decodeIsolateToolResultV1({
121
+ schemaVersion: 1,
122
+ content: "",
123
+ isError: false,
124
+ }),
125
+ ).toEqual({ schemaVersion: 1, content: "", isError: false });
126
+ });
127
+
128
+ test("rejects a non-boolean isError", () => {
129
+ expect(() =>
130
+ decodeIsolateToolResultV1({
131
+ schemaVersion: 1,
132
+ content: "ok",
133
+ isError: "false",
134
+ }),
135
+ ).toThrow(/isError must be a boolean/);
136
+ });
137
+
138
+ test("rejects an unsupported schema version", () => {
139
+ expect(() =>
140
+ decodeIsolateToolResultV1({
141
+ schemaVersion: 2,
142
+ content: "ok",
143
+ isError: false,
144
+ }),
145
+ ).toThrow(/schemaVersion is unsupported/);
146
+ });
147
+ });
148
+
149
+ describe("isolate health v1", () => {
150
+ const health = {
151
+ schemaVersion: 1,
152
+ ok: true,
153
+ packageId: "bot-authored",
154
+ contractVersion: 1,
155
+ tools: [descriptor()],
156
+ };
157
+
158
+ test("decodes a healthy report", () => {
159
+ expect(decodeIsolateHealthV1(health).tools).toHaveLength(1);
160
+ });
161
+
162
+ test("rejects an unsupported contract version", () => {
163
+ expect(() =>
164
+ decodeIsolateHealthV1({ ...health, contractVersion: 3 }),
165
+ ).toThrow(/contractVersion is unsupported/);
166
+ expect(() =>
167
+ decodeIsolateHealthV1({ ...health, contractVersion: 0 }),
168
+ ).toThrow(/contractVersion is unsupported/);
169
+ });
170
+
171
+ test("admits a v1 descriptor onto every turn type", () => {
172
+ const decoded = decodeIsolateHealthV1(health);
173
+ expect(decoded.contractVersion).toBe(1);
174
+ expect(decoded.tools[0]?.admission).toBeUndefined();
175
+ });
176
+
177
+ test("carries a v2 descriptor admission through, and refuses one on v1", () => {
178
+ const decoded = decodeIsolateHealthV1({
179
+ ...health,
180
+ contractVersion: 2,
181
+ tools: [descriptor({ admission: { turnTypes: ["chat"] } })],
182
+ });
183
+ expect(decoded).toMatchObject({
184
+ contractVersion: 2,
185
+ tools: [{ admission: { turnTypes: ["chat"] } }],
186
+ });
187
+ expect(
188
+ decodeIsolateHealthV1({ ...health, contractVersion: 2 }).tools[0]
189
+ ?.admission,
190
+ ).toBeUndefined();
191
+ expect(() =>
192
+ decodeIsolateHealthV1({
193
+ ...health,
194
+ tools: [descriptor({ admission: { turnTypes: ["chat"] } })],
195
+ }),
196
+ ).toThrow(/invalid fields/);
197
+ });
198
+
199
+ test("rejects an unknown turn type in a v2 descriptor", () => {
200
+ expect(() =>
201
+ decodeIsolateHealthV1({
202
+ ...health,
203
+ contractVersion: 2,
204
+ tools: [descriptor({ admission: { turnTypes: ["routine"] } })],
205
+ }),
206
+ ).toThrow(/turnTypes\[0\] is invalid/);
207
+ expect(() =>
208
+ decodeIsolateHealthV1({
209
+ ...health,
210
+ contractVersion: 2,
211
+ tools: [descriptor({ admission: { turnTypes: [] } })],
212
+ }),
213
+ ).toThrow(/turnTypes must not be empty/);
214
+ });
215
+
216
+ test("rejects duplicate tool names", () => {
217
+ expect(() =>
218
+ decodeIsolateHealthV1({ ...health, tools: [descriptor(), descriptor()] }),
219
+ ).toThrow(/duplicate names/);
220
+ });
221
+ });
222
+
223
+ describe("isolate identity and capabilities", () => {
224
+ test("decodes the identity binding", () => {
225
+ expect(
226
+ decodeIsolateIdentityV1({
227
+ botId: "bot-1",
228
+ generationId: "gen-1",
229
+ packageId: "pkg-1",
230
+ }),
231
+ ).toEqual({ botId: "bot-1", generationId: "gen-1", packageId: "pkg-1" });
232
+ });
233
+
234
+ test("decodes a capability list", () => {
235
+ expect(
236
+ decodeIsolateCapabilityListV1([
237
+ { capabilityId: "models:chat", kind: "model" },
238
+ ]),
239
+ ).toHaveLength(1);
240
+ });
241
+
242
+ test("rejects an unknown capability kind", () => {
243
+ expect(() =>
244
+ decodeIsolateCapabilityListV1([
245
+ { capabilityId: "models:chat", kind: "network" },
246
+ ]),
247
+ ).toThrow(/kind is invalid/);
248
+ });
249
+
250
+ test("decodes an authority request and its pending answer", () => {
251
+ expect(
252
+ decodeIsolateAuthorityRequestV1({
253
+ capabilityId: "models:chat",
254
+ reason: "translate",
255
+ }),
256
+ ).toEqual({ capabilityId: "models:chat", reason: "translate" });
257
+ expect(
258
+ decodeIsolatePendingDecisionV1({
259
+ status: "pending-user-decision",
260
+ decisionId: "decision-1",
261
+ }).decisionId,
262
+ ).toBe("decision-1");
263
+ });
264
+
265
+ test("refuses to decode a grant as a decision", () => {
266
+ expect(() =>
267
+ decodeIsolatePendingDecisionV1({
268
+ status: "granted",
269
+ decisionId: "decision-1",
270
+ }),
271
+ ).toThrow(/pending-user-decision/);
272
+ });
273
+ });
274
+
275
+ describe("isolate model invocation v1", () => {
276
+ test("round-trips a stream event line", () => {
277
+ const line = encodeIsolateModelEventLineV1({
278
+ type: "text-delta",
279
+ text: "hi",
280
+ });
281
+ expect(line.endsWith("\n")).toBe(true);
282
+ expect(decodeIsolateModelEventV1(JSON.parse(line))).toEqual({
283
+ type: "text-delta",
284
+ text: "hi",
285
+ });
286
+ });
287
+
288
+ test("decodes a tool-call event", () => {
289
+ expect(
290
+ decodeIsolateModelEventV1({
291
+ type: "tool-call",
292
+ call: { id: "call-1", name: "echo", input: { text: "x" } },
293
+ }),
294
+ ).toEqual({
295
+ type: "tool-call",
296
+ call: { id: "call-1", name: "echo", input: { text: "x" } },
297
+ });
298
+ });
299
+
300
+ test("rejects an unknown event type", () => {
301
+ expect(() => decodeIsolateModelEventV1({ type: "usage" })).toThrow(
302
+ /type is invalid/,
303
+ );
304
+ });
305
+
306
+ test("decodes a streaming outcome carrying a byte stream", () => {
307
+ const events = new ReadableStream<Uint8Array>();
308
+ const outcome = decodeIsolateModelInvocationV1({
309
+ status: "streaming",
310
+ requestId: "request-1",
311
+ events,
312
+ });
313
+ expect(outcome.status).toBe("streaming");
314
+ if (outcome.status === "streaming") expect(outcome.events).toBe(events);
315
+ });
316
+
317
+ test("decodes a pending decision outcome", () => {
318
+ expect(
319
+ decodeIsolateModelInvocationV1({
320
+ status: "pending-user-decision",
321
+ decisionId: "decision-9",
322
+ }),
323
+ ).toEqual({ status: "pending-user-decision", decisionId: "decision-9" });
324
+ });
325
+
326
+ test("rejects a streaming outcome without a stream", () => {
327
+ expect(() =>
328
+ decodeIsolateModelInvocationV1({
329
+ status: "streaming",
330
+ requestId: "request-1",
331
+ events: [],
332
+ }),
333
+ ).toThrow(/must be a readable stream/);
334
+ });
335
+ });
336
+
337
+ describe("isolate capability failure v1", () => {
338
+ test("decodes the declared refusal", () => {
339
+ expect(
340
+ decodeIsolateCapabilityFailureV1({
341
+ status: "unavailable",
342
+ reason: "the model request could not be served",
343
+ }),
344
+ ).toEqual({
345
+ status: "unavailable",
346
+ reason: "the model request could not be served",
347
+ });
348
+ });
349
+
350
+ test("refuses another status, an undeclared field, and an unbounded reason", () => {
351
+ expect(() =>
352
+ decodeIsolateCapabilityFailureV1({ status: "denied", reason: "no" }),
353
+ ).toThrow(/status must be unavailable/);
354
+ expect(() =>
355
+ decodeIsolateCapabilityFailureV1({
356
+ status: "unavailable",
357
+ reason: "no",
358
+ detail: "provider said 401 for key sk-live-1",
359
+ }),
360
+ ).toThrow(/has invalid fields/);
361
+ expect(() =>
362
+ decodeIsolateCapabilityFailureV1({
363
+ status: "unavailable",
364
+ reason: "r".repeat(513),
365
+ }),
366
+ ).toThrow(/reason must be a bounded string/);
367
+ });
368
+ });
369
+
370
+ describe("isolate loader identity", () => {
371
+ test("is the Bot, the User, and the content address — nothing else", () => {
372
+ expect(
373
+ isolateLoaderIdV1({
374
+ userId: "user-1",
375
+ botId: "bot-1",
376
+ artifactSetHash: "a".repeat(64),
377
+ }),
378
+ ).toBe(`bot-package:user-1:bot-1:${"a".repeat(64)}`);
379
+ });
380
+
381
+ test("two Bots of one User never share an id", () => {
382
+ const hash = "b".repeat(64);
383
+ expect(
384
+ isolateLoaderIdV1({
385
+ userId: "user-1",
386
+ botId: "bot-1",
387
+ artifactSetHash: hash,
388
+ }),
389
+ ).not.toBe(
390
+ isolateLoaderIdV1({
391
+ userId: "user-1",
392
+ botId: "bot-2",
393
+ artifactSetHash: hash,
394
+ }),
395
+ );
396
+ });
397
+
398
+ test("rejects a component that could forge another Bot's id", () => {
399
+ expect(() =>
400
+ isolateLoaderIdV1({
401
+ userId: "user-1:bot-2",
402
+ botId: "bot-1",
403
+ artifactSetHash: "c".repeat(64),
404
+ }),
405
+ ).toThrow(/components are invalid/);
406
+ });
407
+
408
+ test("rejects a hash that is not a content address", () => {
409
+ expect(() =>
410
+ isolateLoaderIdV1({
411
+ userId: "user-1",
412
+ botId: "bot-1",
413
+ artifactSetHash: "not-a-hash",
414
+ }),
415
+ ).toThrow(/components are invalid/);
416
+ });
417
+ });