@vellumai/assistant 0.11.10 → 0.11.11-staging.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (199) hide show
  1. package/AGENTS.md +1 -1
  2. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  3. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
  6. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
  8. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
  10. package/node_modules/@vellumai/service-contracts/package.json +2 -1
  11. package/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
  12. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  13. package/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
  14. package/openapi.yaml +152 -55
  15. package/package.json +1 -1
  16. package/src/__tests__/anthropic-provider.test.ts +60 -0
  17. package/src/__tests__/config-get-vision-flag.test.ts +36 -0
  18. package/src/__tests__/conversation-error.test.ts +38 -0
  19. package/src/__tests__/copy-composer-tc-templates.test.ts +2 -2
  20. package/src/__tests__/guardian-action-service-scope.test.ts +53 -0
  21. package/src/__tests__/headless-browser-mode.test.ts +63 -13
  22. package/src/__tests__/heartbeat-disk-pressure.test.ts +2 -0
  23. package/src/__tests__/heartbeat-service.test.ts +118 -27
  24. package/src/__tests__/host-app-control-proxy.test.ts +2 -2
  25. package/src/__tests__/host-app-control-routes.test.ts +41 -0
  26. package/src/__tests__/host-bash-routes.test.ts +30 -1
  27. package/src/__tests__/host-browser-routes.test.ts +34 -0
  28. package/src/__tests__/host-cu-routes-targeted.test.ts +29 -0
  29. package/src/__tests__/host-file-routes-targeted.test.ts +29 -0
  30. package/src/__tests__/host-proxy-base.test.ts +1 -0
  31. package/src/__tests__/host-transfer-routes-targeted.test.ts +86 -0
  32. package/src/__tests__/llm-schema.test.ts +32 -0
  33. package/src/__tests__/mcp-config-secret-boundary.test.ts +2 -0
  34. package/src/__tests__/memory-config-file-parity.test.ts +18 -0
  35. package/src/__tests__/memory-retrieval-hook.test.ts +15 -0
  36. package/src/__tests__/messaging-send-tool.test.ts +159 -13
  37. package/src/__tests__/messaging-shared.test.ts +19 -0
  38. package/src/__tests__/notification-events-source-context-probe.test.ts +211 -0
  39. package/src/__tests__/openai-responses-prompt-cache.test.ts +40 -6
  40. package/src/__tests__/outlook-messaging-provider.test.ts +9 -0
  41. package/src/__tests__/provider-catalog-visibility.test.ts +2 -2
  42. package/src/__tests__/schedule-routes.test.ts +30 -1
  43. package/src/__tests__/scheduler-result-notification.test.ts +209 -0
  44. package/src/__tests__/skills-install-extract.test.ts +26 -0
  45. package/src/__tests__/skills-install-staging.test.ts +11 -2
  46. package/src/api/constants/personality-sliders.test.ts +21 -0
  47. package/src/api/constants/personality-sliders.ts +64 -0
  48. package/src/api/index.ts +8 -0
  49. package/src/archive/ustar-size.test.ts +32 -0
  50. package/src/archive/ustar-size.ts +23 -0
  51. package/src/background-wake/next-wake.test.ts +13 -0
  52. package/src/background-wake/next-wake.ts +4 -2
  53. package/src/calls/voice-session-bridge.ts +26 -0
  54. package/src/cli/__tests__/catalog-search-help.test.ts +1 -0
  55. package/src/cli/commands/__tests__/plugins-search.test.ts +81 -0
  56. package/src/cli/commands/config.help.ts +3 -1
  57. package/src/cli/commands/memory/worker.ts +1 -1
  58. package/src/cli/commands/platform/__tests__/credits-format.test.ts +94 -5
  59. package/src/cli/commands/platform/__tests__/credits.test.ts +10 -0
  60. package/src/cli/commands/platform/credits-format.ts +57 -0
  61. package/src/cli/commands/platform/index.help.ts +19 -3
  62. package/src/cli/commands/plugins.help.ts +4 -1
  63. package/src/cli/commands/plugins.ts +2 -1
  64. package/src/cli/lib/bundled-marketplace.json +1 -1
  65. package/src/cli/lib/install-from-platform.ts +8 -10
  66. package/src/config/bundled-skills/messaging/SKILL.md +2 -0
  67. package/src/config/bundled-skills/messaging/TOOLS.json +4 -4
  68. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +85 -1
  69. package/src/config/bundled-skills/messaging/tools/shared.ts +6 -0
  70. package/src/config/bundled-skills/schedule/SKILL.md +9 -1
  71. package/src/config/bundled-skills/screen-annotation/SKILL.md +87 -20
  72. package/src/config/bundled-skills/screen-annotation/TOOLS.json +21 -5
  73. package/src/config/bundled-skills/screen-annotation/tools/screen-annotation.test.ts +67 -0
  74. package/src/config/feature-flag-registry.json +17 -1
  75. package/src/config/input-modalities.test.ts +71 -0
  76. package/src/config/input-modalities.ts +95 -0
  77. package/src/config/schemas/heartbeat.ts +3 -3
  78. package/src/config/schemas/llm.ts +13 -3
  79. package/src/config/schemas/memory-storage.test.ts +39 -0
  80. package/src/config/schemas/memory-storage.ts +21 -0
  81. package/src/context/token-estimator.ts +2 -2
  82. package/src/daemon/__tests__/embedding-reconcile.test.ts +77 -0
  83. package/src/daemon/__tests__/lifecycle-embedding-reconcile.test.ts +3 -0
  84. package/src/daemon/conversation-error.ts +20 -7
  85. package/src/daemon/embedding-reconcile.ts +64 -1
  86. package/src/daemon/handlers/config-embeddings.ts +31 -0
  87. package/src/daemon/host-app-control-proxy.ts +2 -0
  88. package/src/daemon/host-bash-proxy.ts +1 -0
  89. package/src/daemon/host-cu-proxy.ts +6 -6
  90. package/src/daemon/host-file-proxy.ts +6 -6
  91. package/src/daemon/host-proxy-base.ts +10 -9
  92. package/src/daemon/host-transfer-proxy.ts +21 -24
  93. package/src/daemon/mcp-reload-service.ts +6 -0
  94. package/src/daemon/providers-setup.ts +5 -26
  95. package/src/heartbeat/heartbeat-service.ts +72 -13
  96. package/src/live-voice/__tests__/live-voice-photo.test.ts +72 -0
  97. package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +81 -0
  98. package/src/live-voice/live-voice-photo.ts +68 -4
  99. package/src/live-voice/live-voice-session.ts +36 -2
  100. package/src/live-voice/protocol.ts +98 -1
  101. package/src/mcp/__tests__/reload-signal-emission.test.ts +54 -0
  102. package/src/mcp/__tests__/reload-signal.test.ts +57 -0
  103. package/src/mcp/__tests__/startup.test.ts +150 -0
  104. package/src/mcp/reload-signal.ts +50 -0
  105. package/src/mcp/startup.ts +101 -0
  106. package/src/messaging/providers/outlook/adapter.ts +2 -13
  107. package/src/messaging/providers/outlook/client.ts +13 -1
  108. package/src/messaging/providers/outlook/types.ts +3 -0
  109. package/src/notifications/__tests__/decision-engine.test.ts +79 -6
  110. package/src/notifications/__tests__/home-feed-side-effect.test.ts +27 -0
  111. package/src/notifications/__tests__/schedule-result-producer.test.ts +397 -0
  112. package/src/notifications/copy-composer.ts +33 -11
  113. package/src/notifications/decision-engine.ts +48 -11
  114. package/src/notifications/events-store.ts +68 -2
  115. package/src/notifications/home-feed-side-effect.ts +15 -3
  116. package/src/notifications/schedule-result-producer.ts +275 -0
  117. package/src/notifications/signal.ts +5 -0
  118. package/src/oauth/seed-providers.ts +3 -2
  119. package/src/persistence/conversation-crud.ts +38 -0
  120. package/src/persistence/embeddings/embedding-backend.test.ts +162 -0
  121. package/src/persistence/embeddings/embedding-backend.ts +148 -11
  122. package/src/persistence/embeddings/embedding-openai.test.ts +113 -0
  123. package/src/persistence/embeddings/embedding-openai.ts +55 -4
  124. package/src/persistence/embeddings/embedding-types.test.ts +24 -0
  125. package/src/persistence/embeddings/embedding-types.ts +25 -1
  126. package/src/persistence/job-utils.ts +6 -2
  127. package/src/plugin-api/index.ts +2 -1
  128. package/src/plugin-api/vision-support.test.ts +81 -0
  129. package/src/plugin-api/vision-support.ts +57 -10
  130. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +8 -0
  131. package/src/plugins/defaults/memory/jobs/__tests__/embed-concept-page.test.ts +1 -0
  132. package/src/plugins/defaults/memory/jobs/embed-concept-page.ts +11 -3
  133. package/src/plugins/defaults/memory/src/__tests__/memory-worker-routes.test.ts +20 -0
  134. package/src/plugins/defaults/memory/src/memory-worker-routes.ts +3 -1
  135. package/src/plugins/defaults/memory/substrate/__tests__/reembed-job.test.ts +1 -0
  136. package/src/plugins/defaults/memory/v2/__tests__/backfill-jobs.test.ts +1 -0
  137. package/src/plugins/defaults/memory/v3/__tests__/section-dense-store.test.ts +82 -0
  138. package/src/plugins/defaults/memory/v3/section-dense-store.ts +9 -27
  139. package/src/providers/__tests__/provider-secret-catalog.test.ts +4 -0
  140. package/src/providers/__tests__/xml-tool-call-salvage.test.ts +172 -0
  141. package/src/providers/anthropic/client.ts +9 -4
  142. package/src/providers/inline-audio-support.test.ts +34 -0
  143. package/src/providers/inline-audio-support.ts +27 -0
  144. package/src/providers/model-catalog.ts +22 -2
  145. package/src/providers/openai/__tests__/api-error-normalization.test.ts +50 -0
  146. package/src/providers/openai/__tests__/chat-completions-extra-body.test.ts +51 -0
  147. package/src/providers/openai/__tests__/chat-completions-provider-xml-salvage.test.ts +291 -0
  148. package/src/providers/openai/api-error-normalization.ts +18 -3
  149. package/src/providers/openai/chat-completions-provider.ts +113 -13
  150. package/src/providers/openai/responses-provider.ts +10 -8
  151. package/src/providers/provider-secret-catalog.ts +11 -1
  152. package/src/providers/vellum/client.ts +14 -1
  153. package/src/providers/vellum/personality-directions.test.ts +163 -0
  154. package/src/providers/vellum/personality-directions.ts +120 -0
  155. package/src/providers/xml-tool-call-salvage.ts +212 -0
  156. package/src/runtime/__tests__/background-job-runner.test.ts +42 -4
  157. package/src/runtime/auth/__tests__/guard-tests.test.ts +1 -6
  158. package/src/runtime/auth/__tests__/route-policy.test.ts +65 -0
  159. package/src/runtime/auth/same-actor.ts +29 -5
  160. package/src/runtime/background-job-runner.ts +38 -29
  161. package/src/runtime/guardian-action-service.ts +20 -1
  162. package/src/runtime/migrations/vbundle-validator.ts +3 -3
  163. package/src/runtime/pending-interactions.ts +4 -5
  164. package/src/runtime/routes/__tests__/heartbeat-routes.test.ts +24 -0
  165. package/src/runtime/routes/__tests__/platform-credits-routes.test.ts +99 -5
  166. package/src/runtime/routes/conversation-query-routes.ts +53 -11
  167. package/src/runtime/routes/document-comments-routes.ts +8 -2
  168. package/src/runtime/routes/guardian-action-routes.ts +7 -0
  169. package/src/runtime/routes/heartbeat-routes.ts +41 -53
  170. package/src/runtime/routes/host-app-control-routes.ts +10 -36
  171. package/src/runtime/routes/host-bash-routes.ts +11 -45
  172. package/src/runtime/routes/host-browser-routes.ts +24 -0
  173. package/src/runtime/routes/host-cu-routes.ts +11 -45
  174. package/src/runtime/routes/host-file-routes.ts +11 -44
  175. package/src/runtime/routes/host-proxy-result-binding.ts +57 -0
  176. package/src/runtime/routes/host-transfer-routes.ts +33 -25
  177. package/src/runtime/routes/index.ts +0 -2
  178. package/src/runtime/routes/integrations/a2a.ts +12 -3
  179. package/src/runtime/routes/integrations/vercel.ts +13 -3
  180. package/src/runtime/routes/platform-routes.ts +121 -24
  181. package/src/runtime/routes/schedule-routes.ts +15 -1
  182. package/src/schedule/__tests__/schedule-timezone.test.ts +21 -3
  183. package/src/schedule/__tests__/worker-mcp-tools.test.ts +88 -0
  184. package/src/schedule/schedule-timezone.ts +25 -0
  185. package/src/schedule/scheduler.ts +19 -0
  186. package/src/schedule/worker.ts +119 -2
  187. package/src/skills/catalog-install.ts +40 -5
  188. package/src/tools/browser/__tests__/browser-status.test.ts +3 -0
  189. package/src/tools/browser/browser-execution.ts +117 -53
  190. package/src/tools/browser/browser-status-constants.ts +6 -6
  191. package/src/tools/capability-offer.test.ts +87 -0
  192. package/src/tools/capability-offer.ts +93 -0
  193. package/src/tools/terminal/__tests__/safe-env.test.ts +17 -0
  194. package/src/tools/terminal/safe-env.ts +3 -2
  195. package/src/util/provider-error-patterns.ts +34 -0
  196. package/src/watcher/__tests__/engine.test.ts +5 -2
  197. package/src/watcher/engine.ts +7 -4
  198. package/src/runtime/routes/__tests__/sight-frame-routes.test.ts +0 -487
  199. package/src/runtime/routes/sight-frame-routes.ts +0 -136
