@vellumai/assistant 0.5.11 → 0.5.12

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 (188) hide show
  1. package/Dockerfile +1 -0
  2. package/docs/architecture/integrations.md +34 -32
  3. package/node_modules/@vellumai/ces-contracts/src/__tests__/grants.test.ts +7 -7
  4. package/node_modules/@vellumai/ces-contracts/src/handles.ts +5 -4
  5. package/node_modules/@vellumai/credential-storage/src/index.ts +1 -1
  6. package/openapi.yaml +87 -9
  7. package/package.json +1 -1
  8. package/src/__tests__/catalog-cache.test.ts +164 -0
  9. package/src/__tests__/catalog-search.test.ts +61 -0
  10. package/src/__tests__/cli-command-risk-guard.test.ts +181 -6
  11. package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +396 -0
  12. package/src/__tests__/conversation-error.test.ts +3 -2
  13. package/src/__tests__/credential-security-invariants.test.ts +9 -15
  14. package/src/__tests__/credential-vault-unit.test.ts +32 -34
  15. package/src/__tests__/credential-vault.test.ts +25 -33
  16. package/src/__tests__/credentials-cli.test.ts +3 -3
  17. package/src/__tests__/daemon-credential-client.test.ts +2 -2
  18. package/src/__tests__/host-bash-proxy.test.ts +79 -0
  19. package/src/__tests__/host-cu-proxy.test.ts +90 -0
  20. package/src/__tests__/host-file-proxy.test.ts +89 -0
  21. package/src/__tests__/integration-status.test.ts +5 -5
  22. package/src/__tests__/list-messages-attachments.test.ts +171 -0
  23. package/src/__tests__/mcp-abort-signal.test.ts +205 -0
  24. package/src/__tests__/messaging-send-tool.test.ts +5 -5
  25. package/src/__tests__/notification-telegram-adapter.test.ts +125 -0
  26. package/src/__tests__/oauth-cli.test.ts +126 -119
  27. package/src/__tests__/oauth-provider-profiles.test.ts +55 -20
  28. package/src/__tests__/oauth-scope-policy.test.ts +4 -6
  29. package/src/__tests__/onboarding-template-contract.test.ts +2 -2
  30. package/src/__tests__/secret-routes-managed-proxy.test.ts +78 -0
  31. package/src/__tests__/secure-keys-managed-failover.test.ts +73 -0
  32. package/src/__tests__/skills-uninstall.test.ts +2 -2
  33. package/src/__tests__/slack-messaging-token-resolution.test.ts +22 -24
  34. package/src/__tests__/slack-share-routes.test.ts +5 -5
  35. package/src/__tests__/system-prompt.test.ts +39 -0
  36. package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +5 -4
  37. package/src/cli/AGENTS.md +47 -7
  38. package/src/cli/commands/browser-relay.ts +2 -17
  39. package/src/cli/commands/contacts.ts +6 -4
  40. package/src/cli/commands/conversations.ts +13 -1
  41. package/src/cli/commands/credential-execution.ts +16 -1
  42. package/src/cli/commands/credentials.ts +2 -8
  43. package/src/cli/commands/oauth/__tests__/connect.test.ts +29 -108
  44. package/src/cli/commands/oauth/__tests__/disconnect.test.ts +13 -87
  45. package/src/cli/commands/oauth/__tests__/mode.test.ts +22 -69
  46. package/src/cli/commands/oauth/__tests__/ping.test.ts +20 -79
  47. package/src/cli/commands/oauth/__tests__/providers-delete.test.ts +574 -0
  48. package/src/cli/commands/oauth/__tests__/providers-update.test.ts +416 -0
  49. package/src/cli/commands/oauth/__tests__/status.test.ts +12 -40
  50. package/src/cli/commands/oauth/__tests__/token.test.ts +3 -50
  51. package/src/cli/commands/oauth/apps.ts +63 -44
  52. package/src/cli/commands/oauth/connect.ts +187 -155
  53. package/src/cli/commands/oauth/disconnect.ts +27 -75
  54. package/src/cli/commands/oauth/index.ts +36 -46
  55. package/src/cli/commands/oauth/mode.ts +22 -34
  56. package/src/cli/commands/oauth/ping.ts +19 -45
  57. package/src/cli/commands/oauth/providers.ts +569 -62
  58. package/src/cli/commands/oauth/request.ts +36 -48
  59. package/src/cli/commands/oauth/shared.ts +1 -19
  60. package/src/cli/commands/oauth/status.ts +14 -25
  61. package/src/cli/commands/oauth/token.ts +25 -34
  62. package/src/cli/commands/platform/connect.ts +104 -0
  63. package/src/cli/commands/platform/disconnect.ts +118 -0
  64. package/src/cli/commands/{platform.ts → platform/index.ts} +108 -38
  65. package/src/cli/commands/sequence.ts +5 -4
  66. package/src/cli/commands/shotgun.ts +16 -0
  67. package/src/cli/commands/skills.ts +173 -41
  68. package/src/cli/commands/usage.ts +5 -11
  69. package/src/cli/lib/daemon-credential-client.ts +22 -38
  70. package/src/cli/program.ts +1 -1
  71. package/src/config/assistant-feature-flags.ts +3 -7
  72. package/src/config/bundled-skills/contacts/tools/google-contacts.ts +1 -1
  73. package/src/config/bundled-skills/conversations/SKILL.md +20 -0
  74. package/src/config/bundled-skills/conversations/TOOLS.json +23 -0
  75. package/src/config/bundled-skills/conversations/tools/rename-conversation.ts +66 -0
  76. package/src/config/bundled-skills/gmail/SKILL.md +13 -13
  77. package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +3 -3
  78. package/src/config/bundled-skills/gmail/tools/gmail-attachments.ts +2 -2
  79. package/src/config/bundled-skills/gmail/tools/gmail-draft.ts +1 -1
  80. package/src/config/bundled-skills/gmail/tools/gmail-filters.ts +1 -1
  81. package/src/config/bundled-skills/gmail/tools/gmail-follow-up.ts +1 -1
  82. package/src/config/bundled-skills/gmail/tools/gmail-forward.ts +1 -1
  83. package/src/config/bundled-skills/gmail/tools/gmail-label.ts +2 -2
  84. package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +1 -1
  85. package/src/config/bundled-skills/gmail/tools/gmail-send-draft.ts +1 -1
  86. package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +1 -1
  87. package/src/config/bundled-skills/gmail/tools/gmail-trash.ts +1 -1
  88. package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +1 -1
  89. package/src/config/bundled-skills/gmail/tools/gmail-vacation.ts +1 -1
  90. package/src/config/bundled-skills/google-calendar/SKILL.md +10 -4
  91. package/src/config/bundled-skills/google-calendar/tools/shared.ts +1 -1
  92. package/src/config/bundled-skills/messaging/SKILL.md +7 -7
  93. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -2
  94. package/src/config/bundled-skills/messaging/tools/shared.ts +5 -6
  95. package/src/config/bundled-tool-registry.ts +5 -0
  96. package/src/config/feature-flag-registry.json +1 -1
  97. package/src/credential-execution/client.ts +1 -1
  98. package/src/daemon/conversation-agent-loop.ts +2 -0
  99. package/src/daemon/conversation-error.ts +36 -6
  100. package/src/daemon/conversation-messaging.ts +9 -0
  101. package/src/daemon/conversation-runtime-assembly.ts +33 -0
  102. package/src/daemon/conversation-surfaces.ts +120 -14
  103. package/src/daemon/conversation.ts +5 -0
  104. package/src/daemon/handlers/skills.ts +148 -3
  105. package/src/daemon/host-bash-proxy.ts +16 -0
  106. package/src/daemon/host-cu-proxy.ts +16 -0
  107. package/src/daemon/host-file-proxy.ts +16 -0
  108. package/src/daemon/lifecycle.ts +47 -1
  109. package/src/daemon/message-types/conversations.ts +1 -0
  110. package/src/daemon/message-types/guardian-actions.ts +2 -0
  111. package/src/daemon/message-types/host-bash.ts +6 -1
  112. package/src/daemon/message-types/host-cu.ts +6 -1
  113. package/src/daemon/message-types/host-file.ts +6 -1
  114. package/src/daemon/message-types/integrations.ts +0 -1
  115. package/src/daemon/server.ts +29 -2
  116. package/src/hooks/cli.ts +74 -0
  117. package/src/inbound/platform-callback-registration.ts +7 -12
  118. package/src/mcp/client.ts +6 -1
  119. package/src/mcp/manager.ts +2 -1
  120. package/src/memory/conversation-crud.ts +92 -3
  121. package/src/memory/conversation-key-store.ts +26 -0
  122. package/src/memory/db-init.ts +16 -0
  123. package/src/memory/migrations/196-messages-conversation-created-at-index.ts +9 -0
  124. package/src/memory/migrations/196-strip-integration-prefix-from-provider-keys.ts +186 -0
  125. package/src/memory/migrations/197-oauth-providers-behavior-columns.ts +29 -0
  126. package/src/memory/migrations/198-drop-setup-skill-id-column.ts +11 -0
  127. package/src/memory/migrations/index.ts +4 -0
  128. package/src/memory/migrations/registry.ts +8 -0
  129. package/src/memory/schema/oauth.ts +11 -0
  130. package/src/messaging/provider.ts +13 -12
  131. package/src/messaging/providers/gmail/adapter.ts +44 -35
  132. package/src/messaging/providers/slack/adapter.ts +63 -33
  133. package/src/messaging/providers/telegram-bot/adapter.ts +6 -8
  134. package/src/messaging/providers/whatsapp/adapter.ts +6 -8
  135. package/src/notifications/adapters/telegram.ts +78 -2
  136. package/src/oauth/__tests__/identity-verifier.test.ts +464 -0
  137. package/src/oauth/byo-connection.test.ts +22 -24
  138. package/src/oauth/connect-orchestrator.ts +37 -76
  139. package/src/oauth/connect-types.ts +7 -65
  140. package/src/oauth/connection-resolver.test.ts +13 -13
  141. package/src/oauth/connection-resolver.ts +3 -4
  142. package/src/oauth/identity-verifier.ts +177 -0
  143. package/src/oauth/oauth-store.ts +228 -3
  144. package/src/oauth/platform-connection.test.ts +56 -6
  145. package/src/oauth/platform-connection.ts +8 -1
  146. package/src/oauth/seed-providers.ts +247 -34
  147. package/src/permissions/checker.ts +127 -1
  148. package/src/prompts/system-prompt.ts +43 -9
  149. package/src/prompts/templates/BOOTSTRAP.md +16 -5
  150. package/src/providers/anthropic/client.ts +2 -33
  151. package/src/runtime/guardian-action-service.ts +7 -2
  152. package/src/runtime/http-server.ts +5 -3
  153. package/src/runtime/http-types.ts +8 -1
  154. package/src/runtime/routes/conversation-management-routes.ts +31 -0
  155. package/src/runtime/routes/conversation-routes.ts +79 -4
  156. package/src/runtime/routes/guardian-action-routes.ts +15 -2
  157. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +21 -8
  158. package/src/runtime/routes/integrations/slack/share.ts +1 -1
  159. package/src/runtime/routes/oauth-apps.ts +2 -1
  160. package/src/runtime/routes/secret-routes.ts +36 -13
  161. package/src/runtime/routes/settings-routes.ts +12 -19
  162. package/src/runtime/routes/skills-routes.ts +45 -4
  163. package/src/schedule/integration-status.ts +2 -2
  164. package/src/security/ces-rpc-credential-backend.ts +19 -16
  165. package/src/security/oauth-completion-page.ts +153 -0
  166. package/src/security/oauth2.ts +3 -17
  167. package/src/security/secure-keys.ts +207 -7
  168. package/src/security/token-manager.ts +3 -6
  169. package/src/signals/bash.ts +6 -1
  170. package/src/skills/catalog-cache.ts +44 -0
  171. package/src/skills/catalog-search.ts +18 -0
  172. package/src/tools/credentials/post-connect-hooks.ts +1 -1
  173. package/src/tools/credentials/vault.ts +34 -45
  174. package/src/tools/host-terminal/host-shell.ts +16 -3
  175. package/src/tools/mcp/mcp-tool-factory.ts +2 -1
  176. package/src/tools/skills/sandbox-runner.ts +16 -3
  177. package/src/tools/terminal/shell.ts +16 -3
  178. package/src/util/logger.ts +11 -1
  179. package/src/util/sentry-log-stream.ts +51 -0
  180. package/src/watcher/providers/github.ts +2 -2
  181. package/src/watcher/providers/gmail.ts +1 -1
  182. package/src/watcher/providers/google-calendar.ts +1 -1
  183. package/src/watcher/providers/linear.ts +2 -2
  184. package/src/workspace/migrations/011-backfill-installation-id.ts +5 -3
  185. package/src/workspace/migrations/020-rename-oauth-skill-dirs.ts +119 -0
  186. package/src/workspace/migrations/registry.ts +2 -0
  187. package/src/cli/commands/oauth/connections.ts +0 -255
  188. package/src/oauth/provider-behaviors.ts +0 -634
