@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.
- package/AGENTS.md +1 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +2 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +2 -1
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
- package/node_modules/@vellumai/service-contracts/package.json +2 -1
- package/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
- package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
- package/openapi.yaml +152 -55
- package/package.json +1 -1
- package/src/__tests__/anthropic-provider.test.ts +60 -0
- package/src/__tests__/config-get-vision-flag.test.ts +36 -0
- package/src/__tests__/conversation-error.test.ts +38 -0
- package/src/__tests__/copy-composer-tc-templates.test.ts +2 -2
- package/src/__tests__/guardian-action-service-scope.test.ts +53 -0
- package/src/__tests__/headless-browser-mode.test.ts +63 -13
- package/src/__tests__/heartbeat-disk-pressure.test.ts +2 -0
- package/src/__tests__/heartbeat-service.test.ts +118 -27
- package/src/__tests__/host-app-control-proxy.test.ts +2 -2
- package/src/__tests__/host-app-control-routes.test.ts +41 -0
- package/src/__tests__/host-bash-routes.test.ts +30 -1
- package/src/__tests__/host-browser-routes.test.ts +34 -0
- package/src/__tests__/host-cu-routes-targeted.test.ts +29 -0
- package/src/__tests__/host-file-routes-targeted.test.ts +29 -0
- package/src/__tests__/host-proxy-base.test.ts +1 -0
- package/src/__tests__/host-transfer-routes-targeted.test.ts +86 -0
- package/src/__tests__/llm-schema.test.ts +32 -0
- package/src/__tests__/mcp-config-secret-boundary.test.ts +2 -0
- package/src/__tests__/memory-config-file-parity.test.ts +18 -0
- package/src/__tests__/memory-retrieval-hook.test.ts +15 -0
- package/src/__tests__/messaging-send-tool.test.ts +159 -13
- package/src/__tests__/messaging-shared.test.ts +19 -0
- package/src/__tests__/notification-events-source-context-probe.test.ts +211 -0
- package/src/__tests__/openai-responses-prompt-cache.test.ts +40 -6
- package/src/__tests__/outlook-messaging-provider.test.ts +9 -0
- package/src/__tests__/provider-catalog-visibility.test.ts +2 -2
- package/src/__tests__/schedule-routes.test.ts +30 -1
- package/src/__tests__/scheduler-result-notification.test.ts +209 -0
- package/src/__tests__/skills-install-extract.test.ts +26 -0
- package/src/__tests__/skills-install-staging.test.ts +11 -2
- package/src/api/constants/personality-sliders.test.ts +21 -0
- package/src/api/constants/personality-sliders.ts +64 -0
- package/src/api/index.ts +8 -0
- package/src/archive/ustar-size.test.ts +32 -0
- package/src/archive/ustar-size.ts +23 -0
- package/src/background-wake/next-wake.test.ts +13 -0
- package/src/background-wake/next-wake.ts +4 -2
- package/src/calls/voice-session-bridge.ts +26 -0
- package/src/cli/__tests__/catalog-search-help.test.ts +1 -0
- package/src/cli/commands/__tests__/plugins-search.test.ts +81 -0
- package/src/cli/commands/config.help.ts +3 -1
- package/src/cli/commands/memory/worker.ts +1 -1
- package/src/cli/commands/platform/__tests__/credits-format.test.ts +94 -5
- package/src/cli/commands/platform/__tests__/credits.test.ts +10 -0
- package/src/cli/commands/platform/credits-format.ts +57 -0
- package/src/cli/commands/platform/index.help.ts +19 -3
- package/src/cli/commands/plugins.help.ts +4 -1
- package/src/cli/commands/plugins.ts +2 -1
- package/src/cli/lib/bundled-marketplace.json +1 -1
- package/src/cli/lib/install-from-platform.ts +8 -10
- package/src/config/bundled-skills/messaging/SKILL.md +2 -0
- package/src/config/bundled-skills/messaging/TOOLS.json +4 -4
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +85 -1
- package/src/config/bundled-skills/messaging/tools/shared.ts +6 -0
- package/src/config/bundled-skills/schedule/SKILL.md +9 -1
- package/src/config/bundled-skills/screen-annotation/SKILL.md +87 -20
- package/src/config/bundled-skills/screen-annotation/TOOLS.json +21 -5
- package/src/config/bundled-skills/screen-annotation/tools/screen-annotation.test.ts +67 -0
- package/src/config/feature-flag-registry.json +17 -1
- package/src/config/input-modalities.test.ts +71 -0
- package/src/config/input-modalities.ts +95 -0
- package/src/config/schemas/heartbeat.ts +3 -3
- package/src/config/schemas/llm.ts +13 -3
- package/src/config/schemas/memory-storage.test.ts +39 -0
- package/src/config/schemas/memory-storage.ts +21 -0
- package/src/context/token-estimator.ts +2 -2
- package/src/daemon/__tests__/embedding-reconcile.test.ts +77 -0
- package/src/daemon/__tests__/lifecycle-embedding-reconcile.test.ts +3 -0
- package/src/daemon/conversation-error.ts +20 -7
- package/src/daemon/embedding-reconcile.ts +64 -1
- package/src/daemon/handlers/config-embeddings.ts +31 -0
- package/src/daemon/host-app-control-proxy.ts +2 -0
- package/src/daemon/host-bash-proxy.ts +1 -0
- package/src/daemon/host-cu-proxy.ts +6 -6
- package/src/daemon/host-file-proxy.ts +6 -6
- package/src/daemon/host-proxy-base.ts +10 -9
- package/src/daemon/host-transfer-proxy.ts +21 -24
- package/src/daemon/mcp-reload-service.ts +6 -0
- package/src/daemon/providers-setup.ts +5 -26
- package/src/heartbeat/heartbeat-service.ts +72 -13
- package/src/live-voice/__tests__/live-voice-photo.test.ts +72 -0
- package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +81 -0
- package/src/live-voice/live-voice-photo.ts +68 -4
- package/src/live-voice/live-voice-session.ts +36 -2
- package/src/live-voice/protocol.ts +98 -1
- package/src/mcp/__tests__/reload-signal-emission.test.ts +54 -0
- package/src/mcp/__tests__/reload-signal.test.ts +57 -0
- package/src/mcp/__tests__/startup.test.ts +150 -0
- package/src/mcp/reload-signal.ts +50 -0
- package/src/mcp/startup.ts +101 -0
- package/src/messaging/providers/outlook/adapter.ts +2 -13
- package/src/messaging/providers/outlook/client.ts +13 -1
- package/src/messaging/providers/outlook/types.ts +3 -0
- package/src/notifications/__tests__/decision-engine.test.ts +79 -6
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +27 -0
- package/src/notifications/__tests__/schedule-result-producer.test.ts +397 -0
- package/src/notifications/copy-composer.ts +33 -11
- package/src/notifications/decision-engine.ts +48 -11
- package/src/notifications/events-store.ts +68 -2
- package/src/notifications/home-feed-side-effect.ts +15 -3
- package/src/notifications/schedule-result-producer.ts +275 -0
- package/src/notifications/signal.ts +5 -0
- package/src/oauth/seed-providers.ts +3 -2
- package/src/persistence/conversation-crud.ts +38 -0
- package/src/persistence/embeddings/embedding-backend.test.ts +162 -0
- package/src/persistence/embeddings/embedding-backend.ts +148 -11
- package/src/persistence/embeddings/embedding-openai.test.ts +113 -0
- package/src/persistence/embeddings/embedding-openai.ts +55 -4
- package/src/persistence/embeddings/embedding-types.test.ts +24 -0
- package/src/persistence/embeddings/embedding-types.ts +25 -1
- package/src/persistence/job-utils.ts +6 -2
- package/src/plugin-api/index.ts +2 -1
- package/src/plugin-api/vision-support.test.ts +81 -0
- package/src/plugin-api/vision-support.ts +57 -10
- package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +8 -0
- package/src/plugins/defaults/memory/jobs/__tests__/embed-concept-page.test.ts +1 -0
- package/src/plugins/defaults/memory/jobs/embed-concept-page.ts +11 -3
- package/src/plugins/defaults/memory/src/__tests__/memory-worker-routes.test.ts +20 -0
- package/src/plugins/defaults/memory/src/memory-worker-routes.ts +3 -1
- package/src/plugins/defaults/memory/substrate/__tests__/reembed-job.test.ts +1 -0
- package/src/plugins/defaults/memory/v2/__tests__/backfill-jobs.test.ts +1 -0
- package/src/plugins/defaults/memory/v3/__tests__/section-dense-store.test.ts +82 -0
- package/src/plugins/defaults/memory/v3/section-dense-store.ts +9 -27
- package/src/providers/__tests__/provider-secret-catalog.test.ts +4 -0
- package/src/providers/__tests__/xml-tool-call-salvage.test.ts +172 -0
- package/src/providers/anthropic/client.ts +9 -4
- package/src/providers/inline-audio-support.test.ts +34 -0
- package/src/providers/inline-audio-support.ts +27 -0
- package/src/providers/model-catalog.ts +22 -2
- package/src/providers/openai/__tests__/api-error-normalization.test.ts +50 -0
- package/src/providers/openai/__tests__/chat-completions-extra-body.test.ts +51 -0
- package/src/providers/openai/__tests__/chat-completions-provider-xml-salvage.test.ts +291 -0
- package/src/providers/openai/api-error-normalization.ts +18 -3
- package/src/providers/openai/chat-completions-provider.ts +113 -13
- package/src/providers/openai/responses-provider.ts +10 -8
- package/src/providers/provider-secret-catalog.ts +11 -1
- package/src/providers/vellum/client.ts +14 -1
- package/src/providers/vellum/personality-directions.test.ts +163 -0
- package/src/providers/vellum/personality-directions.ts +120 -0
- package/src/providers/xml-tool-call-salvage.ts +212 -0
- package/src/runtime/__tests__/background-job-runner.test.ts +42 -4
- package/src/runtime/auth/__tests__/guard-tests.test.ts +1 -6
- package/src/runtime/auth/__tests__/route-policy.test.ts +65 -0
- package/src/runtime/auth/same-actor.ts +29 -5
- package/src/runtime/background-job-runner.ts +38 -29
- package/src/runtime/guardian-action-service.ts +20 -1
- package/src/runtime/migrations/vbundle-validator.ts +3 -3
- package/src/runtime/pending-interactions.ts +4 -5
- package/src/runtime/routes/__tests__/heartbeat-routes.test.ts +24 -0
- package/src/runtime/routes/__tests__/platform-credits-routes.test.ts +99 -5
- package/src/runtime/routes/conversation-query-routes.ts +53 -11
- package/src/runtime/routes/document-comments-routes.ts +8 -2
- package/src/runtime/routes/guardian-action-routes.ts +7 -0
- package/src/runtime/routes/heartbeat-routes.ts +41 -53
- package/src/runtime/routes/host-app-control-routes.ts +10 -36
- package/src/runtime/routes/host-bash-routes.ts +11 -45
- package/src/runtime/routes/host-browser-routes.ts +24 -0
- package/src/runtime/routes/host-cu-routes.ts +11 -45
- package/src/runtime/routes/host-file-routes.ts +11 -44
- package/src/runtime/routes/host-proxy-result-binding.ts +57 -0
- package/src/runtime/routes/host-transfer-routes.ts +33 -25
- package/src/runtime/routes/index.ts +0 -2
- package/src/runtime/routes/integrations/a2a.ts +12 -3
- package/src/runtime/routes/integrations/vercel.ts +13 -3
- package/src/runtime/routes/platform-routes.ts +121 -24
- package/src/runtime/routes/schedule-routes.ts +15 -1
- package/src/schedule/__tests__/schedule-timezone.test.ts +21 -3
- package/src/schedule/__tests__/worker-mcp-tools.test.ts +88 -0
- package/src/schedule/schedule-timezone.ts +25 -0
- package/src/schedule/scheduler.ts +19 -0
- package/src/schedule/worker.ts +119 -2
- package/src/skills/catalog-install.ts +40 -5
- package/src/tools/browser/__tests__/browser-status.test.ts +3 -0
- package/src/tools/browser/browser-execution.ts +117 -53
- package/src/tools/browser/browser-status-constants.ts +6 -6
- package/src/tools/capability-offer.test.ts +87 -0
- package/src/tools/capability-offer.ts +93 -0
- package/src/tools/terminal/__tests__/safe-env.test.ts +17 -0
- package/src/tools/terminal/safe-env.ts +3 -2
- package/src/util/provider-error-patterns.ts +34 -0
- package/src/watcher/__tests__/engine.test.ts +5 -2
- package/src/watcher/engine.ts +7 -4
- package/src/runtime/routes/__tests__/sight-frame-routes.test.ts +0 -487
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
?
|
|
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) */
|