@@ -1323,6 +1323,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1323
1323
  // older one is simply out of date. Consumed (and cleared) when a turn
1324
1324
  // launches, handed back if that turn is rolled back, and cleared on close.
1325
1325
  private pendingTurnAttachmentId: string | null = null;
1326
+ // When `speech_started` last went to the client, for the sight-frame log:
1327
+ // the distance from it to a keep arriving is the client leg of the frame the
1328
+ // onset asked for, measured from the daemon's own clock.
1329
+ private lastSpeechStartedAtMs: number | null = null;
1326
1330
  private readonly maxPendingAudioBytes: number;
1327
1331
  // Set on VAD speech onset; consumed when the first speech chunk is routed
1328
1332
  // to an utterance so the metric lands on the right turn.
@@ -1719,11 +1723,32 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1719
1723
  * same fact.
1720
1724
  */
1721
1725
  private persistSightFrame(frame: LiveVoiceClientSightFrameFrame): void {
1726
+ const receivedAtMs = Date.now();
1727
+ // One line per keep, written when the row lands, carrying the whole
1728
+ // timeline: the client leg the frame reported, the daemon leg the persist
1729
+ // measured, and the distance from the speech onset the frame may have
1730
+ // been asked for. Together with the turn's own "Voice turn dispatch
1731
+ // timing" line this says whether a frame missed its turn and where.
1732
+ const sinceSpeechStartedMs =
1733
+ this.lastSpeechStartedAtMs === null
1734
+ ? null
1735
+ : receivedAtMs - this.lastSpeechStartedAtMs;
1722
1736
  void persistAmbientSightFrame(
1723
1737
  this.conversationId,
1724
1738
  frame.attachmentId,
1725
1739
  "voice",
1726
1740
  ).then((result) => {
1741
+ log.info(
1742
+ {
1743
+ attachmentId: frame.attachmentId,
1744
+ persisted: result.ok,
1745
+ sinceSpeechStartedMs,
1746
+ client: frame.timing ?? null,
1747
+ daemon: result.timing ?? null,
1748
+ daemonTotalMs: Date.now() - receivedAtMs,
1749
+ },
1750
+ "Sight frame timing",
1751
+ );
1727
1752
  if (!result.ok && !this.isClosed) {
1728
1753
  void this.sendFrame({
1729
1754
  type: "error",
@@ -2790,12 +2815,21 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
2790
2815
 
2791
2816
  this.pendingBargeIn = null;
2792
2817
  this.assistantPlaybackTailUntilMs = 0;
2793
- void this.sendFrame({ type: "speech_started" });
2818
+ this.sendSpeechStarted();
2794
2819
  if (bargeableTurn) {
2795
2820
  this.bargeIn(bargeableTurn);
2796
2821
  }
2797
2822
  }
2798
2823
 
2824
+ /**
2825
+ * Tell the client the caller started speaking, and remember when, so a
2826
+ * camera frame that follows can be logged against the onset it answers.
2827
+ */
2828
+ private sendSpeechStarted(): void {
2829
+ this.lastSpeechStartedAtMs = Date.now();
2830
+ void this.sendFrame({ type: "speech_started" });
2831
+ }
2832
+
2799
2833
  // Advance the sustained-speech barge-in guard by one server-VAD chunk.
2800
2834
  // Speech accumulates toward bargeInMinSpeechMs, short true-silence gaps are
2801
2835
  // tolerated, and classified playback echo resets the run immediately.
@@ -2841,7 +2875,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
2841
2875
  }
2842
2876
  this.pendingBargeIn = null;
2843
2877
  this.assistantPlaybackTailUntilMs = 0;
2844
- void this.sendFrame({ type: "speech_started" });
2878
+ this.sendSpeechStarted();
2845
2879
  const { turn } = guard;
2846
2880
  if (turn && turn === this.activeAssistantTurn && !turn.finalized) {
2847
2881
  this.bargeIn(turn);
@@ -312,6 +312,44 @@ export interface LiveVoiceClientAttachFrameFrame {
312
312
  export interface LiveVoiceClientSightFrameFrame {
313
313
  readonly type: "sight_frame";
314
314
  readonly attachmentId: string;
315
+ /**
316
+ * How long the client's half of the frame took, for the daemon's log. The
317
+ * daemon adds its own half and the distance from the speech onset it
318
+ * announced, which is what makes a frame that answered the wrong question
319
+ * legible after the fact. Optional: an older client sends none.
320
+ */
321
+ readonly timing?: LiveVoiceSightFrameTiming;
322
+ }
323
+
324
+ /**
325
+ * The client leg of one kept frame, as durations between its own marks.
326
+ *
327
+ * Durations rather than timestamps because the two clocks are not the same
328
+ * clock: the client stamps from `performance.now` and the daemon from wall
329
+ * time, and only the client can say how long its encode and upload took.
330
+ * Every field is a non-negative whole number of milliseconds.
331
+ */
332
+ export interface LiveVoiceSightFrameTiming {
333
+ /**
334
+ * Why the gate kept the frame: `forced` is the keep a speech onset asked
335
+ * for, everything else is the ambient cadence. Free-form so a new gate
336
+ * reason needs no daemon change to be logged.
337
+ */
338
+ readonly reason: string;
339
+ /**
340
+ * From the arm that asked for this keep to the keep itself. Present only on
341
+ * a forced keep, where it is the distance from the client hearing
342
+ * `speech_started` to a frame that postdates it.
343
+ */
344
+ readonly armToKeepMs?: number;
345
+ /** From the keep to a JPEG in hand, sized for upload. */
346
+ readonly keepToEncodedMs: number;
347
+ /** From the JPEG to the attachment id, which is the HTTP upload. */
348
+ readonly encodedToUploadedMs: number;
349
+ /** From the id to the send, which is the wait for older keeps to go first. */
350
+ readonly uploadedToSentMs: number;
351
+ /** The JPEG that was uploaded, in bytes. */
352
+ readonly bytes: number;
315
353
  }
316
354
 
317
355
  /**
@@ -932,9 +970,68 @@ function validateSightFrameFrame(
932
970
  );
933
971
  }
934
972
 
973
+ if (!("timing" in value) || value.timing === undefined) {
974
+ return {
975
+ ok: true,
976
+ frame: { type: "sight_frame", attachmentId: value.attachmentId },
977
+ };
978
+ }
979
+
980
+ const timing = validateSightFrameTiming(value.timing);
981
+ if (timing === null) {
982
+ return protocolError(
983
+ "invalid_field",
984
+ "sight_frame frame field timing must carry non-negative integer durations and a reason",
985
+ "timing",
986
+ "sight_frame",
987
+ );
988
+ }
989
+
935
990
  return {
936
991
  ok: true,
937
- frame: { type: "sight_frame", attachmentId: value.attachmentId },
992
+ frame: { type: "sight_frame", attachmentId: value.attachmentId, timing },
993
+ };
994
+ }
995
+
996
+ /**
997
+ * Parse a `sight_frame`'s `timing`. Null for anything off-shape: a timing is
998
+ * for the log, so nothing about it is coerced, but a malformed one still
999
+ * refuses the frame, since a client that sends one means to send a whole one.
1000
+ */
1001
+ function validateSightFrameTiming(
1002
+ value: unknown,
1003
+ ): LiveVoiceSightFrameTiming | null {
1004
+ if (typeof value !== "object" || value === null) {
1005
+ return null;
1006
+ }
1007
+ const record = value as Record<string, unknown>;
1008
+ const isDuration = (duration: unknown): duration is number =>
1009
+ isIntInRange(duration, 0, Number.MAX_SAFE_INTEGER);
1010
+ const {
1011
+ reason,
1012
+ armToKeepMs,
1013
+ keepToEncodedMs,
1014
+ encodedToUploadedMs,
1015
+ uploadedToSentMs,
1016
+ bytes,
1017
+ } = record;
1018
+ if (
1019
+ !isNonEmptyString(reason) ||
1020
+ !isDuration(keepToEncodedMs) ||
1021
+ !isDuration(encodedToUploadedMs) ||
1022
+ !isDuration(uploadedToSentMs) ||
1023
+ !isDuration(bytes) ||
1024
+ (armToKeepMs !== undefined && !isDuration(armToKeepMs))
1025
+ ) {
1026
+ return null;
1027
+ }
1028
+ return {
1029
+ reason,
1030
+ ...(armToKeepMs !== undefined ? { armToKeepMs } : {}),
1031
+ keepToEncodedMs,
1032
+ encodedToUploadedMs,
1033
+ uploadedToSentMs,
1034
+ bytes,
938
1035
  };
939
1036
  }
940
1037
 
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The daemon's MCP reload is what announces itself to the other processes.
3
+ *
4
+ * Every path that changes the server set (a config edit, the reload route, an
5
+ * OAuth reauthentication, a plugin install) funnels through `reloadMcpServers`,
6
+ * so signalling there covers all of them. Signalling anywhere narrower would
7
+ * leave whichever path was missed silently stale in the schedule worker.
8
+ */
9
+
10
+ import { existsSync, rmSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { beforeEach, describe, expect, jest, mock, test } from "bun:test";
13
+
14
+ mock.module("../manager.js", () => ({
15
+ getMcpServerManager: () => ({
16
+ start: jest.fn(async () => []),
17
+ stop: jest.fn(async () => {}),
18
+ callTool: jest.fn(),
19
+ getClient: () => undefined,
20
+ }),
21
+ stopMcpServerManager: jest.fn(async () => {}),
22
+ }));
23
+
24
+ mock.module("../mcp-header-store.js", () => ({
25
+ migrateLegacyMcpHeaders: async () => {},
26
+ }));
27
+
28
+ mock.module("../../plugins/mcp-servers.js", () => ({
29
+ readPluginMcpServers: () => ({ servers: [], issues: [] }),
30
+ }));
31
+
32
+ const { getSignalsDir } = await import("../../util/platform.js");
33
+ const { MCP_RELOAD_SIGNAL_FILE } = await import("../reload-signal.js");
34
+ const { reloadMcpServers } = await import("../../daemon/mcp-reload-service.js");
35
+
36
+ const signalPath = () => join(getSignalsDir(), MCP_RELOAD_SIGNAL_FILE);
37
+
38
+ beforeEach(() => {
39
+ rmSync(signalPath(), { force: true });
40
+ });
41
+
42
+ describe("reloadMcpServers", () => {
43
+ test("signals the reload so other processes rebuild their own set", async () => {
44
+ const result = await reloadMcpServers();
45
+
46
+ expect(result.success).toBe(true);
47
+ expect(
48
+ existsSync(signalPath()),
49
+ "Connections and the tools they register are per-process. A reload " +
50
+ "that only rebuilds the daemon's leaves the schedule worker calling " +
51
+ "servers the config no longer lists.",
52
+ ).toBe(true);
53
+ });
54
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The MCP reload signal, the channel that tells the daemon's child processes
3
+ * their MCP server set moved.
4
+ *
5
+ * They hold their own connections to the same servers and have no config
6
+ * watcher of their own, so without this they keep serving the set as it stood
7
+ * when they started: a server the config has since disabled stays callable
8
+ * there long after the daemon dropped it.
9
+ */
10
+
11
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { beforeEach, describe, expect, test } from "bun:test";
14
+
15
+ import { getSignalsDir } from "../../util/platform.js";
16
+ import { MCP_RELOAD_SIGNAL_FILE, signalMcpReloaded } from "../reload-signal.js";
17
+
18
+ const signalPath = () => join(getSignalsDir(), MCP_RELOAD_SIGNAL_FILE);
19
+
20
+ beforeEach(() => {
21
+ rmSync(signalPath(), { force: true });
22
+ });
23
+
24
+ describe("signalMcpReloaded", () => {
25
+ test("writes the signal file", () => {
26
+ signalMcpReloaded();
27
+
28
+ expect(existsSync(signalPath())).toBe(true);
29
+ expect(Number(readFileSync(signalPath(), "utf8"))).toBeGreaterThan(0);
30
+ });
31
+
32
+ test("creates the signals directory when it is absent", () => {
33
+ rmSync(getSignalsDir(), { recursive: true, force: true });
34
+
35
+ signalMcpReloaded();
36
+
37
+ expect(existsSync(signalPath())).toBe(true);
38
+ });
39
+
40
+ test("rewrites in place, so a second reload is a fresh event", () => {
41
+ mkdirSync(getSignalsDir(), { recursive: true });
42
+ signalMcpReloaded();
43
+ const first = readFileSync(signalPath(), "utf8");
44
+
45
+ const until = Date.now() + 2;
46
+ while (Date.now() < until) {
47
+ /* let the clock move so the two writes differ */
48
+ }
49
+ signalMcpReloaded();
50
+
51
+ expect(
52
+ readFileSync(signalPath(), "utf8"),
53
+ "Readers react to the write rather than consuming the file, so the " +
54
+ "content has to change for a second reload to register.",
55
+ ).not.toBe(first);
56
+ });
57
+ });
@@ -0,0 +1,150 @@
1
+ /**
2
+ * MCP tools reach a process's tool registry only by connecting to each server
3
+ * and listing what it offers. `initializeTools()` does not do that: it loads
4
+ * core built-ins and workspace tools from disk. So every process that hosts
5
+ * agent turns has to run this step for itself or its `mcp__*` calls fail as
6
+ * "Unknown tool" while `assistant tools list`, which reads the daemon's
7
+ * registry over IPC, reports the same tool as registered.
8
+ *
9
+ * These cover the shared step itself: that it registers what a server reports,
10
+ * that a server it cannot reach leaves the process running anyway, and that a
11
+ * restart rebuilds the set rather than adding to it. The guard that the
12
+ * schedule worker actually calls it lives in
13
+ * `src/schedule/__tests__/worker-mcp-tools.test.ts`, next to the worker.
14
+ */
15
+
16
+ import { afterEach, describe, expect, jest, mock, test } from "bun:test";
17
+
18
+ const start = jest.fn(async () => [] as unknown[]);
19
+
20
+ mock.module("../manager.js", () => ({
21
+ getMcpServerManager: () => ({
22
+ start,
23
+ callTool: jest.fn(),
24
+ getClient: () => undefined,
25
+ }),
26
+ stopMcpServerManager: jest.fn(async () => {}),
27
+ }));
28
+
29
+ // Plugin-declared servers are read off disk, so leaving this real would make
30
+ // "no servers configured" depend on which plugins the machine running the
31
+ // tests happens to have installed.
32
+ mock.module("../../plugins/mcp-servers.js", () => ({
33
+ readPluginMcpServers: () => ({ servers: [], issues: [] }),
34
+ }));
35
+
36
+ const { restartConfiguredMcpServers, startConfiguredMcpServers } =
37
+ await import("../startup.js");
38
+ const { __resetRegistryForTesting, getTool } =
39
+ await import("../../tools/registry.js");
40
+
41
+ /** One entry of `McpServerManager.start()`'s result: a server and its tools. */
42
+ function serverReporting(serverId: string, toolName: string) {
43
+ return {
44
+ serverId,
45
+ serverConfig: {
46
+ transport: {
47
+ type: "streamable-http",
48
+ url: "https://example.invalid/mcp",
49
+ },
50
+ enabled: true,
51
+ defaultRiskLevel: "low",
52
+ maxTools: 20,
53
+ source: "workspace",
54
+ },
55
+ tools: [
56
+ {
57
+ name: toolName,
58
+ description: "Search email",
59
+ inputSchema: { type: "object", properties: {} },
60
+ annotations: { readOnlyHint: true },
61
+ },
62
+ ],
63
+ };
64
+ }
65
+
66
+ /** The `mcp` config block shape a workspace config carries for one server. */
67
+ function workspaceConfigWith(serverId: string) {
68
+ return {
69
+ servers: {
70
+ [serverId]: {
71
+ transport: {
72
+ type: "streamable-http" as const,
73
+ url: "https://example.invalid/mcp",
74
+ },
75
+ enabled: true,
76
+ defaultRiskLevel: "low" as const,
77
+ maxTools: 20,
78
+ },
79
+ },
80
+ globalMaxTools: 50,
81
+ };
82
+ }
83
+
84
+ afterEach(() => {
85
+ start.mockClear();
86
+ __resetRegistryForTesting();
87
+ });
88
+
89
+ describe("startConfiguredMcpServers", () => {
90
+ test("registers the tools a server reports, namespaced by server id", async () => {
91
+ start.mockResolvedValueOnce([serverReporting("fastmail", "search_email")]);
92
+
93
+ const registered = await startConfiguredMcpServers(
94
+ workspaceConfigWith("fastmail") as never,
95
+ );
96
+
97
+ expect(registered).toBe(1);
98
+ expect(getTool("mcp__fastmail__search_email")).toBeDefined();
99
+ });
100
+
101
+ test("does nothing when no servers are configured", async () => {
102
+ const registered = await startConfiguredMcpServers(undefined);
103
+
104
+ expect(registered).toBe(0);
105
+ expect(start).not.toHaveBeenCalled();
106
+ });
107
+
108
+ test("a server that cannot be started leaves the process running", async () => {
109
+ start.mockRejectedValueOnce(new Error("connection refused"));
110
+
111
+ const registered = await startConfiguredMcpServers(
112
+ workspaceConfigWith("fastmail") as never,
113
+ );
114
+
115
+ expect(registered).toBe(0);
116
+ });
117
+ });
118
+
119
+ describe("restartConfiguredMcpServers", () => {
120
+ test("drops tools the reconnected set no longer reports", async () => {
121
+ start.mockResolvedValueOnce([serverReporting("fastmail", "search_email")]);
122
+ await startConfiguredMcpServers(workspaceConfigWith("fastmail") as never);
123
+ expect(getTool("mcp__fastmail__search_email")).toBeDefined();
124
+
125
+ // The test workspace config declares no MCP servers, which is what a
126
+ // removed or disabled one looks like on the next connect.
127
+ await restartConfiguredMcpServers();
128
+
129
+ expect(
130
+ getTool("mcp__fastmail__search_email"),
131
+ "A server the config dropped has to stop being callable here. Leaving " +
132
+ "its tools registered is what lets a background schedule keep " +
133
+ "reaching a server the daemon has already disconnected.",
134
+ ).toBeUndefined();
135
+ });
136
+
137
+ test("reconnects from the config on disk rather than the previous set", async () => {
138
+ start.mockResolvedValueOnce([serverReporting("fastmail", "search_email")]);
139
+ await startConfiguredMcpServers(workspaceConfigWith("fastmail") as never);
140
+ start.mockClear();
141
+
142
+ await restartConfiguredMcpServers();
143
+
144
+ expect(
145
+ start,
146
+ "The previous effective config is not reusable: it is the thing that " +
147
+ "changed. A restart rereads it.",
148
+ ).not.toHaveBeenCalled();
149
+ });
150
+ });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Cross-process notification that the MCP server set has been reloaded.
3
+ *
4
+ * MCP connections and the tools they register are per-process, and so is the
5
+ * reload that rebuilds them: `daemon/mcp-reload-service.ts` acts on the
6
+ * daemon's own manager and registry. Every other process that holds
7
+ * connections has to hear about it, or it keeps serving the server set as it
8
+ * stood when that process started, including servers the config has since
9
+ * disabled or removed.
10
+ *
11
+ * The channel is a file in the signals directory, written by the reloading
12
+ * process and watched by the others. A file rather than an IPC call because
13
+ * the listeners are the daemon's own children: a call would need the daemon to
14
+ * track who is listening and to stay up while it delivers, and the reload has
15
+ * no reason to wait for either.
16
+ *
17
+ * The file's content is a timestamp, and it is never deleted. Readers react to
18
+ * the write, so there is nothing to consume and no race over who consumes it.
19
+ */
20
+
21
+ import { mkdirSync, writeFileSync } from "node:fs";
22
+ import { join } from "node:path";
23
+
24
+ import { getLogger } from "../util/logger.js";
25
+ import { getSignalsDir } from "../util/platform.js";
26
+
27
+ const log = getLogger("mcp-reload-signal");
28
+
29
+ /** Name of the signal file within the signals directory. */
30
+ export const MCP_RELOAD_SIGNAL_FILE = "mcp-reloaded";
31
+
32
+ /**
33
+ * Announce that this process reloaded its MCP servers.
34
+ *
35
+ * Best-effort: a signals directory that cannot be written leaves other
36
+ * processes on their existing connections, which is where they already were,
37
+ * so it is logged rather than raised into the reload's result.
38
+ */
39
+ export function signalMcpReloaded(): void {
40
+ const signalsDir = getSignalsDir();
41
+ try {
42
+ mkdirSync(signalsDir, { recursive: true });
43
+ writeFileSync(join(signalsDir, MCP_RELOAD_SIGNAL_FILE), String(Date.now()));
44
+ } catch (err) {
45
+ log.warn(
46
+ { err, signalsDir },
47
+ "Failed to write the MCP reload signal; other processes keep their current servers",
48
+ );
49
+ }
50
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Connect the configured MCP servers and register their tools.
3
+ *
4
+ * MCP tools are not loaded from disk the way core and workspace tools are:
5
+ * something has to connect to each server, list its tools, and register the
6
+ * results into this process's registry. That registry is per-process, so every
7
+ * process that hosts agent turns has to run it for itself: the daemon at boot
8
+ * and the schedule worker at startup alike. A process that skips this step has
9
+ * the tools missing entirely rather than filtered out, and every `mcp__*` call
10
+ * made in it fails as "Unknown tool".
11
+ *
12
+ * One home for the step so the processes that run it cannot drift, and so a
13
+ * further process that starts hosting turns has one call to make.
14
+ */
15
+
16
+ import { getConfig, invalidateConfigCache } from "../config/loader.js";
17
+ import type { McpConfig } from "../config/schemas/mcp.js";
18
+ import { createMcpToolsFromServer } from "../tools/mcp/mcp-tool-factory.js";
19
+ import { registerMcpTools, unregisterAllMcpTools } from "../tools/registry.js";
20
+ import { getLogger } from "../util/logger.js";
21
+ import { buildEffectiveMcpConfig } from "./effective-config.js";
22
+ import { getMcpServerManager, stopMcpServerManager } from "./manager.js";
23
+
24
+ const log = getLogger("mcp-startup");
25
+
26
+ /**
27
+ * Start every enabled server in the effective MCP config (workspace entries
28
+ * plus plugin-declared ones) and register the tools they report.
29
+ *
30
+ * Call after {@link initializeTools}, so core and workspace tools already own
31
+ * their names when MCP registrations resolve: `registerMcpTools` yields to an
32
+ * existing owner rather than displacing it.
33
+ *
34
+ * Never throws: a server that cannot be reached is logged and skipped, and a
35
+ * failure of the whole step leaves the process running without MCP tools. This
36
+ * is a startup step in processes whose other work must not be held hostage to
37
+ * a third-party server being up.
38
+ *
39
+ * @param workspaceMcpConfig The `mcp` block of the assistant config, if any.
40
+ * @returns The number of tools registered across all servers.
41
+ */
42
+ export async function startConfiguredMcpServers(
43
+ workspaceMcpConfig?: McpConfig,
44
+ ): Promise<number> {
45
+ let registered = 0;
46
+ try {
47
+ const mcpConfig = buildEffectiveMcpConfig(workspaceMcpConfig);
48
+ if (Object.keys(mcpConfig.servers).length === 0) {
49
+ return 0;
50
+ }
51
+
52
+ const manager = getMcpServerManager();
53
+ const serverToolInfos = await manager.start(mcpConfig);
54
+ for (const { serverId, serverConfig, tools } of serverToolInfos) {
55
+ const mcpTools = createMcpToolsFromServer(
56
+ tools,
57
+ serverId,
58
+ serverConfig,
59
+ manager,
60
+ );
61
+ registered += registerMcpTools(serverId, mcpTools).length;
62
+ }
63
+ } catch (err) {
64
+ log.error(
65
+ { err },
66
+ "MCP server initialization failed, continuing without MCP tools",
67
+ );
68
+ return registered;
69
+ }
70
+
71
+ log.info({ toolCount: registered }, "MCP tools registered");
72
+ return registered;
73
+ }
74
+
75
+ /**
76
+ * Drop this process's MCP connections and tools and build them again from the
77
+ * config on disk.
78
+ *
79
+ * The server set is owned by the config, not by any one process, so a process
80
+ * that holds connections has to be told when it moves. The daemon learns from
81
+ * its own config watcher and reload route; a process without those (the
82
+ * schedule worker) calls this when the daemon signals that a reload happened,
83
+ * which is what keeps a disabled or removed server from staying callable there
84
+ * long after it stopped being callable in the daemon.
85
+ *
86
+ * Never throws, for the same reason {@link startConfiguredMcpServers} does not.
87
+ * The window between the teardown and the reconnect has no MCP tools
88
+ * registered, so an `mcp__*` call landing inside it fails as an unknown tool.
89
+ *
90
+ * @returns The number of tools registered after the reconnect.
91
+ */
92
+ export async function restartConfiguredMcpServers(): Promise<number> {
93
+ try {
94
+ await stopMcpServerManager();
95
+ } catch (err) {
96
+ log.warn({ err }, "MCP server teardown failed, reconnecting anyway");
97
+ }
98
+ unregisterAllMcpTools();
99
+ invalidateConfigCache();
100
+ return startConfiguredMcpServers(getConfig().mcp);
101
+ }
@@ -19,18 +19,7 @@ import type {
19
19
  SendResult,
20
20
  } from "../../provider-types.js";
21
21
  import * as outlook from "./client.js";
22
- import type { OutlookMessage, OutlookSendFileAttachment } from "./types.js";
23
-
24
- function toGraphAttachments(
25
- attachments: NonNullable<SendOptions["attachments"]>,
26
- ): OutlookSendFileAttachment[] {
27
- return attachments.map((att) => ({
28
- "@odata.type": "#microsoft.graph.fileAttachment",
29
- name: att.filename,
30
- contentType: att.mimeType,
31
- contentBytes: att.data.toString("base64"),
32
- }));
33
- }
22
+ import type { OutlookMessage } from "./types.js";
34
23
 
35
24
  function requireConnection(
36
25
  connection: OAuthConnection | undefined,
@@ -155,7 +144,7 @@ export const outlookMessagingProvider: MessagingProvider = {
155
144
  ): Promise<SendResult> {
156
145
  const conn = requireConnection(connection);
157
146
  const attachments = options?.attachments?.length
158
- ? toGraphAttachments(options.attachments)
147
+ ? outlook.toOutlookFileAttachments(options.attachments)
159
148
  : undefined;
160
149
 
161
150
  if (options?.inReplyTo) {
@@ -286,6 +286,18 @@ export async function markMessageRead(
286
286
  );
287
287
  }
288
288
 
289
+ /** Map in-memory file parts to Graph fileAttachment objects. */
290
+ export function toOutlookFileAttachments(
291
+ attachments: Array<{ filename: string; mimeType: string; data: Buffer }>,
292
+ ): OutlookSendFileAttachment[] {
293
+ return attachments.map((att) => ({
294
+ "@odata.type": "#microsoft.graph.fileAttachment",
295
+ name: att.filename,
296
+ contentType: att.mimeType,
297
+ contentBytes: att.data.toString("base64"),
298
+ }));
299
+ }
300
+
289
301
  /** Create a draft message in the user's Drafts folder. */
290
302
  export async function createDraft(
291
303
  connection: OAuthConnection,
@@ -293,7 +305,7 @@ export async function createDraft(
293
305
  ): Promise<OutlookMessage> {
294
306
  return request<OutlookMessage>(connection, "/v1.0/me/messages", {
295
307
  method: "POST",
296
- body: JSON.stringify(draft),
308
+ body: JSON.stringify({ ...draft, isDraft: true }),
297
309
  });
298
310
  }
299
311
 
@@ -53,6 +53,7 @@ export interface OutlookMessage {
53
53
  flagStatus: "notFlagged" | "flagged" | "complete";
54
54
  };
55
55
  internetMessageHeaders?: OutlookInternetMessageHeader[];
56
+ webLink?: string;
56
57
  }
57
58
 
58
59
  /** Outlook mail folder */
@@ -98,6 +99,8 @@ export interface OutlookDraftMessage {
98
99
  toRecipients?: OutlookRecipient[];
99
100
  ccRecipients?: OutlookRecipient[];
100
101
  bccRecipients?: OutlookRecipient[];
102
+ isDraft?: boolean;
103
+ attachments?: OutlookSendFileAttachment[];
101
104
  }
102
105
 
103
106
  /** Forward message payload (Graph API's /createForward returns a draft) */