@@ -184,6 +184,32 @@ describe("HostFileProxy", () => {
184
184
  expect(proxy.hasPendingRequest(requestId)).toBe(false);
185
185
  });
186
186
 
187
+ test("sends host_file_cancel to client on abort", async () => {
188
+ setup();
189
+
190
+ const controller = new AbortController();
191
+ const resultPromise = proxy.request(
192
+ {
193
+ operation: "read",
194
+ path: "/tmp/test.txt",
195
+ },
196
+ "session-1",
197
+ controller.signal,
198
+ );
199
+
200
+ const sent = sentMessages[0] as Record<string, unknown>;
201
+ const requestId = sent.requestId as string;
202
+
203
+ controller.abort();
204
+ await resultPromise;
205
+
206
+ // Second message should be the cancel
207
+ expect(sentMessages).toHaveLength(2);
208
+ const cancelMsg = sentMessages[1] as Record<string, unknown>;
209
+ expect(cancelMsg.type).toBe("host_file_cancel");
210
+ expect(cancelMsg.requestId).toBe(requestId);
211
+ });
212
+
187
213
  test("returns immediately if signal already aborted", async () => {
188
214
  setup();
189
215
 
@@ -247,6 +273,69 @@ describe("HostFileProxy", () => {
247
273
  expect(proxy.hasPendingRequest(requestId)).toBe(false);
248
274
  expect(resultPromise).rejects.toThrow("Host file proxy disposed");
249
275
  });
276
+
277
+ test("sends host_file_cancel for each pending request on dispose", () => {
278
+ setup();
279
+
280
+ const p1 = proxy.request(
281
+ { operation: "read", path: "/tmp/a.txt" },
282
+ "session-1",
283
+ );
284
+ const p2 = proxy.request(
285
+ { operation: "read", path: "/tmp/b.txt" },
286
+ "session-1",
287
+ );
288
+ p1.catch(() => {}); // Expected rejection on dispose
289
+ p2.catch(() => {}); // Expected rejection on dispose
290
+
291
+ const requestIds = (sentMessages as Array<Record<string, unknown>>).map(
292
+ (m) => m.requestId as string,
293
+ );
294
+ expect(requestIds).toHaveLength(2);
295
+
296
+ proxy.dispose();
297
+
298
+ // After the 2 request messages, dispose should have sent 2 cancel messages
299
+ const cancelMessages = sentMessages
300
+ .slice(2)
301
+ .filter(
302
+ (m) => (m as Record<string, unknown>).type === "host_file_cancel",
303
+ ) as Array<Record<string, unknown>>;
304
+ expect(cancelMessages).toHaveLength(2);
305
+ expect(cancelMessages.map((m) => m.requestId)).toContain(requestIds[0]);
306
+ expect(cancelMessages.map((m) => m.requestId)).toContain(requestIds[1]);
307
+ });
308
+ });
309
+
310
+ describe("late resolve after abort", () => {
311
+ test("resolve is a no-op after abort (entry already deleted)", async () => {
312
+ setup();
313
+
314
+ const controller = new AbortController();
315
+ const resultPromise = proxy.request(
316
+ {
317
+ operation: "read",
318
+ path: "/tmp/test.txt",
319
+ },
320
+ "session-1",
321
+ controller.signal,
322
+ );
323
+
324
+ const sent = sentMessages[0] as Record<string, unknown>;
325
+ const requestId = sent.requestId as string;
326
+
327
+ controller.abort();
328
+ const result = await resultPromise;
329
+ expect(result.content).toBe("Aborted");
330
+
331
+ // Late resolve should be silently ignored (no throw, no double-resolve)
332
+ proxy.resolve(requestId, {
333
+ content: "late response",
334
+ isError: false,
335
+ });
336
+
337
+ expect(proxy.hasPendingRequest(requestId)).toBe(false);
338
+ });
250
339
  });
