@vellumai/assistant 0.5.11 → 0.5.13

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 (209) hide show
  1. package/Dockerfile +42 -9
  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/ces-contracts/src/index.ts +7 -0
  6. package/node_modules/@vellumai/ces-contracts/src/rpc.ts +5 -0
  7. package/node_modules/@vellumai/credential-storage/src/index.ts +1 -1
  8. package/openapi.yaml +87 -9
  9. package/package.json +1 -1
  10. package/src/__tests__/catalog-cache.test.ts +164 -0
  11. package/src/__tests__/catalog-search.test.ts +61 -0
  12. package/src/__tests__/cli-command-risk-guard.test.ts +181 -6
  13. package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +396 -0
  14. package/src/__tests__/conversation-error.test.ts +3 -2
  15. package/src/__tests__/credential-security-invariants.test.ts +9 -15
  16. package/src/__tests__/credential-vault-unit.test.ts +32 -34
  17. package/src/__tests__/credential-vault.test.ts +25 -33
  18. package/src/__tests__/credentials-cli.test.ts +3 -3
  19. package/src/__tests__/daemon-credential-client.test.ts +2 -2
  20. package/src/__tests__/first-greeting.test.ts +7 -0
  21. package/src/__tests__/host-bash-proxy.test.ts +79 -0
  22. package/src/__tests__/host-cu-proxy.test.ts +90 -0
  23. package/src/__tests__/host-file-proxy.test.ts +89 -0
  24. package/src/__tests__/integration-status.test.ts +5 -5
  25. package/src/__tests__/list-messages-attachments.test.ts +171 -0
  26. package/src/__tests__/mcp-abort-signal.test.ts +205 -0
  27. package/src/__tests__/messaging-send-tool.test.ts +5 -5
  28. package/src/__tests__/navigate-settings-tab.test.ts +6 -2
  29. package/src/__tests__/notification-telegram-adapter.test.ts +125 -0
  30. package/src/__tests__/oauth-cli.test.ts +126 -119
  31. package/src/__tests__/oauth-provider-profiles.test.ts +55 -20
  32. package/src/__tests__/oauth-scope-policy.test.ts +4 -6
  33. package/src/__tests__/onboarding-template-contract.test.ts +2 -2
  34. package/src/__tests__/platform.test.ts +3 -168
  35. package/src/__tests__/secret-routes-managed-proxy.test.ts +78 -0
  36. package/src/__tests__/secure-keys-managed-failover.test.ts +73 -0
  37. package/src/__tests__/skill-feature-flags.test.ts +8 -0
  38. package/src/__tests__/skill-secret-handling-guard.test.ts +212 -0
  39. package/src/__tests__/skills-uninstall.test.ts +2 -2
  40. package/src/__tests__/slack-messaging-token-resolution.test.ts +22 -24
  41. package/src/__tests__/slack-share-routes.test.ts +5 -5
  42. package/src/__tests__/system-prompt.test.ts +39 -0
  43. package/src/__tests__/token-estimator-accuracy.benchmark.test.ts +1 -1
  44. package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +5 -4
  45. package/src/cli/AGENTS.md +47 -7
  46. package/src/cli/commands/browser-relay.ts +2 -17
  47. package/src/cli/commands/contacts.ts +6 -4
  48. package/src/cli/commands/conversations.ts +13 -1
  49. package/src/cli/commands/credential-execution.ts +16 -1
  50. package/src/cli/commands/credentials.ts +2 -8
  51. package/src/cli/commands/oauth/__tests__/connect.test.ts +29 -108
  52. package/src/cli/commands/oauth/__tests__/disconnect.test.ts +13 -87
  53. package/src/cli/commands/oauth/__tests__/mode.test.ts +22 -69
  54. package/src/cli/commands/oauth/__tests__/ping.test.ts +20 -79
  55. package/src/cli/commands/oauth/__tests__/providers-delete.test.ts +574 -0
  56. package/src/cli/commands/oauth/__tests__/providers-update.test.ts +416 -0
  57. package/src/cli/commands/oauth/__tests__/status.test.ts +12 -40
  58. package/src/cli/commands/oauth/__tests__/token.test.ts +3 -50
  59. package/src/cli/commands/oauth/apps.ts +63 -44
  60. package/src/cli/commands/oauth/connect.ts +187 -155
  61. package/src/cli/commands/oauth/disconnect.ts +27 -75
  62. package/src/cli/commands/oauth/index.ts +36 -46
  63. package/src/cli/commands/oauth/mode.ts +22 -34
  64. package/src/cli/commands/oauth/ping.ts +19 -45
  65. package/src/cli/commands/oauth/providers.ts +569 -62
  66. package/src/cli/commands/oauth/request.ts +36 -48
  67. package/src/cli/commands/oauth/shared.ts +1 -19
  68. package/src/cli/commands/oauth/status.ts +14 -25
  69. package/src/cli/commands/oauth/token.ts +25 -34
  70. package/src/cli/commands/platform/__tests__/connect.test.ts +224 -0
  71. package/src/cli/commands/platform/__tests__/disconnect.test.ts +237 -0
  72. package/src/cli/commands/platform/__tests__/status.test.ts +246 -0
  73. package/src/cli/commands/platform/connect.ts +104 -0
  74. package/src/cli/commands/platform/disconnect.ts +118 -0
  75. package/src/cli/commands/{platform.ts → platform/index.ts} +108 -38
  76. package/src/cli/commands/sequence.ts +5 -4
  77. package/src/cli/commands/shotgun.ts +16 -0
  78. package/src/cli/commands/skills.ts +173 -41
  79. package/src/cli/commands/usage.ts +5 -11
  80. package/src/cli/lib/daemon-credential-client.ts +22 -38
  81. package/src/cli/program.ts +1 -1
  82. package/src/config/assistant-feature-flags.ts +3 -7
  83. package/src/config/bundled-skills/contacts/tools/google-contacts.ts +1 -1
  84. package/src/config/bundled-skills/conversations/SKILL.md +20 -0
  85. package/src/config/bundled-skills/conversations/TOOLS.json +23 -0
  86. package/src/config/bundled-skills/conversations/tools/rename-conversation.ts +66 -0
  87. package/src/config/bundled-skills/gmail/SKILL.md +13 -13
  88. package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +3 -3
  89. package/src/config/bundled-skills/gmail/tools/gmail-attachments.ts +2 -2
  90. package/src/config/bundled-skills/gmail/tools/gmail-draft.ts +1 -1
  91. package/src/config/bundled-skills/gmail/tools/gmail-filters.ts +1 -1
  92. package/src/config/bundled-skills/gmail/tools/gmail-follow-up.ts +1 -1
  93. package/src/config/bundled-skills/gmail/tools/gmail-forward.ts +1 -1
  94. package/src/config/bundled-skills/gmail/tools/gmail-label.ts +2 -2
  95. package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +1 -1
  96. package/src/config/bundled-skills/gmail/tools/gmail-send-draft.ts +1 -1
  97. package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +1 -1
  98. package/src/config/bundled-skills/gmail/tools/gmail-trash.ts +1 -1
  99. package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +1 -1
  100. package/src/config/bundled-skills/gmail/tools/gmail-vacation.ts +1 -1
  101. package/src/config/bundled-skills/google-calendar/SKILL.md +10 -4
  102. package/src/config/bundled-skills/google-calendar/tools/shared.ts +1 -1
  103. package/src/config/bundled-skills/messaging/SKILL.md +7 -7
  104. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -2
  105. package/src/config/bundled-skills/messaging/tools/shared.ts +5 -6
  106. package/src/config/bundled-skills/settings/TOOLS.json +5 -3
  107. package/src/config/bundled-skills/settings/tools/navigate-settings-tab.ts +4 -2
  108. package/src/config/bundled-tool-registry.ts +5 -0
  109. package/src/config/feature-flag-registry.json +2 -2
  110. package/src/credential-execution/client.ts +15 -3
  111. package/src/daemon/conversation-agent-loop.ts +2 -0
  112. package/src/daemon/conversation-error.ts +36 -6
  113. package/src/daemon/conversation-messaging.ts +9 -0
  114. package/src/daemon/conversation-runtime-assembly.ts +33 -0
  115. package/src/daemon/conversation-surfaces.ts +120 -14
  116. package/src/daemon/conversation.ts +5 -0
  117. package/src/daemon/first-greeting.ts +6 -1
  118. package/src/daemon/handlers/skills.ts +148 -3
  119. package/src/daemon/host-bash-proxy.ts +16 -0
  120. package/src/daemon/host-cu-proxy.ts +16 -0
  121. package/src/daemon/host-file-proxy.ts +16 -0
  122. package/src/daemon/lifecycle.ts +56 -5
  123. package/src/daemon/message-types/conversations.ts +1 -0
  124. package/src/daemon/message-types/guardian-actions.ts +2 -0
  125. package/src/daemon/message-types/host-bash.ts +6 -1
  126. package/src/daemon/message-types/host-cu.ts +6 -1
  127. package/src/daemon/message-types/host-file.ts +6 -1
  128. package/src/daemon/message-types/integrations.ts +0 -1
  129. package/src/daemon/server.ts +29 -2
  130. package/src/hooks/cli.ts +74 -0
  131. package/src/inbound/platform-callback-registration.ts +7 -12
  132. package/src/index.ts +0 -12
  133. package/src/mcp/client.ts +6 -1
  134. package/src/mcp/manager.ts +2 -1
  135. package/src/memory/conversation-crud.ts +92 -3
  136. package/src/memory/conversation-key-store.ts +26 -0
  137. package/src/memory/conversation-queries.ts +6 -6
  138. package/src/memory/db-init.ts +16 -0
  139. package/src/memory/journal-memory.ts +8 -2
  140. package/src/memory/migrations/196-messages-conversation-created-at-index.ts +9 -0
  141. package/src/memory/migrations/196-strip-integration-prefix-from-provider-keys.ts +186 -0
  142. package/src/memory/migrations/197-oauth-providers-behavior-columns.ts +29 -0
  143. package/src/memory/migrations/198-drop-setup-skill-id-column.ts +11 -0
  144. package/src/memory/migrations/index.ts +4 -0
  145. package/src/memory/migrations/registry.ts +8 -0
  146. package/src/memory/schema/oauth.ts +11 -0
  147. package/src/messaging/provider.ts +13 -12
  148. package/src/messaging/providers/gmail/adapter.ts +44 -35
  149. package/src/messaging/providers/slack/adapter.ts +63 -33
  150. package/src/messaging/providers/telegram-bot/adapter.ts +6 -8
  151. package/src/messaging/providers/whatsapp/adapter.ts +6 -8
  152. package/src/notifications/adapters/telegram.ts +78 -2
  153. package/src/oauth/__tests__/identity-verifier.test.ts +464 -0
  154. package/src/oauth/byo-connection.test.ts +22 -24
  155. package/src/oauth/connect-orchestrator.ts +37 -76
  156. package/src/oauth/connect-types.ts +7 -65
  157. package/src/oauth/connection-resolver.test.ts +13 -13
  158. package/src/oauth/connection-resolver.ts +3 -4
  159. package/src/oauth/identity-verifier.ts +177 -0
  160. package/src/oauth/oauth-store.ts +228 -3
  161. package/src/oauth/platform-connection.test.ts +56 -6
  162. package/src/oauth/platform-connection.ts +8 -1
  163. package/src/oauth/seed-providers.ts +247 -34
  164. package/src/permissions/checker.ts +127 -1
  165. package/src/prompts/journal-context.ts +4 -1
  166. package/src/prompts/system-prompt.ts +54 -9
  167. package/src/prompts/templates/BOOTSTRAP.md +16 -5
  168. package/src/providers/anthropic/client.ts +2 -33
  169. package/src/runtime/guardian-action-service.ts +7 -2
  170. package/src/runtime/http-server.ts +12 -18
  171. package/src/runtime/http-types.ts +8 -1
  172. package/src/runtime/migrations/rebind-secrets-screen.ts +2 -2
  173. package/src/runtime/routes/conversation-management-routes.ts +31 -0
  174. package/src/runtime/routes/conversation-routes.ts +79 -4
  175. package/src/runtime/routes/guardian-action-routes.ts +15 -2
  176. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +21 -8
  177. package/src/runtime/routes/integrations/slack/share.ts +1 -1
  178. package/src/runtime/routes/oauth-apps.ts +2 -1
  179. package/src/runtime/routes/secret-routes.ts +45 -15
  180. package/src/runtime/routes/settings-routes.ts +12 -19
  181. package/src/runtime/routes/skills-routes.ts +45 -4
  182. package/src/schedule/integration-status.ts +2 -2
  183. package/src/security/ces-rpc-credential-backend.ts +19 -16
  184. package/src/security/oauth-completion-page.ts +153 -0
  185. package/src/security/oauth2.ts +3 -17
  186. package/src/security/secure-keys.ts +207 -7
  187. package/src/security/token-manager.ts +3 -6
  188. package/src/signals/bash.ts +6 -1
  189. package/src/skills/catalog-cache.ts +44 -0
  190. package/src/skills/catalog-search.ts +18 -0
  191. package/src/tools/browser/browser-manager.ts +2 -2
  192. package/src/tools/credentials/post-connect-hooks.ts +1 -1
  193. package/src/tools/credentials/vault.ts +34 -45
  194. package/src/tools/host-terminal/host-shell.ts +16 -3
  195. package/src/tools/mcp/mcp-tool-factory.ts +2 -1
  196. package/src/tools/skills/sandbox-runner.ts +16 -3
  197. package/src/tools/terminal/shell.ts +16 -3
  198. package/src/util/logger.ts +11 -1
  199. package/src/util/platform.ts +1 -91
  200. package/src/util/sentry-log-stream.ts +51 -0
  201. package/src/watcher/providers/github.ts +2 -2
  202. package/src/watcher/providers/gmail.ts +1 -1
  203. package/src/watcher/providers/google-calendar.ts +1 -1
  204. package/src/watcher/providers/linear.ts +2 -2
  205. package/src/workspace/migrations/011-backfill-installation-id.ts +5 -3
  206. package/src/workspace/migrations/020-rename-oauth-skill-dirs.ts +119 -0
  207. package/src/workspace/migrations/registry.ts +2 -0
  208. package/src/cli/commands/oauth/connections.ts +0 -255
  209. package/src/oauth/provider-behaviors.ts +0 -634
