@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,1757 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { compileFoundationApplication } from "@frockbot/application-foundation/runtime";
3
+ import type {
4
+ BotConfigurationCommandV1,
5
+ BotSettingsViewV1,
6
+ UserSettingsViewV1,
7
+ } from "@frockbot/configuration-core";
8
+ import { createShellBotBackendContribution } from "./backend.js";
9
+
10
+ class MemoryStorage {
11
+ readonly values = new Map<string, unknown>();
12
+
13
+ get<T>(key: string): Promise<T | undefined> {
14
+ return Promise.resolve(this.values.get(key) as T | undefined);
15
+ }
16
+
17
+ put(key: string | Record<string, unknown>, value?: unknown): Promise<void> {
18
+ if (typeof key === "string") this.values.set(key, structuredClone(value));
19
+ else {
20
+ for (const [entry, item] of Object.entries(key)) {
21
+ this.values.set(entry, structuredClone(item));
22
+ }
23
+ }
24
+ return Promise.resolve();
25
+ }
26
+
27
+ delete(key: string): Promise<boolean> {
28
+ return Promise.resolve(this.values.delete(key));
29
+ }
30
+
31
+ list<T>(options: { prefix?: string }): Promise<Map<string, T>> {
32
+ return Promise.resolve(
33
+ new Map(
34
+ [...this.values.entries()].filter(([key]) =>
35
+ key.startsWith(options.prefix ?? ""),
36
+ ) as Array<[string, T]>,
37
+ ),
38
+ );
39
+ }
40
+
41
+ transaction<T>(callback: (storage: MemoryStorage) => Promise<T>): Promise<T> {
42
+ return callback(this);
43
+ }
44
+
45
+ setAlarm(): Promise<void> {
46
+ return Promise.resolve();
47
+ }
48
+
49
+ deleteAlarm(): Promise<void> {
50
+ return Promise.resolve();
51
+ }
52
+ }
53
+
54
+ function installedUser(): UserSettingsViewV1 {
55
+ return {
56
+ schemaVersion: 1,
57
+ revision: 1,
58
+ profile: { name: "User" },
59
+ packages: [{ packageId: "composio", version: "0.0.1", state: "installed" }],
60
+ connections: [
61
+ {
62
+ connectionId: "gmail-1",
63
+ packageId: "composio",
64
+ connectionTypeId: "gmail",
65
+ displayName: "Gmail",
66
+ state: "ready",
67
+ providerType: "fixture-models",
68
+ safeMetadata: {},
69
+ },
70
+ ],
71
+ };
72
+ }
73
+
74
+ async function compileAssignmentTestApplication(): ReturnType<
75
+ typeof compileFoundationApplication
76
+ > {
77
+ const application = await compileFoundationApplication();
78
+ const template = application.packages.find((pkg) => pkg.id === "settings");
79
+ if (!template) throw new Error("Settings fixture Package is unavailable");
80
+ return {
81
+ ...application,
82
+ packages: [
83
+ ...application.packages,
84
+ {
85
+ ...template,
86
+ id: "composio",
87
+ specifier: "@test/composio",
88
+ version: "0.0.1",
89
+ manifest: {
90
+ ...template.manifest,
91
+ id: "composio",
92
+ displayName: "Connection fixture",
93
+ version: "0.0.1",
94
+ dependencies: {},
95
+ contributions: {},
96
+ permissions: [],
97
+ configuration: {
98
+ settings: [],
99
+ connectionTypes: [
100
+ {
101
+ id: "gmail",
102
+ displayName: "Gmail",
103
+ allowMultiple: true,
104
+ authorization: { kind: "grant", driverId: "fixture" },
105
+ capabilities: ["gmail-tools", "gmail-send"],
106
+ },
107
+ ],
108
+ capabilities: [
109
+ {
110
+ id: "gmail-tools",
111
+ kind: "model",
112
+ connectionTypes: ["gmail"],
113
+ },
114
+ {
115
+ id: "gmail-send",
116
+ kind: "tool",
117
+ connectionTypes: ["gmail"],
118
+ },
119
+ ],
120
+ },
121
+ },
122
+ },
123
+ ],
124
+ };
125
+ }
126
+
127
+ function assignmentCommand(
128
+ commandId: string,
129
+ assignment: {
130
+ packageId: string;
131
+ capabilityId: string;
132
+ connectionId?: string;
133
+ },
134
+ ): BotConfigurationCommandV1 {
135
+ return {
136
+ schemaVersion: 1,
137
+ type: "bot/assign-capability",
138
+ commandId,
139
+ botId: "primary",
140
+ expectedRevision: 0,
141
+ assignment: {
142
+ assignmentId: commandId,
143
+ ...assignment,
144
+ },
145
+ };
146
+ }
147
+
148
+ describe("Bot capability assignment admission", () => {
149
+ test("rejects an unmaterialized Bot without writing durable state", async () => {
150
+ const storage = new MemoryStorage();
151
+ const contribution = createShellBotBackendContribution({
152
+ state: { storage } as unknown as DurableObjectState,
153
+ env: {} as never,
154
+ });
155
+ await expect(
156
+ contribution.readConfiguration({
157
+ schemaVersion: 1,
158
+ userId: "user-1",
159
+ botId: "unknown",
160
+ }),
161
+ ).rejects.toThrow("not materialized");
162
+ expect(storage.values.size).toBe(0);
163
+ });
164
+
165
+ test("rejects archived settings mutations while preserving configuration reads", async () => {
166
+ const storage = new MemoryStorage();
167
+ let archived = false;
168
+ const contribution = createShellBotBackendContribution({
169
+ state: { storage } as unknown as DurableObjectState,
170
+ env: {} as never,
171
+ assertLifecycleActive: (_transaction, botId) => {
172
+ if (archived)
173
+ return Promise.reject(new Error(`Bot "${botId}" is archived`));
174
+ return Promise.resolve();
175
+ },
176
+ });
177
+ const identity = { userId: "user-1", botId: "primary" };
178
+ await contribution.materializeSettings(identity, { name: "Primary" });
179
+ archived = true;
180
+ await expect(
181
+ contribution.executeConfiguration({
182
+ schemaVersion: 1,
183
+ userId: identity.userId,
184
+ botId: identity.botId,
185
+ command: {
186
+ schemaVersion: 1,
187
+ type: "bot/update-profile",
188
+ commandId: "archived-profile",
189
+ botId: identity.botId,
190
+ expectedRevision: 0,
191
+ profile: { name: "Changed" },
192
+ },
193
+ }),
194
+ ).rejects.toThrow("archived");
195
+ expect(await contribution.getSettings(identity)).toMatchObject({
196
+ revision: 0,
197
+ profile: { name: "Primary" },
198
+ });
199
+ });
200
+
201
+ test("rechecks lifecycle admission in the configuration mutation transaction", async () => {
202
+ const storage = new MemoryStorage();
203
+ let admissions = 0;
204
+ const contribution = createShellBotBackendContribution({
205
+ state: { storage } as unknown as DurableObjectState,
206
+ env: {} as never,
207
+ assertLifecycleActive: (_transaction, botId) => {
208
+ admissions += 1;
209
+ return admissions === 1
210
+ ? Promise.resolve()
211
+ : Promise.reject(new Error(`Bot "${botId}" is archived`));
212
+ },
213
+ });
214
+ const identity = { userId: "user-1", botId: "primary" };
215
+ await contribution.materializeSettings(identity, { name: "Primary" });
216
+ await expect(
217
+ contribution.executeConfiguration({
218
+ schemaVersion: 1,
219
+ userId: identity.userId,
220
+ botId: identity.botId,
221
+ command: {
222
+ schemaVersion: 1,
223
+ type: "bot/update-profile",
224
+ commandId: "racing-profile",
225
+ botId: identity.botId,
226
+ expectedRevision: 0,
227
+ profile: { name: "Changed" },
228
+ },
229
+ }),
230
+ ).rejects.toThrow("archived");
231
+ expect(await contribution.getSettings(identity)).toMatchObject({
232
+ revision: 0,
233
+ profile: { name: "Primary" },
234
+ });
235
+ });
236
+
237
+ test("validates notification authority without changing settings", async () => {
238
+ const storage = new MemoryStorage();
239
+ const settings = {
240
+ schemaVersion: 1,
241
+ botId: "primary",
242
+ revision: 7,
243
+ profile: { name: "Primary" },
244
+ notifications: { enabled: true },
245
+ assignments: [],
246
+ assignmentOperations: [],
247
+ } satisfies BotSettingsViewV1;
248
+ await storage.put({
249
+ identity: { userId: "user-1", botId: "primary" },
250
+ "bot-configuration": settings,
251
+ });
252
+ const contribution = createShellBotBackendContribution({
253
+ state: { storage } as unknown as DurableObjectState,
254
+ env: {} as never,
255
+ });
256
+
257
+ await expect(
258
+ contribution.validateIdentity({ userId: "user-1", botId: "primary" }),
259
+ ).resolves.toBeUndefined();
260
+ await expect(
261
+ contribution.validateIdentity({ userId: "other", botId: "primary" }),
262
+ ).rejects.toThrow("Bot authority does not match its durable identity");
263
+ await expect(
264
+ contribution.getSettings({ userId: "other", botId: "primary" }),
265
+ ).rejects.toThrow("Bot authority does not match its durable identity");
266
+ expect(await storage.get<BotSettingsViewV1>("bot-configuration")).toEqual(
267
+ settings,
268
+ );
269
+ });
270
+
271
+ test("initializes current Bot settings without historical-state branching", async () => {
272
+ const storage = new MemoryStorage();
273
+ await storage.put({
274
+ identity: { userId: "user-1", botId: "primary" },
275
+ "latest-events": [{ type: "user", text: "existing history" }],
276
+ "active-run": "run-1",
277
+ "run:run-1": { status: "completed" },
278
+ });
279
+ const contribution = createShellBotBackendContribution({
280
+ state: { storage } as unknown as DurableObjectState,
281
+ env: {
282
+ USER_CONFIGURATIONS: {
283
+ idFromName: () => "user-1",
284
+ get: () => ({
285
+ readConfiguration: () =>
286
+ Promise.resolve({
287
+ ...installedUser(),
288
+ newBotModelTemplate: {
289
+ connectionId: "provider-1",
290
+ providerModelId: "model-1",
291
+ },
292
+ newBotModelTemplateSource: "auto",
293
+ }),
294
+ }),
295
+ },
296
+ } as never,
297
+ });
298
+
299
+ await contribution.materializeSettings(
300
+ { userId: "user-1", botId: "primary" },
301
+ {
302
+ name: "Primary",
303
+ model: { connectionId: "provider-1", providerModelId: "model-1" },
304
+ },
305
+ );
306
+ await expect(
307
+ contribution.readConfiguration({
308
+ schemaVersion: 1,
309
+ userId: "user-1",
310
+ botId: "primary",
311
+ }),
312
+ ).resolves.toMatchObject({
313
+ model: {
314
+ connectionId: "provider-1",
315
+ providerModelId: "model-1",
316
+ },
317
+ });
318
+ });
319
+
320
+ test("binds in-flight and durable receipts to the complete Bot command", async () => {
321
+ const storage = new MemoryStorage();
322
+ const userConfiguration = {
323
+ readConfiguration: () => Promise.resolve(installedUser()),
324
+ };
325
+ const host = {
326
+ state: { storage } as unknown as DurableObjectState,
327
+ env: {
328
+ USER_CONFIGURATIONS: {
329
+ idFromName: () => "user-1",
330
+ get: () => userConfiguration,
331
+ },
332
+ } as never,
333
+ };
334
+ const original: BotConfigurationCommandV1 = {
335
+ schemaVersion: 1,
336
+ type: "bot/update-profile",
337
+ commandId: "profile-command",
338
+ botId: "primary",
339
+ expectedRevision: 0,
340
+ profile: { name: "Original" },
341
+ };
342
+ const collision: BotConfigurationCommandV1 = {
343
+ ...original,
344
+ profile: { name: "Collision" },
345
+ };
346
+ const request = (command: BotConfigurationCommandV1) => ({
347
+ schemaVersion: 1 as const,
348
+ userId: "user-1",
349
+ botId: "primary",
350
+ command,
351
+ });
352
+ const contribution = createShellBotBackendContribution(host);
353
+ await contribution.materializeSettings(
354
+ { userId: "user-1", botId: "primary" },
355
+ { name: "Primary" },
356
+ );
357
+
358
+ const first = contribution.executeConfiguration(request(original));
359
+ await expect(
360
+ contribution.executeConfiguration(request(collision)),
361
+ ).rejects.toThrow(
362
+ 'Configuration command idempotency key "profile-command" was reused for a different command',
363
+ );
364
+ const receipt = await first;
365
+
366
+ const redeployed = createShellBotBackendContribution(host);
367
+ await expect(
368
+ redeployed.executeConfiguration(request(original)),
369
+ ).resolves.toEqual(receipt);
370
+ await expect(
371
+ redeployed.executeConfiguration(request(collision)),
372
+ ).rejects.toThrow(
373
+ 'Configuration command idempotency key "profile-command" was reused for a different command',
374
+ );
375
+ await expect(
376
+ redeployed.readConfiguration({
377
+ schemaVersion: 1,
378
+ userId: "user-1",
379
+ botId: "primary",
380
+ }),
381
+ ).resolves.toMatchObject({
382
+ revision: 1,
383
+ profile: { name: "Original" },
384
+ });
385
+ });
386
+
387
+ test("durably rejects invalid assignments before dependency claims", async () => {
388
+ const storage = new MemoryStorage();
389
+ let user = installedUser();
390
+ let dependencyClaims = 0;
391
+ let dependencyAcknowledgements = 0;
392
+ let reads = 0;
393
+ let claimAuthorized = true;
394
+ const dependencyStates = new Map<string, "claimed" | "acknowledged">();
395
+ const userConfiguration = {
396
+ readConfiguration: () => {
397
+ reads += 1;
398
+ return Promise.resolve(structuredClone(user));
399
+ },
400
+ executeConnectionDependency: (request: {
401
+ action: "claim" | "read" | "acknowledge" | "release" | "reconcile";
402
+ operationId: string;
403
+ requirement?: unknown;
404
+ }) => {
405
+ if (request.action === "read") {
406
+ return Promise.resolve({
407
+ schemaVersion: 1 as const,
408
+ status:
409
+ dependencyStates.get(request.operationId) ?? ("absent" as const),
410
+ });
411
+ }
412
+ if (request.action === "claim") {
413
+ expect(request.requirement).toEqual({
414
+ schemaVersion: 1,
415
+ packageId: "composio",
416
+ packageVersion: "0.0.1",
417
+ capabilityId: "gmail-tools",
418
+ connectionTypeIds: ["gmail"],
419
+ });
420
+ dependencyClaims += 1;
421
+ if (claimAuthorized) {
422
+ dependencyStates.set(request.operationId, "claimed");
423
+ }
424
+ return Promise.resolve(
425
+ claimAuthorized
426
+ ? { schemaVersion: 1 as const, status: "claimed" as const }
427
+ : {
428
+ schemaVersion: 1 as const,
429
+ status: "rejected" as const,
430
+ failure: "claim rejected",
431
+ },
432
+ );
433
+ }
434
+ if (request.action === "acknowledge") {
435
+ dependencyAcknowledgements += 1;
436
+ dependencyStates.set(request.operationId, "acknowledged");
437
+ return Promise.resolve({
438
+ schemaVersion: 1 as const,
439
+ status: "acknowledged" as const,
440
+ });
441
+ }
442
+ return Promise.resolve({
443
+ schemaVersion: 1 as const,
444
+ status: "released" as const,
445
+ });
446
+ },
447
+ };
448
+ const contribution = createShellBotBackendContribution({
449
+ state: { storage } as unknown as DurableObjectState,
450
+ env: {
451
+ USER_CONFIGURATIONS: {
452
+ idFromName: () => "user-1",
453
+ get: () => userConfiguration,
454
+ },
455
+ } as never,
456
+ compileApplication: compileAssignmentTestApplication,
457
+ });
458
+ const identity = { userId: "user-1", botId: "primary" };
459
+ await contribution.materializeSettings(identity, { name: "Primary" });
460
+ const execute = (command: BotConfigurationCommandV1) =>
461
+ contribution.executeConfiguration({
462
+ schemaVersion: 1,
463
+ ...identity,
464
+ command,
465
+ });
466
+ const read = () =>
467
+ contribution.readConfiguration({ schemaVersion: 1, ...identity });
468
+
469
+ await expect(
470
+ contribution.readConfiguration({
471
+ schemaVersion: 1,
472
+ userId: "user-1",
473
+ botId: "../primary",
474
+ }),
475
+ ).rejects.toThrow("botId is invalid");
476
+ await expect(
477
+ contribution.executeConfiguration({
478
+ schemaVersion: 1,
479
+ ...identity,
480
+ command: {
481
+ schemaVersion: 1,
482
+ type: "bot/update-profile",
483
+ commandId: "malformed-profile",
484
+ botId: "primary",
485
+ expectedRevision: 0,
486
+ profile: { name: 42 },
487
+ },
488
+ }),
489
+ ).rejects.toThrow("profile.name must be a string");
490
+ expect(await read()).toMatchObject({ revision: 0, assignments: [] });
491
+
492
+ const missingConnection = assignmentCommand("missing-connection", {
493
+ packageId: "composio",
494
+ capabilityId: "gmail-tools",
495
+ });
496
+ const first = await execute(missingConnection);
497
+ expect(first).toMatchObject({
498
+ status: "rejected",
499
+ revision: 0,
500
+ failure: expect.stringContaining("requires a Connection"),
501
+ });
502
+ const readsAfterFirst = reads;
503
+ expect(await execute(missingConnection)).toEqual(first);
504
+ expect(reads).toBe(readsAfterFirst);
505
+
506
+ expect(
507
+ await execute(
508
+ assignmentCommand("unknown-capability", {
509
+ packageId: "composio",
510
+ capabilityId: "unknown",
511
+ connectionId: "gmail-1",
512
+ }),
513
+ ),
514
+ ).toMatchObject({ status: "rejected", revision: 0 });
515
+
516
+ user = {
517
+ ...installedUser(),
518
+ connections: [
519
+ { ...installedUser().connections[0]!, connectionTypeId: "calendar" },
520
+ ],
521
+ };
522
+ expect(
523
+ await execute(
524
+ assignmentCommand("wrong-connection-type", {
525
+ packageId: "composio",
526
+ capabilityId: "gmail-tools",
527
+ connectionId: "gmail-1",
528
+ }),
529
+ ),
530
+ ).toMatchObject({ status: "rejected", revision: 0 });
531
+
532
+ user = {
533
+ ...installedUser(),
534
+ packages: [{ ...installedUser().packages[0]!, state: "disabled" }],
535
+ };
536
+ expect(
537
+ await execute(
538
+ assignmentCommand("disabled-package", {
539
+ packageId: "composio",
540
+ capabilityId: "gmail-tools",
541
+ connectionId: "gmail-1",
542
+ }),
543
+ ),
544
+ ).toMatchObject({ status: "rejected", revision: 0 });
545
+
546
+ expect(dependencyClaims).toBe(0);
547
+ expect(await read()).toMatchObject({
548
+ revision: 0,
549
+ assignments: [],
550
+ } satisfies Partial<BotSettingsViewV1>);
551
+
552
+ user = installedUser();
553
+ claimAuthorized = false;
554
+ const changedDuringClaim = assignmentCommand("changed-during-claim", {
555
+ packageId: "composio",
556
+ capabilityId: "gmail-tools",
557
+ connectionId: "gmail-1",
558
+ });
559
+ const changedReceipt = await execute(changedDuringClaim);
560
+ expect(changedReceipt).toMatchObject({ status: "rejected", revision: 0 });
561
+ expect(await execute(changedDuringClaim)).toEqual(changedReceipt);
562
+ expect(dependencyClaims).toBe(1);
563
+ expect(dependencyAcknowledgements).toBe(0);
564
+ expect(await read()).toMatchObject({
565
+ revision: 0,
566
+ assignments: [],
567
+ });
568
+
569
+ claimAuthorized = true;
570
+ expect(
571
+ await execute(
572
+ assignmentCommand("valid-assignment", {
573
+ packageId: "composio",
574
+ capabilityId: "gmail-tools",
575
+ connectionId: "gmail-1",
576
+ }),
577
+ ),
578
+ ).toMatchObject({ status: "applied", revision: 1 });
579
+ expect(dependencyClaims).toBe(2);
580
+ expect(dependencyAcknowledgements).toBe(1);
581
+ expect(await read()).toMatchObject({
582
+ revision: 1,
583
+ assignments: [
584
+ {
585
+ assignmentId: "valid-assignment",
586
+ state: "enabled",
587
+ connectionId: "gmail-1",
588
+ },
589
+ ],
590
+ });
591
+
592
+ await expect(
593
+ execute({
594
+ schemaVersion: 1,
595
+ type: "bot/assign-capability",
596
+ commandId: "reuse-assignment-authority",
597
+ botId: "primary",
598
+ expectedRevision: 1,
599
+ assignment: {
600
+ assignmentId: "valid-assignment",
601
+ packageId: "composio",
602
+ capabilityId: "gmail-send",
603
+ connectionId: "gmail-1",
604
+ },
605
+ }),
606
+ ).resolves.toMatchObject({
607
+ status: "rejected",
608
+ revision: 1,
609
+ failure: "Assignment ID cannot change Package Capability authority",
610
+ });
611
+ expect(dependencyClaims).toBe(2);
612
+ });
613
+
614
+ test("atomically binds and durably unbinds a Connection model", async () => {
615
+ const storage = new MemoryStorage();
616
+ let dependencyGeneration: string | undefined;
617
+ let releaseAttempts = 0;
618
+ const userConfiguration = {
619
+ readConfiguration: () => Promise.resolve(installedUser()),
620
+ executeConnectionDependency: (request: {
621
+ action: "claim" | "read" | "acknowledge" | "release" | "reconcile";
622
+ generation: string;
623
+ }) => {
624
+ if (request.action === "claim") {
625
+ dependencyGeneration = request.generation;
626
+ return Promise.resolve({
627
+ schemaVersion: 1 as const,
628
+ status: "claimed" as const,
629
+ });
630
+ }
631
+ if (request.action === "acknowledge") {
632
+ return Promise.resolve({
633
+ schemaVersion: 1 as const,
634
+ status: "acknowledged" as const,
635
+ });
636
+ }
637
+ if (request.action === "read") {
638
+ return Promise.resolve({
639
+ schemaVersion: 1 as const,
640
+ status:
641
+ request.generation === dependencyGeneration
642
+ ? ("acknowledged" as const)
643
+ : ("absent" as const),
644
+ });
645
+ }
646
+ if (request.action === "release") {
647
+ releaseAttempts += 1;
648
+ if (releaseAttempts === 1) {
649
+ return Promise.resolve({
650
+ schemaVersion: 1 as const,
651
+ status: "pending" as const,
652
+ });
653
+ }
654
+ if (request.generation === dependencyGeneration) {
655
+ dependencyGeneration = undefined;
656
+ }
657
+ return Promise.resolve({
658
+ schemaVersion: 1 as const,
659
+ status: "released" as const,
660
+ });
661
+ }
662
+ return Promise.resolve({
663
+ schemaVersion: 1 as const,
664
+ status: "released" as const,
665
+ });
666
+ },
667
+ };
668
+ const contribution = createShellBotBackendContribution({
669
+ state: { storage } as unknown as DurableObjectState,
670
+ env: {
671
+ USER_CONFIGURATIONS: {
672
+ idFromName: () => "user-1",
673
+ get: () => userConfiguration,
674
+ },
675
+ } as never,
676
+ compileApplication: compileAssignmentTestApplication,
677
+ });
678
+ const identity = { userId: "user-1", botId: "primary" };
679
+ await contribution.materializeSettings(identity, { name: "Primary" });
680
+ const execute = (command: BotConfigurationCommandV1) =>
681
+ contribution.executeConfiguration({
682
+ schemaVersion: 1,
683
+ ...identity,
684
+ command,
685
+ });
686
+
687
+ const bound = await execute({
688
+ schemaVersion: 1,
689
+ type: "bot/assign-capability",
690
+ commandId: "bind-model",
691
+ botId: "primary",
692
+ expectedRevision: 0,
693
+ assignment: {
694
+ assignmentId: "fixture-model",
695
+ packageId: "composio",
696
+ capabilityId: "gmail-tools",
697
+ connectionId: "gmail-1",
698
+ },
699
+ model: {
700
+ connectionId: "gmail-1",
701
+ providerModelId: "fixture-model:latest",
702
+ },
703
+ });
704
+
705
+ expect(bound).toMatchObject({ status: "applied", revision: 1 });
706
+ expect(dependencyGeneration).toBe("bind-model");
707
+ expect(await contribution.getSettings(identity)).toMatchObject({
708
+ revision: 1,
709
+ model: {
710
+ connectionId: "gmail-1",
711
+ providerModelId: "fixture-model:latest",
712
+ },
713
+ assignments: [{ assignmentId: "fixture-model", state: "enabled" }],
714
+ });
715
+
716
+ const unavailable = await contribution.getSettings(identity);
717
+ await storage.put("bot-configuration", {
718
+ ...unavailable,
719
+ assignments: unavailable.assignments.map((assignment) => ({
720
+ ...assignment,
721
+ state: "unavailable" as const,
722
+ })),
723
+ });
724
+
725
+ const unbind: BotConfigurationCommandV1 = {
726
+ schemaVersion: 1,
727
+ type: "bot/unbind-model",
728
+ commandId: "unbind-model",
729
+ botId: "primary",
730
+ expectedRevision: 1,
731
+ assignmentId: "fixture-model",
732
+ };
733
+ // The release is delayed once: the Unassign stays visibly retrying and its
734
+ // durable receipt replays until the dependency is actually released.
735
+ await expect(execute(unbind)).resolves.toMatchObject({
736
+ status: "pending",
737
+ });
738
+ expect(dependencyGeneration).toBe("bind-model");
739
+ await contribution.alarm();
740
+
741
+ const unbound = await execute(unbind);
742
+
743
+ expect(unbound).toMatchObject({ status: "applied", revision: 2 });
744
+ expect(releaseAttempts).toBe(2);
745
+ expect(dependencyGeneration).toBeUndefined();
746
+ expect(await contribution.getSettings(identity)).toMatchObject({
747
+ revision: 2,
748
+ model: undefined,
749
+ assignments: [],
750
+ });
751
+ });
752
+
753
+ test("releases the superseded model dependency after switching Connections", async () => {
754
+ const storage = new MemoryStorage();
755
+ const user = installedUser();
756
+ user.connections.push({
757
+ ...user.connections[0]!,
758
+ connectionId: "gmail-2",
759
+ displayName: "Gmail 2",
760
+ });
761
+ const generations = new Set<string>();
762
+ const released: Array<{ connectionId: string; generation: string }> = [];
763
+ let releaseAttempts = 0;
764
+ const userConfiguration = {
765
+ readConfiguration: () => Promise.resolve(user),
766
+ executeConnectionDependency: (request: {
767
+ action: "claim" | "read" | "acknowledge" | "release" | "reconcile";
768
+ connectionId: string;
769
+ generation: string;
770
+ }) => {
771
+ if (request.action === "claim") {
772
+ generations.add(request.generation);
773
+ return Promise.resolve({
774
+ schemaVersion: 1 as const,
775
+ status: "claimed" as const,
776
+ });
777
+ }
778
+ if (request.action === "acknowledge") {
779
+ return Promise.resolve({
780
+ schemaVersion: 1 as const,
781
+ status: "acknowledged" as const,
782
+ });
783
+ }
784
+ if (request.action === "read") {
785
+ return Promise.resolve({
786
+ schemaVersion: 1 as const,
787
+ status: generations.has(request.generation)
788
+ ? ("acknowledged" as const)
789
+ : ("absent" as const),
790
+ });
791
+ }
792
+ if (request.action === "release") {
793
+ releaseAttempts += 1;
794
+ if (releaseAttempts === 1) {
795
+ return Promise.resolve({
796
+ schemaVersion: 1 as const,
797
+ status: "pending" as const,
798
+ });
799
+ }
800
+ released.push({
801
+ connectionId: request.connectionId,
802
+ generation: request.generation,
803
+ });
804
+ generations.delete(request.generation);
805
+ return Promise.resolve({
806
+ schemaVersion: 1 as const,
807
+ status: "released" as const,
808
+ });
809
+ }
810
+ return Promise.resolve({
811
+ schemaVersion: 1 as const,
812
+ status: "released" as const,
813
+ });
814
+ },
815
+ };
816
+ const contribution = createShellBotBackendContribution({
817
+ state: { storage } as unknown as DurableObjectState,
818
+ env: {
819
+ USER_CONFIGURATIONS: {
820
+ idFromName: () => "user-1",
821
+ get: () => userConfiguration,
822
+ },
823
+ } as never,
824
+ compileApplication: compileAssignmentTestApplication,
825
+ });
826
+ const identity = { userId: "user-1", botId: "primary" };
827
+ await contribution.materializeSettings(identity, { name: "Primary" });
828
+ const execute = (command: BotConfigurationCommandV1) =>
829
+ contribution.executeConfiguration({
830
+ schemaVersion: 1,
831
+ ...identity,
832
+ command,
833
+ });
834
+
835
+ await execute({
836
+ schemaVersion: 1,
837
+ type: "bot/assign-capability",
838
+ commandId: "bind-gmail-1",
839
+ botId: "primary",
840
+ expectedRevision: 0,
841
+ assignment: {
842
+ assignmentId: "gmail-model-1",
843
+ packageId: "composio",
844
+ capabilityId: "gmail-tools",
845
+ connectionId: "gmail-1",
846
+ },
847
+ model: {
848
+ connectionId: "gmail-1",
849
+ providerModelId: "fixture-model:latest",
850
+ },
851
+ });
852
+ const beforeSwitch = await contribution.getSettings(identity);
853
+ await storage.put({
854
+ "bot-configuration": {
855
+ ...beforeSwitch,
856
+ assignments: [
857
+ ...beforeSwitch.assignments,
858
+ {
859
+ assignmentId: "gmail-tool",
860
+ packageId: "composio",
861
+ capabilityId: "gmail-send",
862
+ connectionId: "gmail-1",
863
+ state: "enabled" as const,
864
+ },
865
+ ],
866
+ },
867
+ "assignment-generation:gmail-tool": "tool-generation",
868
+ });
869
+ generations.add("tool-generation");
870
+
871
+ // Moving the model to another Connection is an atomic Replace on the same
872
+ // Assignment: it claims the new dependency, commits the swap, then
873
+ // releases the old one. A delayed release stays visibly retrying rather
874
+ // than failing the command.
875
+ const switchModel: BotConfigurationCommandV1 = {
876
+ schemaVersion: 1,
877
+ type: "bot/replace-capability",
878
+ commandId: "bind-gmail-2",
879
+ botId: "primary",
880
+ expectedRevision: 1,
881
+ assignment: {
882
+ assignmentId: "gmail-model-1",
883
+ packageId: "composio",
884
+ capabilityId: "gmail-tools",
885
+ connectionId: "gmail-2",
886
+ },
887
+ model: {
888
+ connectionId: "gmail-2",
889
+ providerModelId: "fixture-model:latest",
890
+ },
891
+ };
892
+ await expect(execute(switchModel)).resolves.toMatchObject({
893
+ status: "applied",
894
+ revision: 2,
895
+ });
896
+ // The commit is durable; only the old release is still retrying.
897
+ await contribution.alarm();
898
+ await expect(execute(switchModel)).resolves.toMatchObject({
899
+ status: "applied",
900
+ revision: 2,
901
+ });
902
+
903
+ expect(releaseAttempts).toBe(2);
904
+ expect(released).toEqual([
905
+ { connectionId: "gmail-1", generation: "bind-gmail-1" },
906
+ ]);
907
+ expect(generations.has("bind-gmail-1")).toBe(false);
908
+ expect(generations.has("tool-generation")).toBe(true);
909
+ expect(generations.has("bind-gmail-2")).toBe(true);
910
+ expect(await contribution.getSettings(identity)).toMatchObject({
911
+ revision: 2,
912
+ model: { connectionId: "gmail-2" },
913
+ assignments: [
914
+ { assignmentId: "gmail-tool", state: "enabled" },
915
+ { assignmentId: "gmail-model-1", state: "enabled" },
916
+ ],
917
+ });
918
+
919
+ // Each further move is another Replace on the same Assignment, so each
920
+ // one releases exactly the Connection it superseded.
921
+ for (const [commandId, connectionId, expectedRevision] of [
922
+ ["bind-gmail-1-again", "gmail-1", 2],
923
+ ["bind-gmail-2-again", "gmail-2", 3],
924
+ ] as const) {
925
+ await expect(
926
+ execute({
927
+ schemaVersion: 1,
928
+ type: "bot/replace-capability",
929
+ commandId,
930
+ botId: "primary",
931
+ expectedRevision,
932
+ assignment: {
933
+ assignmentId: "gmail-model-1",
934
+ packageId: "composio",
935
+ capabilityId: "gmail-tools",
936
+ connectionId,
937
+ },
938
+ model: {
939
+ connectionId,
940
+ providerModelId: "fixture-model:latest",
941
+ },
942
+ }),
943
+ ).resolves.toMatchObject({
944
+ status: "applied",
945
+ revision: expectedRevision + 1,
946
+ });
947
+ }
948
+
949
+ expect(released.slice(-2)).toEqual([
950
+ { connectionId: "gmail-2", generation: "bind-gmail-2" },
951
+ { connectionId: "gmail-1", generation: "bind-gmail-1-again" },
952
+ ]);
953
+ expect(generations.has("bind-gmail-1")).toBe(false);
954
+ expect(generations.has("bind-gmail-2")).toBe(false);
955
+ expect(generations.has("bind-gmail-1-again")).toBe(false);
956
+ expect(generations.has("tool-generation")).toBe(true);
957
+ expect(await contribution.getSettings(identity)).toMatchObject({
958
+ revision: 4,
959
+ model: { connectionId: "gmail-2" },
960
+ assignments: [
961
+ { assignmentId: "gmail-tool", state: "enabled" },
962
+ { assignmentId: "gmail-model-1", state: "enabled" },
963
+ ],
964
+ });
965
+ });
966
+
967
+ test("orders atomic Replace and keeps Unassign stable until release", async () => {
968
+ const storage = new MemoryStorage();
969
+ const user = installedUser();
970
+ user.connections.push({
971
+ ...user.connections[0]!,
972
+ connectionId: "gmail-2",
973
+ displayName: "Gmail replacement",
974
+ });
975
+ const dependencies = new Map<
976
+ string,
977
+ "claimed" | "acknowledged" | "released"
978
+ >();
979
+ const log: string[] = [];
980
+ let holdRelease = false;
981
+ const userConfiguration = {
982
+ readConfiguration: () => Promise.resolve(structuredClone(user)),
983
+ executeConnectionDependency: (request: {
984
+ action: "claim" | "read" | "acknowledge" | "release" | "reconcile";
985
+ generation: string;
986
+ }) => {
987
+ log.push(`${request.action}:${request.generation}`);
988
+ if (request.action === "read") {
989
+ return Promise.resolve({
990
+ schemaVersion: 1 as const,
991
+ status: dependencies.get(request.generation) ?? ("absent" as const),
992
+ });
993
+ }
994
+ if (request.action === "claim") {
995
+ dependencies.set(request.generation, "claimed");
996
+ return Promise.resolve({
997
+ schemaVersion: 1 as const,
998
+ status: "claimed" as const,
999
+ });
1000
+ }
1001
+ if (request.action === "acknowledge") {
1002
+ dependencies.set(request.generation, "acknowledged");
1003
+ return Promise.resolve({
1004
+ schemaVersion: 1 as const,
1005
+ status: "acknowledged" as const,
1006
+ });
1007
+ }
1008
+ if (request.action === "release" && holdRelease) {
1009
+ return Promise.resolve({
1010
+ schemaVersion: 1 as const,
1011
+ status: "pending" as const,
1012
+ });
1013
+ }
1014
+ if (request.action === "release")
1015
+ dependencies.set(request.generation, "released");
1016
+ return Promise.resolve({
1017
+ schemaVersion: 1 as const,
1018
+ status:
1019
+ request.action === "reconcile"
1020
+ ? ("pending" as const)
1021
+ : ("released" as const),
1022
+ });
1023
+ },
1024
+ };
1025
+ const contribution = createShellBotBackendContribution({
1026
+ state: { storage } as unknown as DurableObjectState,
1027
+ env: {
1028
+ USER_CONFIGURATIONS: {
1029
+ idFromName: () => "user-1",
1030
+ get: () => userConfiguration,
1031
+ },
1032
+ } as never,
1033
+ compileApplication: compileAssignmentTestApplication,
1034
+ });
1035
+ const identity = { userId: "user-1", botId: "primary" };
1036
+ await contribution.materializeSettings(identity, { name: "Primary" });
1037
+ const execute = (command: BotConfigurationCommandV1) =>
1038
+ contribution.executeConfiguration({
1039
+ schemaVersion: 1,
1040
+ ...identity,
1041
+ command,
1042
+ });
1043
+
1044
+ await expect(
1045
+ execute({
1046
+ schemaVersion: 1,
1047
+ type: "bot/assign-capability",
1048
+ commandId: "assign-1",
1049
+ botId: "primary",
1050
+ expectedRevision: 0,
1051
+ assignment: {
1052
+ assignmentId: "mail",
1053
+ packageId: "composio",
1054
+ capabilityId: "gmail-tools",
1055
+ connectionId: "gmail-1",
1056
+ },
1057
+ }),
1058
+ ).resolves.toMatchObject({ status: "applied", revision: 1 });
1059
+ log.length = 0;
1060
+
1061
+ await expect(
1062
+ execute({
1063
+ schemaVersion: 1,
1064
+ type: "bot/replace-capability",
1065
+ commandId: "replace-1",
1066
+ botId: "primary",
1067
+ expectedRevision: 1,
1068
+ assignment: {
1069
+ assignmentId: "mail",
1070
+ packageId: "composio",
1071
+ capabilityId: "gmail-tools",
1072
+ connectionId: "gmail-2",
1073
+ },
1074
+ }),
1075
+ ).resolves.toMatchObject({ status: "applied", revision: 2 });
1076
+ expect(log).toEqual([
1077
+ "read:replace-1",
1078
+ "claim:replace-1",
1079
+ "read:replace-1",
1080
+ "acknowledge:replace-1",
1081
+ "read:assign-1",
1082
+ "release:assign-1",
1083
+ ]);
1084
+ expect(await contribution.getSettings(identity)).toMatchObject({
1085
+ revision: 2,
1086
+ assignments: [{ assignmentId: "mail", connectionId: "gmail-2" }],
1087
+ assignmentOperations: [],
1088
+ });
1089
+
1090
+ holdRelease = true;
1091
+ const pendingUnassign = await execute({
1092
+ schemaVersion: 1,
1093
+ type: "bot/unassign-capability",
1094
+ commandId: "unassign-1",
1095
+ botId: "primary",
1096
+ expectedRevision: 2,
1097
+ assignmentId: "mail",
1098
+ });
1099
+ expect(pendingUnassign).toEqual({
1100
+ schemaVersion: 1,
1101
+ commandId: "unassign-1",
1102
+ revision: 2,
1103
+ status: "pending",
1104
+ });
1105
+ await expect(
1106
+ execute({
1107
+ schemaVersion: 1,
1108
+ type: "bot/unassign-capability",
1109
+ commandId: "unassign-1",
1110
+ botId: "primary",
1111
+ expectedRevision: 2,
1112
+ assignmentId: "mail",
1113
+ }),
1114
+ ).resolves.toEqual(pendingUnassign);
1115
+ expect(await contribution.getSettings(identity)).toMatchObject({
1116
+ revision: 2,
1117
+ assignments: [{ assignmentId: "mail", connectionId: "gmail-2" }],
1118
+ assignmentOperations: [
1119
+ { commandId: "unassign-1", kind: "unassigning", state: "retrying" },
1120
+ ],
1121
+ });
1122
+
1123
+ holdRelease = false;
1124
+ const reconstructed = createShellBotBackendContribution({
1125
+ state: { storage } as unknown as DurableObjectState,
1126
+ env: {
1127
+ USER_CONFIGURATIONS: {
1128
+ idFromName: () => "user-1",
1129
+ get: () => userConfiguration,
1130
+ },
1131
+ } as never,
1132
+ compileApplication: compileAssignmentTestApplication,
1133
+ });
1134
+ await reconstructed.alarm();
1135
+ expect(await reconstructed.getSettings(identity)).toMatchObject({
1136
+ revision: 3,
1137
+ assignments: [],
1138
+ assignmentOperations: [],
1139
+ });
1140
+ await expect(
1141
+ reconstructed.executeConfiguration({
1142
+ schemaVersion: 1,
1143
+ ...identity,
1144
+ command: {
1145
+ schemaVersion: 1,
1146
+ type: "bot/unassign-capability",
1147
+ commandId: "unassign-1",
1148
+ botId: "primary",
1149
+ expectedRevision: 2,
1150
+ assignmentId: "mail",
1151
+ },
1152
+ }),
1153
+ ).resolves.toMatchObject({ status: "applied", revision: 3 });
1154
+ await expect(
1155
+ reconstructed.executeConfiguration({
1156
+ schemaVersion: 1,
1157
+ ...identity,
1158
+ command: {
1159
+ schemaVersion: 1,
1160
+ type: "bot/unassign-capability",
1161
+ commandId: "unassign-1",
1162
+ botId: "primary",
1163
+ expectedRevision: 2,
1164
+ assignmentId: "other",
1165
+ },
1166
+ }),
1167
+ ).rejects.toThrow("reused for a different command");
1168
+ });
1169
+
1170
+ test("releases the old Replace dependency when new acknowledgement is absent or rejected", async () => {
1171
+ for (const acknowledgement of ["absent", "rejected"] as const) {
1172
+ const storage = new MemoryStorage();
1173
+ const user = installedUser();
1174
+ user.connections.push({
1175
+ ...user.connections[0]!,
1176
+ connectionId: "gmail-2",
1177
+ displayName: "Gmail replacement",
1178
+ });
1179
+ const dependencies = new Map<
1180
+ string,
1181
+ "claimed" | "acknowledged" | "released"
1182
+ >();
1183
+ const log: string[] = [];
1184
+ let replacementReads = 0;
1185
+ let holdOldRelease = true;
1186
+ const userConfiguration = {
1187
+ readConfiguration: () => Promise.resolve(structuredClone(user)),
1188
+ executeConnectionDependency: (request: {
1189
+ action: "claim" | "read" | "acknowledge" | "release" | "reconcile";
1190
+ generation: string;
1191
+ }) => {
1192
+ log.push(`${request.action}:${request.generation}`);
1193
+ if (request.action === "read") {
1194
+ if (request.generation.startsWith("replace-")) {
1195
+ replacementReads += 1;
1196
+ if (replacementReads > 1 && acknowledgement === "absent") {
1197
+ return Promise.resolve({
1198
+ schemaVersion: 1 as const,
1199
+ status: "absent" as const,
1200
+ });
1201
+ }
1202
+ }
1203
+ return Promise.resolve({
1204
+ schemaVersion: 1 as const,
1205
+ status:
1206
+ dependencies.get(request.generation) ?? ("absent" as const),
1207
+ });
1208
+ }
1209
+ if (request.action === "claim") {
1210
+ dependencies.set(request.generation, "claimed");
1211
+ return Promise.resolve({
1212
+ schemaVersion: 1 as const,
1213
+ status: "claimed" as const,
1214
+ });
1215
+ }
1216
+ if (request.action === "acknowledge") {
1217
+ if (
1218
+ request.generation.startsWith("replace-") &&
1219
+ acknowledgement === "rejected"
1220
+ ) {
1221
+ return Promise.resolve({
1222
+ schemaVersion: 1 as const,
1223
+ status: "rejected" as const,
1224
+ failure: "acknowledgement rejected",
1225
+ });
1226
+ }
1227
+ dependencies.set(request.generation, "acknowledged");
1228
+ return Promise.resolve({
1229
+ schemaVersion: 1 as const,
1230
+ status: "acknowledged" as const,
1231
+ });
1232
+ }
1233
+ if (request.action === "release") {
1234
+ if (request.generation === "assign-old" && holdOldRelease) {
1235
+ return Promise.resolve({
1236
+ schemaVersion: 1 as const,
1237
+ status: "pending" as const,
1238
+ });
1239
+ }
1240
+ dependencies.set(request.generation, "released");
1241
+ return Promise.resolve({
1242
+ schemaVersion: 1 as const,
1243
+ status: "released" as const,
1244
+ });
1245
+ }
1246
+ return Promise.resolve({
1247
+ schemaVersion: 1 as const,
1248
+ status: "pending" as const,
1249
+ });
1250
+ },
1251
+ };
1252
+ const makeContribution = () =>
1253
+ createShellBotBackendContribution({
1254
+ state: { storage } as unknown as DurableObjectState,
1255
+ env: {
1256
+ USER_CONFIGURATIONS: {
1257
+ idFromName: () => "user-1",
1258
+ get: () => userConfiguration,
1259
+ },
1260
+ } as never,
1261
+ compileApplication: compileAssignmentTestApplication,
1262
+ });
1263
+ const contribution = makeContribution();
1264
+ const identity = { userId: "user-1", botId: "primary" };
1265
+ await contribution.materializeSettings(identity, { name: "Primary" });
1266
+ const execute = (
1267
+ backend: ReturnType<typeof createShellBotBackendContribution>,
1268
+ command: BotConfigurationCommandV1,
1269
+ ) =>
1270
+ backend.executeConfiguration({
1271
+ schemaVersion: 1,
1272
+ ...identity,
1273
+ command,
1274
+ });
1275
+ await execute(contribution, {
1276
+ schemaVersion: 1,
1277
+ type: "bot/assign-capability",
1278
+ commandId: "assign-old",
1279
+ botId: "primary",
1280
+ expectedRevision: 0,
1281
+ assignment: {
1282
+ assignmentId: "mail",
1283
+ packageId: "composio",
1284
+ capabilityId: "gmail-tools",
1285
+ connectionId: "gmail-1",
1286
+ },
1287
+ });
1288
+ const replaceCommand = {
1289
+ schemaVersion: 1 as const,
1290
+ type: "bot/replace-capability" as const,
1291
+ commandId: `replace-${acknowledgement}`,
1292
+ botId: "primary",
1293
+ expectedRevision: 1,
1294
+ assignment: {
1295
+ assignmentId: "mail",
1296
+ packageId: "composio",
1297
+ capabilityId: "gmail-tools",
1298
+ connectionId: "gmail-2",
1299
+ },
1300
+ };
1301
+
1302
+ await expect(
1303
+ execute(contribution, replaceCommand),
1304
+ ).resolves.toMatchObject({ status: "applied", revision: 2 });
1305
+ expect(dependencies.get("assign-old")).toBe("acknowledged");
1306
+ expect(await contribution.getSettings(identity)).toMatchObject({
1307
+ revision: 2,
1308
+ assignments: [
1309
+ {
1310
+ assignmentId: "mail",
1311
+ connectionId: "gmail-2",
1312
+ state: "unavailable",
1313
+ },
1314
+ ],
1315
+ assignmentOperations: [
1316
+ {
1317
+ commandId: `replace-${acknowledgement}`,
1318
+ kind: "replacing",
1319
+ state: "retrying",
1320
+ },
1321
+ ],
1322
+ });
1323
+ expect(log).toContain("release:assign-old");
1324
+
1325
+ holdOldRelease = false;
1326
+ const reconstructed = makeContribution();
1327
+ await reconstructed.alarm();
1328
+ expect(dependencies.get("assign-old")).toBe("released");
1329
+ expect(await reconstructed.getSettings(identity)).toMatchObject({
1330
+ revision: 2,
1331
+ assignments: [
1332
+ {
1333
+ assignmentId: "mail",
1334
+ connectionId: "gmail-2",
1335
+ state: "unavailable",
1336
+ },
1337
+ ],
1338
+ assignmentOperations: [],
1339
+ });
1340
+ await expect(
1341
+ execute(reconstructed, replaceCommand),
1342
+ ).resolves.toMatchObject({ status: "applied", revision: 2 });
1343
+ }
1344
+ });
1345
+
1346
+ test("keeps provider absence retrying until the owner becomes available", async () => {
1347
+ const storage = new MemoryStorage();
1348
+ let available = false;
1349
+ let dependency: "absent" | "claimed" | "acknowledged" = "absent";
1350
+ const userConfiguration = {
1351
+ readConfiguration: () => Promise.resolve(installedUser()),
1352
+ executeConnectionDependency: (request: { action: string }) => {
1353
+ if (!available) {
1354
+ return Promise.resolve({
1355
+ schemaVersion: 1 as const,
1356
+ status: "unavailable" as const,
1357
+ failure: "Connection owner is unavailable",
1358
+ });
1359
+ }
1360
+ if (request.action === "read") {
1361
+ return Promise.resolve({
1362
+ schemaVersion: 1 as const,
1363
+ status: dependency,
1364
+ });
1365
+ }
1366
+ if (request.action === "claim") dependency = "claimed";
1367
+ if (request.action === "acknowledge") dependency = "acknowledged";
1368
+ return Promise.resolve({
1369
+ schemaVersion: 1 as const,
1370
+ status:
1371
+ request.action === "claim"
1372
+ ? ("claimed" as const)
1373
+ : request.action === "acknowledge"
1374
+ ? ("acknowledged" as const)
1375
+ : ("pending" as const),
1376
+ });
1377
+ },
1378
+ };
1379
+ const host = {
1380
+ state: { storage } as unknown as DurableObjectState,
1381
+ env: {
1382
+ USER_CONFIGURATIONS: {
1383
+ idFromName: () => "user-1",
1384
+ get: () => userConfiguration,
1385
+ },
1386
+ } as never,
1387
+ compileApplication: compileAssignmentTestApplication,
1388
+ };
1389
+ const identity = { userId: "user-1", botId: "primary" };
1390
+ const contribution = createShellBotBackendContribution(host);
1391
+ await contribution.materializeSettings(identity, { name: "Primary" });
1392
+ const command = assignmentCommand("owner-retry", {
1393
+ packageId: "composio",
1394
+ capabilityId: "gmail-tools",
1395
+ connectionId: "gmail-1",
1396
+ });
1397
+ await expect(
1398
+ contribution.executeConfiguration({
1399
+ schemaVersion: 1,
1400
+ ...identity,
1401
+ command,
1402
+ }),
1403
+ ).resolves.toEqual({
1404
+ schemaVersion: 1,
1405
+ commandId: "owner-retry",
1406
+ revision: 0,
1407
+ status: "pending",
1408
+ });
1409
+ expect(await contribution.getSettings(identity)).toMatchObject({
1410
+ revision: 0,
1411
+ assignments: [],
1412
+ assignmentOperations: [{ commandId: "owner-retry", state: "retrying" }],
1413
+ });
1414
+ available = true;
1415
+ const reconstructed = createShellBotBackendContribution(host);
1416
+ await reconstructed.alarm();
1417
+ expect(await reconstructed.getSettings(identity)).toMatchObject({
1418
+ revision: 1,
1419
+ assignments: [{ assignmentId: "owner-retry", state: "enabled" }],
1420
+ assignmentOperations: [],
1421
+ });
1422
+ });
1423
+
1424
+ test("scopes generations to Assignments that share one Connection", async () => {
1425
+ const storage = new MemoryStorage();
1426
+ const user = installedUser();
1427
+ user.connections.push({
1428
+ ...user.connections[0]!,
1429
+ connectionId: "gmail-2",
1430
+ displayName: "Gmail replacement",
1431
+ });
1432
+ const dependencies = new Map<
1433
+ string,
1434
+ "claimed" | "acknowledged" | "released"
1435
+ >();
1436
+ const userConfiguration = {
1437
+ readConfiguration: () => Promise.resolve(structuredClone(user)),
1438
+ executeConnectionDependency: (request: {
1439
+ action: "claim" | "read" | "acknowledge" | "release" | "reconcile";
1440
+ generation: string;
1441
+ }) => {
1442
+ if (request.action === "read") {
1443
+ return Promise.resolve({
1444
+ schemaVersion: 1 as const,
1445
+ status: dependencies.get(request.generation) ?? ("absent" as const),
1446
+ });
1447
+ }
1448
+ if (request.action === "claim") {
1449
+ dependencies.set(request.generation, "claimed");
1450
+ return Promise.resolve({
1451
+ schemaVersion: 1 as const,
1452
+ status: "claimed" as const,
1453
+ });
1454
+ }
1455
+ if (request.action === "acknowledge") {
1456
+ dependencies.set(request.generation, "acknowledged");
1457
+ return Promise.resolve({
1458
+ schemaVersion: 1 as const,
1459
+ status: "acknowledged" as const,
1460
+ });
1461
+ }
1462
+ if (request.action === "release") {
1463
+ dependencies.set(request.generation, "released");
1464
+ }
1465
+ return Promise.resolve({
1466
+ schemaVersion: 1 as const,
1467
+ status: "released" as const,
1468
+ });
1469
+ },
1470
+ };
1471
+ const contribution = createShellBotBackendContribution({
1472
+ state: { storage } as unknown as DurableObjectState,
1473
+ env: {
1474
+ USER_CONFIGURATIONS: {
1475
+ idFromName: () => "user-1",
1476
+ get: () => userConfiguration,
1477
+ },
1478
+ } as never,
1479
+ compileApplication: compileAssignmentTestApplication,
1480
+ });
1481
+ const identity = { userId: "user-1", botId: "primary" };
1482
+ await contribution.materializeSettings(identity, { name: "Primary" });
1483
+ const execute = (command: BotConfigurationCommandV1) =>
1484
+ contribution.executeConfiguration({
1485
+ schemaVersion: 1,
1486
+ ...identity,
1487
+ command,
1488
+ });
1489
+ const assign = (
1490
+ commandId: string,
1491
+ expectedRevision: number,
1492
+ assignmentId: string,
1493
+ connectionId: string,
1494
+ type:
1495
+ | "bot/assign-capability"
1496
+ | "bot/replace-capability" = "bot/assign-capability",
1497
+ ) =>
1498
+ execute({
1499
+ schemaVersion: 1,
1500
+ type,
1501
+ commandId,
1502
+ botId: "primary",
1503
+ expectedRevision,
1504
+ assignment: {
1505
+ assignmentId,
1506
+ packageId: "composio",
1507
+ capabilityId: "gmail-tools",
1508
+ connectionId,
1509
+ },
1510
+ });
1511
+
1512
+ await assign("assign-a", 0, "mail-a", "gmail-1");
1513
+ await assign("assign-b", 1, "mail-b", "gmail-1");
1514
+ await assign("replace-a", 2, "mail-a", "gmail-2", "bot/replace-capability");
1515
+ expect(dependencies.get("assign-a")).toBe("released");
1516
+ expect(dependencies.get("assign-b")).toBe("acknowledged");
1517
+ await execute({
1518
+ schemaVersion: 1,
1519
+ type: "bot/unassign-capability",
1520
+ commandId: "unassign-a",
1521
+ botId: "primary",
1522
+ expectedRevision: 3,
1523
+ assignmentId: "mail-a",
1524
+ });
1525
+ expect(await contribution.getSettings(identity)).toMatchObject({
1526
+ revision: 4,
1527
+ assignments: [
1528
+ {
1529
+ assignmentId: "mail-b",
1530
+ connectionId: "gmail-1",
1531
+ state: "enabled",
1532
+ },
1533
+ ],
1534
+ });
1535
+ expect(dependencies.get("assign-b")).toBe("acknowledged");
1536
+ });
1537
+
1538
+ test("settles a committed assignment saga before replaying its receipt", async () => {
1539
+ const storage = new MemoryStorage();
1540
+ let acknowledgementAttempts = 0;
1541
+ let acknowledged = false;
1542
+ let dependencyStatus: "absent" | "claimed" | "acknowledged" = "absent";
1543
+ const userConfiguration = {
1544
+ readConfiguration: () => Promise.resolve(installedUser()),
1545
+ executeConnectionDependency: (request: { action: string }) => {
1546
+ if (request.action === "read") {
1547
+ return Promise.resolve({
1548
+ schemaVersion: 1 as const,
1549
+ status: dependencyStatus,
1550
+ });
1551
+ }
1552
+ if (request.action === "claim") {
1553
+ dependencyStatus = "claimed";
1554
+ return Promise.resolve({
1555
+ schemaVersion: 1 as const,
1556
+ status: "claimed" as const,
1557
+ });
1558
+ }
1559
+ if (request.action === "acknowledge") {
1560
+ acknowledgementAttempts += 1;
1561
+ if (acknowledgementAttempts <= 1) {
1562
+ return Promise.reject(new Error("acknowledgement response lost"));
1563
+ }
1564
+ dependencyStatus = "acknowledged";
1565
+ acknowledged = true;
1566
+ return Promise.resolve({
1567
+ schemaVersion: 1 as const,
1568
+ status: "acknowledged" as const,
1569
+ });
1570
+ }
1571
+ return Promise.resolve({
1572
+ schemaVersion: 1 as const,
1573
+ status: "released" as const,
1574
+ });
1575
+ },
1576
+ };
1577
+ const contribution = createShellBotBackendContribution({
1578
+ state: { storage } as unknown as DurableObjectState,
1579
+ env: {
1580
+ USER_CONFIGURATIONS: {
1581
+ idFromName: () => "user-1",
1582
+ get: () => userConfiguration,
1583
+ },
1584
+ } as never,
1585
+ compileApplication: compileAssignmentTestApplication,
1586
+ });
1587
+ await contribution.materializeSettings(
1588
+ { userId: "user-1", botId: "primary" },
1589
+ { name: "Primary" },
1590
+ );
1591
+ const command = assignmentCommand("lost-assignment-response", {
1592
+ packageId: "composio",
1593
+ capabilityId: "gmail-tools",
1594
+ connectionId: "gmail-1",
1595
+ });
1596
+ const execute = () =>
1597
+ contribution.executeConfiguration({
1598
+ schemaVersion: 1,
1599
+ userId: "user-1",
1600
+ botId: "primary",
1601
+ command,
1602
+ });
1603
+
1604
+ await expect(execute()).resolves.toEqual({
1605
+ schemaVersion: 1,
1606
+ commandId: "lost-assignment-response",
1607
+ status: "pending",
1608
+ revision: 0,
1609
+ });
1610
+ expect(acknowledgementAttempts).toBe(1);
1611
+ expect(acknowledged).toBe(false);
1612
+
1613
+ await expect(execute()).resolves.toMatchObject({
1614
+ commandId: "lost-assignment-response",
1615
+ status: "applied",
1616
+ revision: 1,
1617
+ });
1618
+ expect(acknowledgementAttempts).toBe(2);
1619
+ expect(acknowledged).toBe(true);
1620
+ });
1621
+ });
1622
+
1623
+ describe("User default model", () => {
1624
+ test("claims one durable Assignment for a Bot that follows the default", async () => {
1625
+ const storage = new MemoryStorage();
1626
+ let dependencyClaims = 0;
1627
+ let dependencyAcknowledgements = 0;
1628
+ let dependencyStatus: "absent" | "claimed" | "acknowledged" = "absent";
1629
+ const user: UserSettingsViewV1 = {
1630
+ ...installedUser(),
1631
+ newBotModelTemplate: {
1632
+ connectionId: "gmail-1",
1633
+ providerModelId: "fixture-model",
1634
+ },
1635
+ newBotModelTemplateSource: "auto",
1636
+ };
1637
+ const userConfiguration = {
1638
+ readConfiguration: () => Promise.resolve(structuredClone(user)),
1639
+ executeConnectionDependency: (request: { action: string }) => {
1640
+ if (request.action === "read") {
1641
+ return Promise.resolve({
1642
+ schemaVersion: 1 as const,
1643
+ status: dependencyStatus,
1644
+ });
1645
+ }
1646
+ if (request.action === "claim") {
1647
+ dependencyClaims += 1;
1648
+ dependencyStatus = "claimed";
1649
+ return Promise.resolve({
1650
+ schemaVersion: 1 as const,
1651
+ status: "claimed" as const,
1652
+ });
1653
+ }
1654
+ if (request.action === "acknowledge") {
1655
+ dependencyAcknowledgements += 1;
1656
+ dependencyStatus = "acknowledged";
1657
+ return Promise.resolve({
1658
+ schemaVersion: 1 as const,
1659
+ status: "acknowledged" as const,
1660
+ });
1661
+ }
1662
+ dependencyStatus = "absent";
1663
+ return Promise.resolve({
1664
+ schemaVersion: 1 as const,
1665
+ status: "released" as const,
1666
+ });
1667
+ },
1668
+ };
1669
+ const contribution = createShellBotBackendContribution({
1670
+ state: { storage } as unknown as DurableObjectState,
1671
+ env: {
1672
+ USER_CONFIGURATIONS: {
1673
+ idFromName: () => "user-1",
1674
+ get: () => userConfiguration,
1675
+ },
1676
+ } as never,
1677
+ compileApplication: compileAssignmentTestApplication,
1678
+ });
1679
+ const identity = { userId: "user-1", botId: "primary" };
1680
+ await contribution.materializeSettings(identity, { name: "Primary" });
1681
+
1682
+ const plan = await contribution.resolveConfiguration(identity);
1683
+ expect(plan.model).toBeUndefined();
1684
+ expect(plan.assignments).toMatchObject([
1685
+ {
1686
+ packageId: "composio",
1687
+ capabilityId: "gmail-tools",
1688
+ connectionId: "gmail-1",
1689
+ state: "enabled",
1690
+ },
1691
+ ]);
1692
+ expect(dependencyClaims).toBe(1);
1693
+ expect(dependencyAcknowledgements).toBe(1);
1694
+
1695
+ // The claim is made once: a Bot that already holds the Assignment keeps it
1696
+ // and follows the default without further durable writes.
1697
+ const settings = await contribution.readConfiguration({
1698
+ schemaVersion: 1,
1699
+ ...identity,
1700
+ });
1701
+ await contribution.resolveConfiguration(identity);
1702
+ expect(dependencyClaims).toBe(1);
1703
+ expect(
1704
+ await contribution.readConfiguration({ schemaVersion: 1, ...identity }),
1705
+ ).toEqual(settings);
1706
+ expect(settings.model).toBeUndefined();
1707
+ });
1708
+
1709
+ test("leaves a Bot unclaimed when the default Connection is unavailable", async () => {
1710
+ const storage = new MemoryStorage();
1711
+ let dependencyClaims = 0;
1712
+ const userConfiguration = {
1713
+ readConfiguration: () =>
1714
+ Promise.resolve({
1715
+ ...installedUser(),
1716
+ connections: [
1717
+ { ...installedUser().connections[0]!, state: "revoked" as const },
1718
+ ],
1719
+ newBotModelTemplate: {
1720
+ connectionId: "gmail-1",
1721
+ providerModelId: "fixture-model",
1722
+ },
1723
+ newBotModelTemplateSource: "auto",
1724
+ }),
1725
+ executeConnectionDependency: (request: { action: string }) => {
1726
+ if (request.action === "read") {
1727
+ return Promise.resolve({
1728
+ schemaVersion: 1 as const,
1729
+ status: "absent" as const,
1730
+ });
1731
+ }
1732
+ if (request.action === "claim") dependencyClaims += 1;
1733
+ return Promise.resolve({
1734
+ schemaVersion: 1 as const,
1735
+ status: "unauthorized" as const,
1736
+ });
1737
+ },
1738
+ };
1739
+ const contribution = createShellBotBackendContribution({
1740
+ state: { storage } as unknown as DurableObjectState,
1741
+ env: {
1742
+ USER_CONFIGURATIONS: {
1743
+ idFromName: () => "user-1",
1744
+ get: () => userConfiguration,
1745
+ },
1746
+ } as never,
1747
+ compileApplication: compileAssignmentTestApplication,
1748
+ });
1749
+ const identity = { userId: "user-1", botId: "primary" };
1750
+ await contribution.materializeSettings(identity, { name: "Primary" });
1751
+
1752
+ expect(
1753
+ (await contribution.resolveConfiguration(identity)).assignments,
1754
+ ).toEqual([]);
1755
+ expect(dependencyClaims).toBe(0);
1756
+ });
1757
+ });