251
340
 
252
341
  describe("updateSender", () => {
@@ -56,8 +56,8 @@ describe("integration-status", () => {
56
56
  });
57
57
 
58
58
  test("returns all connected when all keys are set", async () => {
59
- setOAuthConnected("integration:google");
60
- setOAuthConnected("integration:slack");
59
+ setOAuthConnected("google");
60
+ setOAuthConnected("slack");
61
61
  mockTwilioAccountSid = "sid";
62
62
  secureKeyValues.set(credentialKey("twilio", "auth_token"), "auth");
63
63
  setOAuthConnected("telegram");
@@ -129,8 +129,8 @@ describe("integration-status", () => {
129
129
  });
130
130
 
131
131
  test("all connected", async () => {
132
- setOAuthConnected("integration:google");
133
- setOAuthConnected("integration:slack");
132
+ setOAuthConnected("google");
133
+ setOAuthConnected("slack");
134
134
  mockTwilioAccountSid = "sid";
135
135
  secureKeyValues.set(credentialKey("twilio", "auth_token"), "auth");
136
136
  setOAuthConnected("telegram");
@@ -163,7 +163,7 @@ describe("integration-status", () => {
163
163
  });
164
164
 
165
165
  test("email category checks Gmail", async () => {
166
- setOAuthConnected("integration:google");
166
+ setOAuthConnected("google");
167
167
  expect(await hasCapability("email")).toBe(true);
168
168
  });
169
169
  });
@@ -286,3 +286,174 @@ describe("handleListMessages no_response filtering", () => {
286
286
  expect(body.messages[0].content).toBe("What does <no_response/> do?");
287
287
  });
288
288
  });
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // Pagination
292
+ // ---------------------------------------------------------------------------
293
+
294
+ interface PaginatedResponse {
295
+ messages: { id: string; content: string; timestamp: string }[];
296
+ hasMore?: boolean;
297
+ oldestTimestamp?: number;
298
+ oldestMessageId?: string;
299
+ }
300
+
301
+ function createPaginatedUrl(
302
+ conversationId: string,
303
+ params?: { limit?: string; beforeTimestamp?: string },
304
+ ): URL {
305
+ const url = new URL(
306
+ `http://localhost/v1/messages?conversationId=${conversationId}`,
307
+ );
308
+ if (params?.limit !== undefined) url.searchParams.set("limit", params.limit);
309
+ if (params?.beforeTimestamp !== undefined)
310
+ url.searchParams.set("beforeTimestamp", params.beforeTimestamp);
311
+ return url;
312
+ }
313
+
314
+ /** Helper: insert N messages with distinct, increasing timestamps and return them in insertion order. */
315
+ async function insertMessages(
316
+ conversationId: string,
317
+ count: number,
318
+ ): Promise<{ id: string; createdAt: number }[]> {
319
+ const msgs: { id: string; createdAt: number }[] = [];
320
+ for (let i = 0; i < count; i++) {
321
+ const msg = await addMessage(
322
+ conversationId,
323
+ i % 2 === 0 ? "user" : "assistant",
324
+ JSON.stringify([{ type: "text", text: `msg-${i}` }]),
325
+ );
326
+ msgs.push({ id: msg.id, createdAt: msg.createdAt });
327
+ }
328
+ return msgs;
329
+ }
330
+
331
+ describe("handleListMessages pagination", () => {
332
+ beforeEach(resetTables);
333
+
334
+ test("no params → all messages, no hasMore field", async () => {
335
+ const conv = createConversation();
336
+ await insertMessages(conv.id, 5);
337
+
338
+ const response = handleListMessages(createTestUrl(conv.id), null);
339
+ const body = (await response.json()) as PaginatedResponse;
340
+
341
+ expect(body.messages).toHaveLength(5);
342
+ expect(body.hasMore).toBeUndefined();
343
+ expect(body.oldestTimestamp).toBeUndefined();
344
+ expect(body.oldestMessageId).toBeUndefined();
345
+ });
346
+
347
+ test("limit only (no beforeTimestamp) → all messages, no hasMore", async () => {
348
+ const conv = createConversation();
349
+ await insertMessages(conv.id, 5);
350
+
351
+ const url = createPaginatedUrl(conv.id, { limit: "3" });
352
+ const response = handleListMessages(url, null);
353
+ const body = (await response.json()) as PaginatedResponse;
354
+
355
+ // Option A: without beforeTimestamp, all messages are returned regardless of limit
356
+ expect(body.messages).toHaveLength(5);
357
+ expect(body.hasMore).toBeUndefined();
358
+ });
359
+
360
+ test("beforeTimestamp + limit → correct page with hasMore: true", async () => {
361
+ const conv = createConversation();
362
+ const msgs = await insertMessages(conv.id, 10);
363
+
364
+ // Cursor is message[7]'s timestamp; limit=3 → should return messages [4,5,6]
365
+ const url = createPaginatedUrl(conv.id, {
366
+ beforeTimestamp: String(msgs[7].createdAt),
367
+ limit: "3",
368
+ });
369
+ const response = handleListMessages(url, null);
370
+ const body = (await response.json()) as PaginatedResponse;
371
+
372
+ expect(body.messages).toHaveLength(3);
373
+ expect(body.messages.map((m) => m.id)).toEqual([
374
+ msgs[4].id,
375
+ msgs[5].id,
376
+ msgs[6].id,
377
+ ]);
378
+ expect(body.hasMore).toBe(true);
379
+ });
380
+
381
+ test("beforeTimestamp is strictly exclusive", async () => {
382
+ const conv = createConversation();
383
+ const msgs = await insertMessages(conv.id, 3);
384
+
385
+ // Use message[1]'s exact timestamp as cursor — message[1] should NOT appear
386
+ const url = createPaginatedUrl(conv.id, {
387
+ beforeTimestamp: String(msgs[1].createdAt),
388
+ limit: "10",
389
+ });
390
+ const response = handleListMessages(url, null);
391
+ const body = (await response.json()) as PaginatedResponse;
392
+
393
+ const ids = body.messages.map((m) => m.id);
394
+ expect(ids).toContain(msgs[0].id);
395
+ expect(ids).not.toContain(msgs[1].id);
396
+ expect(ids).not.toContain(msgs[2].id);
397
+ });
398
+
399
+ test("hasMore: false when all older messages fit", async () => {
400
+ const conv = createConversation();
401
+ const msgs = await insertMessages(conv.id, 5);
402
+
403
+ // beforeTimestamp beyond the last message, limit larger than total count
404
+ const url = createPaginatedUrl(conv.id, {
405
+ beforeTimestamp: String(msgs[4].createdAt + 1),
406
+ limit: "10",
407
+ });
408
+ const response = handleListMessages(url, null);
409
+ const body = (await response.json()) as PaginatedResponse;
410
+
411
+ expect(body.messages).toHaveLength(5);
412
+ expect(body.hasMore).toBe(false);
413
+ });
414
+
415
+ test("oldestTimestamp and oldestMessageId match oldest returned message", async () => {
416
+ const conv = createConversation();
417
+ const msgs = await insertMessages(conv.id, 5);
418
+
419
+ // Fetch last 3 messages before a cursor past the end
420
+ const url = createPaginatedUrl(conv.id, {
421
+ beforeTimestamp: String(msgs[4].createdAt + 1),
422
+ limit: "3",
423
+ });
424
+ const response = handleListMessages(url, null);
425
+ const body = (await response.json()) as PaginatedResponse;
426
+
427
+ expect(body.messages).toHaveLength(3);
428
+ // Oldest returned message is msgs[2] (messages [2,3,4])
429
+ expect(body.oldestTimestamp).toBe(msgs[2].createdAt);
430
+ expect(body.oldestMessageId).toBe(msgs[2].id);
431
+ });
432
+
433
+ test("empty / nonexistent conversation → empty messages, no pagination metadata", async () => {
434
+ const url = createPaginatedUrl("nonexistent-conv-id");
435
+ const response = handleListMessages(url, null);
436
+ const body = (await response.json()) as PaginatedResponse;
437
+
438
+ expect(body.messages).toEqual([]);
439
+ expect(body.hasMore).toBeUndefined();
440
+ expect(body.oldestTimestamp).toBeUndefined();
441
+ expect(body.oldestMessageId).toBeUndefined();
442
+ });
443
+
444
+ test("invalid limit (NaN) → 400", async () => {
445
+ const conv = createConversation();
446
+ const url = createPaginatedUrl(conv.id, { limit: "abc" });
447
+ const response = handleListMessages(url, null);
448
+
449
+ expect(response.status).toBe(400);
450
+ });
451
+
452
+ test("invalid beforeTimestamp (NaN) → 400", async () => {
453
+ const conv = createConversation();
454
+ const url = createPaginatedUrl(conv.id, { beforeTimestamp: "abc" });
455
+ const response = handleListMessages(url, null);
456
+
457
+ expect(response.status).toBe(400);
458
+ });
459
+ });
@@ -0,0 +1,205 @@
1
+ import { describe, expect, jest, mock, test } from "bun:test";
2
+
3
+ // Mock secure-keys so McpOAuthProvider doesn't try to access the credential store
4
+ mock.module("../security/secure-keys.js", () => ({
5
+ getSecureKeyAsync: jest.fn().mockResolvedValue(null),
6
+ setSecureKeyAsync: jest.fn().mockResolvedValue(true),
7
+ deleteSecureKeyAsync: jest.fn().mockResolvedValue("deleted"),
8
+ }));
9
+
10
+ const { McpClient } = await import("../mcp/client.js");
11
+ const { McpServerManager } = await import("../mcp/manager.js");
12
+ const { createMcpTool } = await import("../tools/mcp/mcp-tool-factory.js");
13
+
14
+ describe("MCP AbortSignal threading", () => {
15
+ describe("McpClient.callTool", () => {
16
+ test("forwards signal to the SDK client.callTool options", async () => {
17
+ const client = new McpClient("test-server");
18
+
19
+ const callToolSpy = jest.fn().mockResolvedValue({
20
+ content: [{ type: "text", text: "ok" }],
21
+ isError: false,
22
+ });
23
+
24
+ // Monkey-patch to mark as connected and capture callTool args
25
+ (client as any).connected = true;
26
+ (client as any).client = { callTool: callToolSpy };
27
+
28
+ const ac = new AbortController();
29
+ await client.callTool("my-tool", { foo: "bar" }, ac.signal);
30
+
31
+ expect(callToolSpy).toHaveBeenCalledTimes(1);
32
+ const [params, resultSchema, options] = callToolSpy.mock.calls[0];
33
+ expect(params).toEqual({ name: "my-tool", arguments: { foo: "bar" } });
34
+ expect(resultSchema).toBeUndefined();
35
+ expect(options).toEqual({ signal: ac.signal });
36
+ });
37
+
38
+ test("passes undefined signal cleanly (no regression)", async () => {
39
+ const client = new McpClient("test-server");
40
+
41
+ const callToolSpy = jest.fn().mockResolvedValue({
42
+ content: [{ type: "text", text: "ok" }],
43
+ isError: false,
44
+ });
45
+
46
+ (client as any).connected = true;
47
+ (client as any).client = { callTool: callToolSpy };
48
+
49
+ await client.callTool("my-tool", { foo: "bar" });
50
+
51
+ expect(callToolSpy).toHaveBeenCalledTimes(1);
52
+ const [_params, _resultSchema, options] = callToolSpy.mock.calls[0];
53
+ expect(options).toBeUndefined();
54
+ });
55
+
56
+ test("already-aborted signal causes the SDK call to reject", async () => {
57
+ const client = new McpClient("test-server");
58
+
59
+ const callToolSpy = jest
60
+ .fn()
61
+ .mockImplementation((_p: any, _r: any, opts: any) => {
62
+ if (opts?.signal?.aborted) {
63
+ return Promise.reject(
64
+ new DOMException("The operation was aborted.", "AbortError"),
65
+ );
66
+ }
67
+ return Promise.resolve({
68
+ content: [{ type: "text", text: "ok" }],
69
+ isError: false,
70
+ });
71
+ });
72
+
73
+ (client as any).connected = true;
74
+ (client as any).client = { callTool: callToolSpy };
75
+
76
+ const ac = new AbortController();
77
+ ac.abort();
78
+
79
+ await expect(client.callTool("my-tool", {}, ac.signal)).rejects.toThrow(
80
+ "The operation was aborted.",
81
+ );
82
+ });
83
+ });
84
+
85
+ describe("McpServerManager.callTool", () => {
86
+ test("forwards signal to the underlying McpClient.callTool", async () => {
87
+ const manager = new McpServerManager();
88
+
89
+ const callToolSpy = jest.fn().mockResolvedValue({
90
+ content: "result",
91
+ isError: false,
92
+ });
93
+
94
+ const fakeClient = { callTool: callToolSpy, serverId: "test-server" };
95
+ (manager as any).clients.set("test-server", fakeClient);
96
+
97
+ const ac = new AbortController();
98
+ await manager.callTool("test-server", "my-tool", { x: 1 }, ac.signal);
99
+
100
+ expect(callToolSpy).toHaveBeenCalledWith("my-tool", { x: 1 }, ac.signal);
101
+ });
102
+
103
+ test("passes undefined signal when not provided", async () => {
104
+ const manager = new McpServerManager();
105
+
106
+ const callToolSpy = jest.fn().mockResolvedValue({
107
+ content: "result",
108
+ isError: false,
109
+ });
110
+
111
+ const fakeClient = { callTool: callToolSpy, serverId: "test-server" };
112
+ (manager as any).clients.set("test-server", fakeClient);
113
+
114
+ await manager.callTool("test-server", "my-tool", { x: 1 });
115
+
116
+ expect(callToolSpy).toHaveBeenCalledWith("my-tool", { x: 1 }, undefined);
117
+ });
118
+ });
119
+
120
+ describe("createMcpTool execute", () => {
121
+ test("threads context.signal through manager.callTool", async () => {
122
+ const callToolSpy = jest.fn().mockResolvedValue({
123
+ content: "tool result",
124
+ isError: false,
125
+ });
126
+
127
+ const fakeManager = { callTool: callToolSpy } as any;
128
+
129
+ const tool = createMcpTool(
130
+ {
131
+ name: "my-tool",
132
+ description: "A test tool",
133
+ inputSchema: { type: "object", properties: {} },
134
+ },
135
+ "test-server",
136
+ {
137
+ transport: { type: "stdio", command: "echo", args: [] },
138
+ enabled: true,
139
+ defaultRiskLevel: "high",
140
+ maxTools: 100,
141
+ },
142
+ fakeManager,
143
+ );
144
+
145
+ const ac = new AbortController();
146
+ await tool.execute(
147
+ { someArg: "value" },
148
+ {
149
+ workingDir: "/tmp",
150
+ conversationId: "conv-1",
151
+ signal: ac.signal,
152
+ trustClass: "guardian",
153
+ },
154
+ );
155
+
156
+ expect(callToolSpy).toHaveBeenCalledWith(
157
+ "test-server",
158
+ "my-tool",
159
+ { someArg: "value" },
160
+ ac.signal,
161
+ );
162
+ });
163
+
164
+ test("passes undefined signal when context has no signal", async () => {
165
+ const callToolSpy = jest.fn().mockResolvedValue({
166
+ content: "tool result",
167
+ isError: false,
168
+ });
169
+
170
+ const fakeManager = { callTool: callToolSpy } as any;
171
+
172
+ const tool = createMcpTool(
173
+ {
174
+ name: "my-tool",
175
+ description: "A test tool",
176
+ inputSchema: { type: "object", properties: {} },
177
+ },
178
+ "test-server",
179
+ {
180
+ transport: { type: "stdio", command: "echo", args: [] },
181
+ enabled: true,
182
+ defaultRiskLevel: "high",
183
+ maxTools: 100,
184
+ },
185
+ fakeManager,
186
+ );
187
+
188
+ await tool.execute(
189
+ { someArg: "value" },
190
+ {
191
+ workingDir: "/tmp",
192
+ conversationId: "conv-1",
193
+ trustClass: "guardian",
194
+ },
195
+ );
196
+
197
+ expect(callToolSpy).toHaveBeenCalledWith(
198
+ "test-server",
199
+ "my-tool",
200
+ { someArg: "value" },
201
+ undefined,
202
+ );
203
+ });
204
+ });
205
+ });
@@ -24,16 +24,16 @@ const provider: MessagingProvider = {
24
24
  getHistory: async () => [],
25
25
  search: async () => ({ total: 0, messages: [], hasMore: false }),
26
26
  sendMessage: (
27
- connectionOrToken: OAuthConnection | string,
27
+ connection: OAuthConnection | undefined,
28
28
  conversationId: string,
29
29
  text: string,
30
30
  options?: SendOptions,
31
- ) => sendMessageMock(connectionOrToken, conversationId, text, options),
31
+ ) => sendMessageMock(connection, conversationId, text, options),
32
32
  };
33
33
 
34
34
  mock.module("../config/bundled-skills/messaging/tools/shared.js", () => ({
35
35
  resolveProvider: () => provider,
36
- getProviderConnection: () => "provider-token",
36
+ getProviderConnection: () => undefined,
37
37
  ok: (content: string) => ({ content, isError: false }),
38
38
  err: (content: string) => ({ content, isError: true }),
39
39
  extractHeader: () => "",
@@ -65,7 +65,7 @@ describe("messaging-send tool", () => {
65
65
 
66
66
  expect(result.isError).toBe(false);
67
67
  expect(sendMessageMock).toHaveBeenCalledWith(
68
- "provider-token",
68
+ undefined,
69
69
  "+15550004444",
70
70
  "test message",
71
71
  {
@@ -95,7 +95,7 @@ describe("messaging-send tool", () => {
95
95
 
96
96
  expect(result.isError).toBe(false);
97
97
  expect(sendMessageMock).toHaveBeenCalledWith(
98
- "provider-token",
98
+ undefined,
99
99
  "conv-1",
100
100
  "reply text",
101
101
  {
@@ -6,6 +6,9 @@ const deliveryCalls: Array<{
6
6
  bearerToken?: string;
7
7
  }> = [];
8
8
 
9
+ /** When true, deliverChannelReply throws if the payload contains an approval field. */
10
+ let rejectRichDelivery = false;
11
+
9
12
  mock.module("../config/env.js", () => ({
10
13
  isHttpAuthDisabled: () => true,
11
14
  getGatewayInternalBaseUrl: () => "http://gateway.internal",
@@ -17,6 +20,9 @@ mock.module("../runtime/gateway-client.js", () => ({
17
20
  payload: Record<string, unknown>,
18
21
  bearerToken?: string,
19
22
  ) => {
23
+ if (rejectRichDelivery && payload.approval) {
24
+ throw new Error("Telegram API error: buttons not supported");
25
+ }
20
26
  deliveryCalls.push({ url, payload, bearerToken });
21
27
  },
22
28
  }));
@@ -60,6 +66,7 @@ function makeDestination(
60
66
  describe("TelegramAdapter", () => {
61
67
  beforeEach(() => {
62
68
  deliveryCalls.length = 0;
69
+ rejectRichDelivery = false;
63
70
  });
64
71
 
65
72
  test("prefers deliveryText and does not append deterministic label", async () => {
@@ -156,4 +163,122 @@ describe("TelegramAdapter", () => {
156
163
  );
157
164
  expect(deliveryCalls[2]?.payload.text).toBe("watcher escalation");
158
165
  });
166
+
167
+ // ── Access request inline keyboard tests ──────────────────────────────
168
+
169
+ test("includes approval payload with inline buttons for access requests", async () => {
170
+ const adapter = new TelegramAdapter();
171
+ const payload = makePayload({
172
+ sourceEventName: "ingress.access_request",
173
+ copy: {
174
+ title: "Access Request",
175
+ body: "Someone is requesting access.",
176
+ deliveryText: "Someone is requesting access to the assistant.",
177
+ },
178
+ contextPayload: {
179
+ requestId: "req-abc-123",
180
+ requestCode: "XYZW",
181
+ senderIdentifier: "Marina",
182
+ sourceChannel: "telegram",
183
+ },
184
+ });
185
+
186
+ const result = await adapter.send(payload, makeDestination());
187
+
188
+ expect(result.success).toBe(true);
189
+ expect(deliveryCalls).toHaveLength(1);
190
+
191
+ const call = deliveryCalls[0]!;
192
+ expect(call.payload.text).toBe(
193
+ "Someone is requesting access to the assistant.",
194
+ );
195
+
196
+ const approval = call.payload.approval as {
197
+ requestId: string;
198
+ actions: Array<{ id: string; label: string }>;
199
+ plainTextFallback: string;
200
+ };
201
+ expect(approval).toBeDefined();
202
+ expect(approval.requestId).toBe("req-abc-123");
203
+ expect(approval.actions).toHaveLength(2);
204
+ expect(approval.actions[0]).toEqual({
205
+ id: "approve_once",
206
+ label: "Approve once",
207
+ });
208
+ expect(approval.actions[1]).toEqual({ id: "reject", label: "Reject" });
209
+ expect(approval.plainTextFallback).toContain("XYZW");
210
+ });
211
+
212
+ test("sends plain text without approval when contextPayload is missing", async () => {
213
+ const adapter = new TelegramAdapter();
214
+ const payload = makePayload({
215
+ sourceEventName: "ingress.access_request",
216
+ copy: {
217
+ title: "Access Request",
218
+ body: "Someone is requesting access.",
219
+ },
220
+ });
221
+
222
+ const result = await adapter.send(payload, makeDestination());
223
+
224
+ expect(result.success).toBe(true);
225
+ expect(deliveryCalls).toHaveLength(1);
226
+ expect(deliveryCalls[0]?.payload.approval).toBeUndefined();
227
+ });
228
+
229
+ test("sends plain text without approval when requestId is missing from contextPayload", async () => {
230
+ const adapter = new TelegramAdapter();
231
+ const payload = makePayload({
232
+ sourceEventName: "ingress.access_request",
233
+ copy: {
234
+ title: "Access Request",
235
+ body: "Someone is requesting access.",
236
+ },
237
+ contextPayload: {
238
+ senderIdentifier: "Marina",
239
+ sourceChannel: "telegram",
240
+ // no requestId
241
+ },
242
+ });
243
+
244
+ const result = await adapter.send(payload, makeDestination());
245
+
246
+ expect(result.success).toBe(true);
247
+ expect(deliveryCalls).toHaveLength(1);
248
+ expect(deliveryCalls[0]?.payload.approval).toBeUndefined();
249
+ });
250
+
251
+ test("falls back to plain text with instructions when rich delivery fails", async () => {
252
+ rejectRichDelivery = true;
253
+
254
+ const adapter = new TelegramAdapter();
255
+ const payload = makePayload({
256
+ sourceEventName: "ingress.access_request",
257
+ copy: {
258
+ title: "Access Request",
259
+ body: "Someone is requesting access.",
260
+ deliveryText: "Someone is requesting access to the assistant.",
261
+ },
262
+ contextPayload: {
263
+ requestId: "req-abc-123",
264
+ requestCode: "XYZW",
265
+ senderIdentifier: "Marina",
266
+ sourceChannel: "telegram",
267
+ },
268
+ });
269
+
270
+ const result = await adapter.send(payload, makeDestination());
271
+
272
+ expect(result.success).toBe(true);
273
+ // Rich delivery threw, so only the plain-text fallback should be recorded.
274
+ expect(deliveryCalls).toHaveLength(1);
275
+ const call = deliveryCalls[0]!;
276
+ // No approval payload in the fallback delivery.
277
+ expect(call.payload.approval).toBeUndefined();
278
+ // The fallback text should include the original message AND the
279
+ // typed-command instructions from plainTextFallback.
280
+ const text = call.payload.text as string;
281
+ expect(text).toContain("Someone is requesting access to the assistant.");
282
+ expect(text).toContain("XYZW");
283
+ });
159
284
  });