@@ -187,6 +187,32 @@ describe("HostCuProxy", () => {
187
187
  expect(proxy.hasPendingRequest(requestId)).toBe(false);
188
188
  });
189
189
 
190
+ test("sends host_cu_cancel to client on abort", async () => {
191
+ setup();
192
+
193
+ const controller = new AbortController();
194
+ const resultPromise = proxy.request(
195
+ "computer_use_click",
196
+ { element_id: 1 },
197
+ "session-1",
198
+ 1,
199
+ undefined,
200
+ controller.signal,
201
+ );
202
+
203
+ const sent = sentMessages[0] as Record<string, unknown>;
204
+ const requestId = sent.requestId as string;
205
+
206
+ controller.abort();
207
+ await resultPromise;
208
+
209
+ // Second message should be the cancel
210
+ expect(sentMessages).toHaveLength(2);
211
+ const cancelMsg = sentMessages[1] as Record<string, unknown>;
212
+ expect(cancelMsg.type).toBe("host_cu_cancel");
213
+ expect(cancelMsg.requestId).toBe(requestId);
214
+ });
215
+
190
216
  test("returns immediately if signal already aborted", async () => {
191
217
  setup();
192
218
 
@@ -684,6 +710,70 @@ describe("HostCuProxy", () => {
684
710
  expect(proxy.hasPendingRequest(requestId)).toBe(false);
685
711
  await expect(resultPromise).rejects.toThrow("Host CU proxy disposed");
686
712
  });
713
+
714
+ test("sends host_cu_cancel for each pending request on dispose", () => {
715
+ setup();
716
+
717
+ const p1 = proxy.request(
718
+ "computer_use_click",
719
+ { element_id: 1 },
720
+ "session-1",
721
+ 1,
722
+ );
723
+ const p2 = proxy.request(
724
+ "computer_use_type_text",
725
+ { text: "hello" },
726
+ "session-1",
727
+ 2,
728
+ );
729
+ p1.catch(() => {}); // Expected rejection on dispose
730
+ p2.catch(() => {}); // Expected rejection on dispose
731
+
732
+ const requestIds = (sentMessages as Array<Record<string, unknown>>).map(
733
+ (m) => m.requestId as string,
734
+ );
735
+ expect(requestIds).toHaveLength(2);
736
+
737
+ proxy.dispose();
738
+
739
+ // After the 2 request messages, dispose should have sent 2 cancel messages
740
+ const cancelMessages = sentMessages
741
+ .slice(2)
742
+ .filter(
743
+ (m) => (m as Record<string, unknown>).type === "host_cu_cancel",
744
+ ) as Array<Record<string, unknown>>;
745
+ expect(cancelMessages).toHaveLength(2);
746
+ expect(cancelMessages.map((m) => m.requestId)).toContain(requestIds[0]);
747
+ expect(cancelMessages.map((m) => m.requestId)).toContain(requestIds[1]);
748
+ });
749
+ });
750
+
751
+ describe("late resolve after abort", () => {
752
+ test("resolve is a no-op after abort (entry already deleted)", async () => {
753
+ setup();
754
+
755
+ const controller = new AbortController();
756
+ const resultPromise = proxy.request(
757
+ "computer_use_click",
758
+ { element_id: 1 },
759
+ "session-1",
760
+ 1,
761
+ undefined,
762
+ controller.signal,
763
+ );
764
+
765
+ const sent = sentMessages[0] as Record<string, unknown>;
766
+ const requestId = sent.requestId as string;
767
+
768
+ controller.abort();
769
+ const result = await resultPromise;
770
+ expect(result.content).toContain("Aborted");
771
+
772
+ // Late resolve should be silently ignored (no throw, no double-resolve)
773
+ proxy.resolve(requestId, { axTree: "late response" });
774
+
775
+ expect(proxy.hasPendingRequest(requestId)).toBe(false);
776
+ });
687
777
  });
688
778
 
689
779
  // -------------------------------------------------------------------------
@@ -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
  {
@@ -9,11 +9,13 @@ function makeContext(sendToClient?: (msg: unknown) => void): ToolContext {
9
9
 
10
10
  const CANONICAL_TABS = [
11
11
  "General",
12
- "Channels",
13
12
  "Models & Services",
14
13
  "Voice",
14
+ "Sounds",
15
15
  "Permissions & Privacy",
16
- "Contacts",
16
+ "Billing",
17
+ "Archived Conversations",
18
+ "Schedules",
17
19
  "Developer",
18
20
  ];
19
21
 
@@ -28,6 +30,8 @@ const LEGACY_TABS = [
28
30
  "Privacy",
29
31
  "Sentry Testing",
30
32
  "Automation",
33
+ "Channels",
34
+ "Contacts",
31
35
  ];
32
36
 
33
37
  describe("navigate-settings-tab", () => {