@vellumai/assistant 0.11.1 → 0.11.2-staging.2
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/Dockerfile +1 -3
- package/README.md +1 -1
- package/eslint.config.mjs +28 -8
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
- package/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
- package/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
- package/node_modules/@vellumai/slack-text/src/index.test.ts +48 -0
- package/node_modules/@vellumai/slack-text/src/index.ts +25 -6
- package/openapi.yaml +292 -5
- package/package.json +1 -1
- package/src/__tests__/agent-loop-resume-interrupted.test.ts +223 -0
- package/src/__tests__/byok-default-profile-ensure.test.ts +43 -0
- package/src/__tests__/cli-logger-boundary-guard.test.ts +87 -0
- package/src/__tests__/compaction-events.test.ts +139 -2
- package/src/__tests__/config-loader-backfill.test.ts +35 -3
- package/src/__tests__/config-schema.test.ts +1 -0
- package/src/__tests__/conversation-agent-loop-fatal-cleanup.test.ts +30 -0
- package/src/__tests__/conversation-agent-loop.test.ts +180 -7
- package/src/__tests__/conversation-error.test.ts +211 -4
- package/src/__tests__/conversation-options-turn-scoped-transport.test.ts +64 -0
- package/src/__tests__/conversation-queue.test.ts +126 -0
- package/src/__tests__/conversation-retry-route.test.ts +46 -7
- package/src/__tests__/conversation-slash.test.ts +4 -2
- package/src/__tests__/conversation-summarize-route.test.ts +7 -1
- package/src/__tests__/conversation-summarize-up-to.test.ts +71 -5
- package/src/__tests__/document-create-dedupe.test.ts +2 -1
- package/src/__tests__/document-find-replace.test.ts +2 -1
- package/src/__tests__/document-tool-security.test.ts +2 -1
- package/src/__tests__/document-update-default-surface.test.ts +2 -1
- package/src/__tests__/document-workspace-file.test.ts +467 -0
- package/src/__tests__/emit-signal-routing-intent.test.ts +290 -49
- package/src/__tests__/guardian-card-withdrawal.test.ts +91 -3
- package/src/__tests__/http-user-message-parity.test.ts +66 -0
- package/src/__tests__/list-messages-attachments.test.ts +87 -0
- package/src/__tests__/list-messages-provider-error.test.ts +143 -0
- package/src/__tests__/list-messages-system-card.test.ts +100 -0
- package/src/__tests__/llm-resolver.test.ts +19 -9
- package/src/__tests__/managed-profile-guard.test.ts +23 -0
- package/src/__tests__/notification-platform-adapter.test.ts +130 -2
- package/src/__tests__/notification-telegram-adapter.test.ts +6 -0
- package/src/__tests__/notification-vellum-adapter.test.ts +45 -0
- package/src/__tests__/plugin-api-model-profiles.test.ts +10 -1
- package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
- package/src/__tests__/provider-error-scenarios.test.ts +140 -0
- package/src/__tests__/provider-send-message-override-profile.test.ts +95 -0
- package/src/__tests__/run-conversation-turn-persistence.test.ts +66 -3
- package/src/__tests__/scripted-turn-metadata-persistence.test.ts +209 -0
- package/src/__tests__/skills.test.ts +27 -0
- package/src/__tests__/slack-channels-routes.test.ts +0 -2
- package/src/__tests__/slack-share-routes.test.ts +0 -3
- package/src/__tests__/slack-users-routes.test.ts +0 -2
- package/src/__tests__/subagent-tools.test.ts +11 -0
- package/src/__tests__/tool-preview-lifecycle.test.ts +58 -0
- package/src/__tests__/tool-result-spool.test.ts +5 -4
- package/src/__tests__/turn-boundary-resolution.test.ts +53 -0
- package/src/__tests__/turn-events-store.test.ts +26 -0
- package/src/__tests__/ui-visual-surface.test.ts +695 -0
- package/src/__tests__/unified-turn-context-visible-app.test.ts +99 -0
- package/src/__tests__/visible-app-context.test.ts +189 -0
- package/src/__tests__/workspace-git-service.test.ts +173 -33
- package/src/__tests__/workspace-migration-137-repair-retired-fireworks-minimax-model-id.test.ts +157 -0
- package/src/__tests__/workspace-migration-138-backfill-home-feed-titles.test.ts +373 -0
- package/src/__tests__/workspace-migration-139-clear-renamed-cost-profile-label.test.ts +137 -0
- package/src/agent/loop.ts +36 -7
- package/src/api/events/context-window-usage.ts +31 -0
- package/src/api/events/notification-intent.ts +8 -0
- package/src/api/events/ui-surface-pending.ts +35 -0
- package/src/api/index.ts +14 -0
- package/src/api/responses/conversation-message.ts +25 -4
- package/src/api/surfaces.ts +90 -1
- package/src/approvals/guardian-card-withdrawal.ts +66 -31
- package/src/approvals/guardian-decision-primitive.ts +4 -0
- package/src/calls/__tests__/call-setup-router.test.ts +156 -26
- package/src/calls/__tests__/voice-session-bridge.test.ts +201 -12
- package/src/calls/call-setup-router.ts +80 -36
- package/src/calls/voice-session-bridge.ts +173 -26
- package/src/cli/commands/inference.help.ts +3 -3
- package/src/cli/commands/notifications.help.ts +3 -2
- package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +42 -128
- package/src/cli/commands/platform/__tests__/credits.test.ts +9 -78
- package/src/cli/commands/platform/__tests__/helpers.ts +90 -0
- package/src/cli/commands/platform/__tests__/invoices.test.ts +238 -0
- package/src/cli/commands/platform/__tests__/plans.test.ts +9 -88
- package/src/cli/commands/platform/__tests__/status.test.ts +12 -87
- package/src/cli/commands/platform/__tests__/subscription.test.ts +9 -86
- package/src/cli/commands/platform/index.help.ts +92 -0
- package/src/cli/commands/platform/index.ts +7 -0
- package/src/cli/commands/platform/invoices.ts +132 -0
- package/src/cli/commands/usage.help.ts +1 -1
- package/src/cli/lib/list-installed-plugins.ts +2 -1
- package/src/config/__tests__/default-profile-catalog.test.ts +7 -10
- package/src/config/__tests__/deployment-context-defaults.test.ts +27 -5
- package/src/config/assistant-feature-flags.ts +7 -2
- package/src/config/bundled-skills/app-builder/SKILL.md +3 -2
- package/src/config/bundled-skills/subagent/SKILL.md +3 -1
- package/src/config/bundled-skills/subagent/TOOLS.json +3 -3
- package/src/config/bundled-skills/visualize/SKILL.md +163 -0
- package/src/config/call-site-defaults.ts +3 -2
- package/src/config/default-profile-catalog.ts +75 -70
- package/src/config/default-profile-names.ts +13 -28
- package/src/config/env-registry.ts +1 -0
- package/src/config/feature-flag-registry.json +16 -0
- package/src/config/llm-resolver.ts +21 -5
- package/src/config/loader.ts +18 -11
- package/src/config/schemas/llm.ts +1 -9
- package/src/config/schemas/memory-retrospective.ts +9 -0
- package/src/config/schemas/monitoring.ts +28 -2
- package/src/config/schemas/workspace-git.ts +15 -0
- package/src/config/seed-inference-profiles.ts +19 -5
- package/src/context/post-turn-tool-result-truncation.ts +2 -2
- package/src/conversations/__tests__/message-consolidation.test.ts +54 -0
- package/src/conversations/message-consolidation.ts +17 -15
- package/src/daemon/__tests__/turn-tail-assistant-reply-notify.test.ts +182 -0
- package/src/daemon/__tests__/turn-tail-deleted-conversation.test.ts +181 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +90 -7
- package/src/daemon/conversation-agent-loop.ts +107 -22
- package/src/daemon/conversation-error.ts +169 -48
- package/src/daemon/conversation-messaging.ts +87 -4
- package/src/daemon/conversation-process.ts +57 -45
- package/src/daemon/conversation-runtime-assembly.ts +57 -0
- package/src/daemon/conversation-store.ts +36 -1
- package/src/daemon/conversation-surfaces.ts +68 -6
- package/src/daemon/conversation-turn-finalize.ts +76 -18
- package/src/daemon/conversation.ts +122 -27
- package/src/daemon/lifecycle.ts +9 -0
- package/src/daemon/message-types/conversations.ts +8 -0
- package/src/daemon/message-types/surfaces.ts +3 -0
- package/src/documents/document-store.ts +247 -10
- package/src/home/__tests__/feed-types.test.ts +43 -8
- package/src/home/__tests__/feed-writer.test.ts +59 -0
- package/src/home/feed-types.ts +34 -7
- package/src/home/feed-writer.ts +15 -6
- package/src/live-voice/__tests__/activity-label.test.ts +95 -0
- package/src/live-voice/__tests__/live-activity-reporter.test.ts +86 -5
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +310 -25
- package/src/live-voice/__tests__/live-voice-events.test.ts +4 -4
- package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +8 -4
- package/src/live-voice/__tests__/live-voice-vad.test.ts +9 -1
- package/src/live-voice/activity-label.ts +169 -0
- package/src/live-voice/live-activity-reporter.ts +35 -6
- package/src/live-voice/live-voice-session.ts +282 -41
- package/src/live-voice/protocol.ts +35 -0
- package/src/messaging/providers/slack/__tests__/adapter-mention-rendering.test.ts +84 -2
- package/src/messaging/providers/slack/adapter.ts +86 -19
- package/src/messaging/providers/slack/api.test.ts +85 -1
- package/src/messaging/providers/slack/api.ts +121 -288
- package/src/messaging/providers/slack/client.ts +20 -251
- package/src/messaging/providers/slack/send.test.ts +4 -9
- package/src/messaging/providers/slack/send.ts +1 -1
- package/src/messaging/providers/slack/types.ts +5 -0
- package/src/messaging/providers/slack/web-api-transport.test.ts +181 -0
- package/src/messaging/providers/slack/web-api-transport.ts +367 -0
- package/src/messaging/providers/slack/withdraw.ts +6 -14
- package/src/messaging/providers/telegram-bot/send.test.ts +31 -1
- package/src/messaging/providers/telegram-bot/send.ts +23 -2
- package/src/messaging/providers/telegram-bot/withdraw.test.ts +152 -0
- package/src/messaging/providers/telegram-bot/withdraw.ts +166 -0
- package/src/monitoring/__tests__/db-integrity-sample.test.ts +18 -4
- package/src/monitoring/__tests__/file-descriptors.test.ts +144 -0
- package/src/monitoring/file-descriptors.ts +262 -0
- package/src/monitoring/process-memory.ts +4 -11
- package/src/monitoring/resource-sampler.ts +5 -0
- package/src/monitoring/worker.ts +12 -0
- package/src/notifications/__tests__/assistant-reply-producer.test.ts +740 -0
- package/src/notifications/__tests__/broadcaster.test.ts +347 -7
- package/src/notifications/__tests__/copy-composer.test.ts +53 -3
- package/src/notifications/__tests__/decision-engine.test.ts +349 -29
- package/src/notifications/__tests__/deterministic-checks.test.ts +21 -0
- package/src/notifications/__tests__/edit-notification.test.ts +330 -0
- package/src/notifications/__tests__/guardian-delivery-recorder.test.ts +71 -0
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +170 -12
- package/src/notifications/adapters/macos.ts +3 -0
- package/src/notifications/adapters/platform.ts +90 -14
- package/src/notifications/adapters/telegram.ts +6 -4
- package/src/notifications/assistant-reply-producer.ts +219 -0
- package/src/notifications/broadcaster.ts +523 -317
- package/src/notifications/copy-composer.ts +27 -6
- package/src/notifications/decision-engine.ts +154 -77
- package/src/notifications/deterministic-checks.ts +6 -7
- package/src/notifications/edit-notification.ts +8 -4
- package/src/notifications/emit-signal.ts +49 -16
- package/src/notifications/guardian-delivery-recorder.ts +8 -2
- package/src/notifications/home-feed-side-effect.ts +43 -6
- package/src/notifications/notification-utils.ts +39 -4
- package/src/notifications/signal.ts +10 -0
- package/src/notifications/types.ts +17 -0
- package/src/persistence/bookmark-crud.ts +18 -9
- package/src/persistence/conversation-attention-store.ts +26 -0
- package/src/persistence/conversation-crud.ts +79 -32
- package/src/persistence/conversation-queries.ts +30 -15
- package/src/persistence/conversation-title-service.ts +5 -170
- package/src/persistence/conversation-types.ts +192 -1
- package/src/persistence/db-init.ts +14 -1
- package/src/persistence/db-maintenance.ts +5 -4
- package/src/persistence/embeddings/__tests__/plugin-index-qdrant-init.test.ts +219 -0
- package/src/persistence/embeddings/__tests__/plugin-index.test.ts +22 -0
- package/src/persistence/embeddings/__tests__/worker-script-version.test.ts +76 -0
- package/src/persistence/embeddings/embedding-local.ts +2 -1
- package/src/persistence/embeddings/embedding-runtime-manager.ts +29 -5
- package/src/persistence/embeddings/plugin-index.ts +84 -12
- package/src/persistence/migrations/360-add-document-workspace-path.test.ts +110 -0
- package/src/persistence/migrations/360-add-document-workspace-path.ts +39 -0
- package/src/persistence/planner-statistics.ts +14 -0
- package/src/persistence/schema/documents.ts +26 -11
- package/src/persistence/steps.ts +2 -0
- package/src/platform/client.test.ts +172 -2
- package/src/platform/client.ts +178 -55
- package/src/plugin-api/conversation-turn.ts +13 -0
- package/src/plugin-api/model-profiles.test.ts +6 -2
- package/src/plugins/defaults/memory/__tests__/bookmark-crud.test.ts +34 -0
- package/src/plugins/defaults/memory/__tests__/conversation-queries.test.ts +22 -0
- package/src/plugins/defaults/memory/__tests__/jobs-store-enqueue-gate.test.ts +11 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +199 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-enqueue.test.ts +98 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +105 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-sweep.test.ts +24 -2
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-wake-chain.test.ts +524 -0
- package/src/plugins/defaults/memory/__tests__/memory-tier-boundary-guard.test.ts +1 -1
- package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +9 -4
- package/src/plugins/defaults/memory/host-utils.ts +5 -0
- package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +105 -1
- package/src/plugins/defaults/memory/memory-retrospective-enqueue.ts +64 -5
- package/src/plugins/defaults/memory/memory-retrospective-job.ts +36 -1
- package/src/plugins/defaults/memory/memory-retrospective-sweep.ts +12 -3
- package/src/plugins/defaults/memory/src/__tests__/memory-v2-simulate-route.test.ts +1 -0
- package/src/plugins/defaults/memory/substrate/__tests__/skill-store.test.ts +195 -1
- package/src/plugins/defaults/memory/substrate/consolidation-job.ts +1 -1
- package/src/plugins/defaults/memory/substrate/skill-store.ts +167 -29
- package/src/plugins/defaults/memory/v2/__tests__/injection.test.ts +131 -0
- package/src/plugins/defaults/memory/v2/__tests__/router.test.ts +1 -0
- package/src/plugins/defaults/memory/v2/activation-log-store.ts +4 -0
- package/src/plugins/defaults/memory/v2/injection.ts +48 -3
- package/src/plugins/defaults/memory/v2/rerank-local.ts +6 -2
- package/src/plugins/defaults/memory/v3/__tests__/pool-select.test.ts +1 -1
- package/src/plugins/defaults/platform-hosted/routes/reengage.ts +1 -1
- package/src/plugins/defaults/turn-context/injectors.ts +1 -0
- package/src/plugins/defaults/turn-context/unified-turn-context.ts +26 -0
- package/src/plugins/types.ts +17 -0
- package/src/prompts/templates/system-sections.ts +2 -2
- package/src/providers/__tests__/dispatch-connection-routing.test.ts +43 -2
- package/src/providers/__tests__/vellum-mismatch-routing.test.ts +8 -0
- package/src/providers/call-site-routing.ts +77 -14
- package/src/providers/connection-resolution.ts +42 -2
- package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +127 -1
- package/src/providers/inference/adapter-factory.ts +70 -12
- package/src/providers/model-catalog.ts +0 -11
- package/src/providers/model-intents.ts +11 -0
- package/src/providers/registry.ts +8 -0
- package/src/providers/retry.ts +85 -17
- package/src/providers/types.ts +8 -1
- package/src/runtime/__tests__/agent-wake.test.ts +181 -0
- package/src/runtime/agent-wake.ts +132 -9
- package/src/runtime/channel-approval-types.ts +25 -0
- package/src/runtime/routes/__tests__/connection-routes-vs-cli-parity.test.ts +2 -2
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +11 -0
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +121 -9
- package/src/runtime/routes/__tests__/platform-invoice-routes.test.ts +392 -0
- package/src/runtime/routes/__tests__/slack-channel-routes.test.ts +7 -0
- package/src/runtime/routes/__tests__/workspace-commit-routes.test.ts +63 -0
- package/src/runtime/routes/consolidation-routes.ts +1 -1
- package/src/runtime/routes/conversation-management-routes.ts +18 -6
- package/src/runtime/routes/conversation-query-routes.ts +14 -23
- package/src/runtime/routes/conversation-routes.ts +113 -8
- package/src/runtime/routes/credential-routes.ts +1 -5
- package/src/runtime/routes/documents-routes.ts +207 -12
- package/src/runtime/routes/identity-routes.ts +3 -93
- package/src/runtime/routes/inbound-stages/admission-policy.test.ts +31 -1
- package/src/runtime/routes/inbound-stages/admission-policy.ts +18 -0
- package/src/runtime/routes/inference-provider-connection-routes.ts +52 -3
- package/src/runtime/routes/notification-routes.ts +4 -1
- package/src/runtime/routes/platform-routes.ts +238 -3
- package/src/runtime/routes/playground/guard.ts +1 -2
- package/src/runtime/routes/slack-channel-routes.ts +2 -4
- package/src/runtime/routes/workspace-commit-routes.ts +49 -2
- package/src/runtime/routes/workspace-routes.ts +11 -12
- package/src/runtime/routes/workspace-utils.ts +49 -2
- package/src/runtime/services/conversation-serializer.ts +2 -4
- package/src/subagent/__tests__/consult-context-gating.test.ts +98 -0
- package/src/subagent/__tests__/consult-context-skills.test.ts +82 -0
- package/src/subagent/__tests__/consult-context.test.ts +84 -0
- package/src/subagent/__tests__/consult-prompt.test.ts +36 -0
- package/src/subagent/consult-context.ts +410 -0
- package/src/subagent/consult-prompt.ts +38 -5
- package/src/subagent/manager.ts +9 -4
- package/src/subagent/types.ts +8 -0
- package/src/telemetry/telemetry-event-sources.ts +7 -0
- package/src/telemetry/telemetry-wire-source.json +1 -1
- package/src/telemetry/telemetry-wire.generated.ts +1 -0
- package/src/telemetry/turn-events-store.ts +30 -1
- package/src/telemetry/types.ts +24 -0
- package/src/telemetry/usage-telemetry-reporter.test.ts +48 -0
- package/src/tools/browser/pinned-tabs.ts +3 -1
- package/src/tools/subagent/spawn.ts +23 -1
- package/src/tools/terminal/safe-env.ts +1 -0
- package/src/tools/ui-surface/definitions.ts +39 -1
- package/src/tools/ui-surface/surface-shape-docs.ts +38 -4
- package/src/tools/ui-surface/visual-validation.ts +787 -0
- package/src/tools/workflows/run-workflow.test.ts +1 -0
- package/src/util/__tests__/short-title.test.ts +229 -0
- package/src/util/__tests__/worker-compute.test.ts +65 -0
- package/src/util/cgroup-cpu.ts +93 -0
- package/src/util/errors.ts +27 -0
- package/src/util/process-tree.ts +19 -0
- package/src/util/short-title.ts +189 -0
- package/src/util/worker-compute.ts +83 -0
- package/src/workspace/byok-default-profile-ensure.ts +25 -15
- package/src/workspace/git-service.ts +79 -30
- package/src/workspace/migrations/137-repair-retired-fireworks-minimax-model-id.ts +131 -0
- package/src/workspace/migrations/138-backfill-home-feed-titles.ts +179 -0
- package/src/workspace/migrations/139-clear-renamed-cost-profile-label.ts +92 -0
- package/src/workspace/migrations/registry.ts +6 -0
- package/src/plugins/defaults/memory/substrate/constants.ts +0 -8
|
@@ -21,7 +21,10 @@ import {
|
|
|
21
21
|
parseAccessRequestPayload,
|
|
22
22
|
} from "./access-request-copy.js";
|
|
23
23
|
import { isGuardianSensitiveEvent } from "./adapters/macos.js";
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
pairDeliveryWithConversation,
|
|
26
|
+
type PairingResult,
|
|
27
|
+
} from "./conversation-pairing.js";
|
|
25
28
|
import { composeFallbackCopy } from "./copy-composer.js";
|
|
26
29
|
import {
|
|
27
30
|
createDelivery,
|
|
@@ -42,8 +45,11 @@ import { nonEmpty } from "./notification-utils.js";
|
|
|
42
45
|
import type { NotificationSignal } from "./signal.js";
|
|
43
46
|
import type {
|
|
44
47
|
ChannelAdapter,
|
|
48
|
+
ChannelDeliveryObserver,
|
|
45
49
|
ChannelDeliveryPayload,
|
|
50
|
+
ChannelDestination,
|
|
46
51
|
ConversationAction,
|
|
52
|
+
DeliveryResult,
|
|
47
53
|
NotificationChannel,
|
|
48
54
|
NotificationDecision,
|
|
49
55
|
NotificationDeliveryResult,
|
|
@@ -221,6 +227,50 @@ export type OnConversationCreatedFn = (
|
|
|
221
227
|
) => void | Promise<void>;
|
|
222
228
|
export interface BroadcastDecisionOptions {
|
|
223
229
|
onConversationCreated?: OnConversationCreatedFn;
|
|
230
|
+
/** Deadline override for tests; defaults to PLATFORM_OUTCOME_DEADLINE_MS. */
|
|
231
|
+
platformOutcomeDeadlineMs?: number;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Cap on how long the deferred vellum send waits for the platform dispatch
|
|
235
|
+
// outcome: a duplicate banner beats a late one.
|
|
236
|
+
const PLATFORM_OUTCOME_DEADLINE_MS = 2_500;
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* A channel dispatch that is fully prepared (destination resolved, copy
|
|
240
|
+
* rendered, conversation paired, pending delivery row created) but whose
|
|
241
|
+
* adapter send has not yet run. Used to defer the vellum send until the
|
|
242
|
+
* platform dispatch outcome is known.
|
|
243
|
+
*/
|
|
244
|
+
interface PendingChannelDispatch {
|
|
245
|
+
adapter: ChannelAdapter;
|
|
246
|
+
channel: NotificationChannel;
|
|
247
|
+
destination: ChannelDestination;
|
|
248
|
+
payload: ChannelDeliveryPayload;
|
|
249
|
+
deliveryId: string;
|
|
250
|
+
destinationLabel: string;
|
|
251
|
+
pairing: PairingResult;
|
|
252
|
+
hasPersistedDecision: boolean;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Build a delivery result for a prepared dispatch. The single construction
|
|
257
|
+
* site for every post-preparation outcome branch, so a future field cannot
|
|
258
|
+
* be added to one branch and missed in another.
|
|
259
|
+
*/
|
|
260
|
+
function buildDeliveryResult(
|
|
261
|
+
dispatch: PendingChannelDispatch,
|
|
262
|
+
status: NotificationDeliveryResult["status"],
|
|
263
|
+
overrides?: Partial<NotificationDeliveryResult>,
|
|
264
|
+
): NotificationDeliveryResult {
|
|
265
|
+
return {
|
|
266
|
+
channel: dispatch.channel,
|
|
267
|
+
destination: dispatch.destinationLabel,
|
|
268
|
+
status,
|
|
269
|
+
conversationId: dispatch.pairing.conversationId ?? undefined,
|
|
270
|
+
messageId: dispatch.pairing.messageId ?? undefined,
|
|
271
|
+
conversationStrategy: dispatch.pairing.strategy,
|
|
272
|
+
...overrides,
|
|
273
|
+
};
|
|
224
274
|
}
|
|
225
275
|
|
|
226
276
|
export class NotificationBroadcaster {
|
|
@@ -268,16 +318,22 @@ export class NotificationBroadcaster {
|
|
|
268
318
|
|
|
269
319
|
// Ensure vellum is processed first so the notification_conversation_created
|
|
270
320
|
// event fires immediately, before slower channel sends (e.g. Telegram 30s
|
|
271
|
-
// timeout) can delay it past the macOS deep-link retry window.
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
321
|
+
// timeout) can delay it past the macOS deep-link retry window. Platform
|
|
322
|
+
// sorts second: the vellum intent carries remotePushDispatched, so its
|
|
323
|
+
// deferred send (below) flushes right after the platform outcome is known
|
|
324
|
+
// instead of waiting behind slower channels.
|
|
325
|
+
const dispatchRank = (channel: NotificationChannel): number => {
|
|
326
|
+
if (channel === "vellum") {
|
|
327
|
+
return 0;
|
|
275
328
|
}
|
|
276
|
-
if (
|
|
329
|
+
if (channel === "platform") {
|
|
277
330
|
return 1;
|
|
278
331
|
}
|
|
279
|
-
return
|
|
280
|
-
}
|
|
332
|
+
return 2;
|
|
333
|
+
};
|
|
334
|
+
const orderedChannels = [...decision.selectedChannels].sort(
|
|
335
|
+
(a, b) => dispatchRank(a) - dispatchRank(b),
|
|
336
|
+
);
|
|
281
337
|
|
|
282
338
|
// Pre-compute fallback copy in case any channel is missing rendered copy
|
|
283
339
|
let fallbackCopy: Partial<
|
|
@@ -301,369 +357,519 @@ export class NotificationBroadcaster {
|
|
|
301
357
|
messageId: string | null;
|
|
302
358
|
} | null = null;
|
|
303
359
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
360
|
+
// The vellum intent's remotePushDispatched flag must reflect the ACTUAL
|
|
361
|
+
// platform dispatch outcome, not channel selection: when both channels
|
|
362
|
+
// are selected, the vellum channel is still prepared first (pairing, the
|
|
363
|
+
// conversation-created emission, and the pending delivery row all run
|
|
364
|
+
// eagerly, so the platform deep link keeps the vellum pairing carry), but
|
|
365
|
+
// its adapter send is deferred until the platform adapter reports whether
|
|
366
|
+
// the platform accepted a device push. Typical cost: one fast HTTP
|
|
367
|
+
// round-trip before the local banner goes out, capped at
|
|
368
|
+
// PLATFORM_OUTCOME_DEADLINE_MS.
|
|
369
|
+
let deferredVellumSend: PendingChannelDispatch | null = null;
|
|
370
|
+
let platformRemotePushAccepted = false;
|
|
371
|
+
let platformRemotePushPlatforms: ("ios" | "android")[] | undefined;
|
|
372
|
+
const flushDeferredVellumSend = async (): Promise<void> => {
|
|
373
|
+
if (!deferredVellumSend) {
|
|
374
|
+
return;
|
|
318
375
|
}
|
|
376
|
+
const pending = deferredVellumSend;
|
|
377
|
+
deferredVellumSend = null;
|
|
378
|
+
pending.payload.remotePushDispatched = platformRemotePushAccepted;
|
|
379
|
+
pending.payload.remotePushPlatforms = platformRemotePushPlatforms;
|
|
380
|
+
await this.sendAndRecord(pending, signal, results);
|
|
381
|
+
};
|
|
319
382
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
});
|
|
332
|
-
continue;
|
|
333
|
-
}
|
|
383
|
+
try {
|
|
384
|
+
for (const channel of orderedChannels) {
|
|
385
|
+
// Platform sorts immediately after vellum, so reaching any later
|
|
386
|
+
// channel means the platform outcome is settled (or the platform
|
|
387
|
+
// channel was skipped): flush the deferred vellum send now so the
|
|
388
|
+
// local banner is not held behind slower sends. On the vellum
|
|
389
|
+
// iteration itself this is a no-op -- vellum appears at most once in
|
|
390
|
+
// the decision's channels and sorts first, so nothing is deferred yet.
|
|
391
|
+
if (channel !== "platform") {
|
|
392
|
+
await flushDeferredVellumSend();
|
|
393
|
+
}
|
|
334
394
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
// returns empty title/body strings that pass type-only validation, so
|
|
338
|
-
// treat copy with no usable content the same as missing copy.
|
|
339
|
-
let copy = decision.renderedCopy[channel];
|
|
340
|
-
if (!copy || (!copy.title?.trim() && !copy.body?.trim())) {
|
|
341
|
-
if (copy) {
|
|
395
|
+
const adapter = this.adapters.get(channel);
|
|
396
|
+
if (!adapter) {
|
|
342
397
|
log.warn(
|
|
343
398
|
{ channel, signalId: signal.signalId },
|
|
344
|
-
"
|
|
399
|
+
"No adapter registered for channel -- skipping",
|
|
345
400
|
);
|
|
401
|
+
results.push({
|
|
402
|
+
channel,
|
|
403
|
+
destination: "",
|
|
404
|
+
status: "skipped",
|
|
405
|
+
errorMessage: `No adapter for channel: ${channel}`,
|
|
406
|
+
});
|
|
407
|
+
continue;
|
|
346
408
|
}
|
|
347
|
-
if (!fallbackCopy) {
|
|
348
|
-
fallbackCopy = composeFallbackCopy(signal, decision.selectedChannels);
|
|
349
|
-
}
|
|
350
|
-
copy = fallbackCopy[channel];
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// Fail closed: if neither the decision nor the fallback composer produced
|
|
354
|
-
// a usable body, skip the channel rather than leaking the raw event name
|
|
355
|
-
// as placeholder text. The pre-send `checkRenderedCopyQuality` only sees
|
|
356
|
-
// `decision.renderedCopy`, so this is the last guard before delivery.
|
|
357
|
-
if (!copy || !copy.body?.trim()) {
|
|
358
|
-
log.warn(
|
|
359
|
-
{ channel, signalId: signal.signalId },
|
|
360
|
-
"No usable rendered copy available -- skipping channel to avoid leaking event name",
|
|
361
|
-
);
|
|
362
|
-
results.push({
|
|
363
|
-
channel,
|
|
364
|
-
destination: destination.endpoint ?? channel,
|
|
365
|
-
status: "skipped",
|
|
366
|
-
errorMessage: `No usable rendered copy for channel: ${channel}`,
|
|
367
|
-
});
|
|
368
|
-
continue;
|
|
369
|
-
}
|
|
370
409
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
410
|
+
const destination = destinations.get(channel);
|
|
411
|
+
if (!destination) {
|
|
412
|
+
log.warn(
|
|
413
|
+
{ channel, signalId: signal.signalId },
|
|
414
|
+
"Could not resolve destination -- skipping",
|
|
415
|
+
);
|
|
416
|
+
results.push({
|
|
417
|
+
channel,
|
|
418
|
+
destination: "",
|
|
419
|
+
status: "skipped",
|
|
420
|
+
errorMessage: `Destination not resolved for channel: ${channel}`,
|
|
421
|
+
});
|
|
422
|
+
continue;
|
|
381
423
|
}
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
// Resolve the per-channel conversation action from the decision (default: start_new)
|
|
385
|
-
const conversationAction: ConversationAction | undefined =
|
|
386
|
-
decision.conversationActions?.[channel];
|
|
387
424
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
// On retry paths the vellum row already exists, so the fresh-pairing
|
|
400
|
-
// carry below never runs — seed the platform deep-link carry from
|
|
401
|
-
// the duplicate row's conversation instead.
|
|
402
|
-
if (channel === "vellum" && existingDelivery.conversationId) {
|
|
403
|
-
vellumPairing = {
|
|
404
|
-
conversationId: existingDelivery.conversationId,
|
|
405
|
-
messageId: existingDelivery.messageId,
|
|
406
|
-
};
|
|
425
|
+
// Pull rendered copy from the decision; fall back to copy-composer if
|
|
426
|
+
// missing or effectively blank. The decision engine's LLM occasionally
|
|
427
|
+
// returns empty title/body strings that pass type-only validation, so
|
|
428
|
+
// treat copy with no usable content the same as missing copy.
|
|
429
|
+
let copy = decision.renderedCopy[channel];
|
|
430
|
+
if (!copy || (!copy.title?.trim() && !copy.body?.trim())) {
|
|
431
|
+
if (copy) {
|
|
432
|
+
log.warn(
|
|
433
|
+
{ channel, signalId: signal.signalId },
|
|
434
|
+
"Decision copy has empty title and body -- using fallback",
|
|
435
|
+
);
|
|
407
436
|
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
437
|
+
// A policy-forced platform channel has no rendered copy of its own
|
|
438
|
+
// (the decision engine renders only the channels it selected). The
|
|
439
|
+
// push mirrors the in-app banner -- and the iOS dedup suppresses the
|
|
440
|
+
// vellum banner in its favor -- so reuse the vellum channel's
|
|
441
|
+
// rendered copy before falling back to deterministic templates.
|
|
442
|
+
const vellumCopy =
|
|
443
|
+
channel === "platform" ? decision.renderedCopy.vellum : undefined;
|
|
444
|
+
if (vellumCopy?.body?.trim()) {
|
|
445
|
+
copy = vellumCopy;
|
|
446
|
+
} else {
|
|
447
|
+
if (!fallbackCopy) {
|
|
448
|
+
fallbackCopy = composeFallbackCopy(
|
|
449
|
+
signal,
|
|
450
|
+
decision.selectedChannels,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
copy = fallbackCopy[channel];
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Fail closed: if neither the decision nor the fallback composer produced
|
|
458
|
+
// a usable body, skip the channel rather than leaking the raw event name
|
|
459
|
+
// as placeholder text. The pre-send `checkRenderedCopyQuality` only sees
|
|
460
|
+
// `decision.renderedCopy`, so this is the last guard before delivery.
|
|
461
|
+
if (!copy || !copy.body?.trim()) {
|
|
462
|
+
log.warn(
|
|
463
|
+
{ channel, signalId: signal.signalId },
|
|
464
|
+
"No usable rendered copy available -- skipping channel to avoid leaking event name",
|
|
415
465
|
);
|
|
416
466
|
results.push({
|
|
417
467
|
channel,
|
|
418
468
|
destination: destination.endpoint ?? channel,
|
|
419
469
|
status: "skipped",
|
|
420
|
-
errorMessage:
|
|
421
|
-
conversationId: existingDelivery.conversationId ?? undefined,
|
|
422
|
-
messageId: existingDelivery.messageId ?? undefined,
|
|
423
|
-
conversationStrategy:
|
|
424
|
-
existingDelivery.conversationStrategy ?? undefined,
|
|
470
|
+
errorMessage: `No usable rendered copy for channel: ${channel}`,
|
|
425
471
|
});
|
|
426
472
|
continue;
|
|
427
473
|
}
|
|
428
|
-
}
|
|
429
474
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
signal
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
475
|
+
// For tool_grant_request signals, prefer the deterministic template seed
|
|
476
|
+
// over LLM-generated prose. The enriched questionText is already concise
|
|
477
|
+
// and informative -- LLM rewording just adds noise.
|
|
478
|
+
if (signal.contextPayload?.requestKind === "tool_grant_request") {
|
|
479
|
+
if (!fallbackCopy) {
|
|
480
|
+
fallbackCopy = composeFallbackCopy(
|
|
481
|
+
signal,
|
|
482
|
+
decision.selectedChannels,
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
const templateSeed = fallbackCopy[channel]?.conversationSeedMessage;
|
|
486
|
+
if (templateSeed) {
|
|
487
|
+
copy = { ...copy, conversationSeedMessage: templateSeed };
|
|
488
|
+
}
|
|
489
|
+
}
|
|
438
490
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
491
|
+
// Resolve the per-channel conversation action from the decision (default: start_new)
|
|
492
|
+
const conversationAction: ConversationAction | undefined =
|
|
493
|
+
decision.conversationActions?.[channel];
|
|
494
|
+
|
|
495
|
+
// Check for duplicate delivery BEFORE pairing to avoid side effects
|
|
496
|
+
// (e.g. appending seed messages to existing conversations) on retry paths
|
|
497
|
+
// where a delivery row already exists.
|
|
498
|
+
const persistedDecisionId = decision.persistedDecisionId;
|
|
499
|
+
const hasPersistedDecision = typeof persistedDecisionId === "string";
|
|
500
|
+
if (hasPersistedDecision) {
|
|
501
|
+
const existingDelivery = findDeliveryByDecisionAndChannel(
|
|
502
|
+
persistedDecisionId,
|
|
503
|
+
channel,
|
|
504
|
+
);
|
|
505
|
+
if (existingDelivery) {
|
|
506
|
+
// On retry paths the vellum row already exists, so the fresh-pairing
|
|
507
|
+
// carry below never runs -- seed the platform deep-link carry from
|
|
508
|
+
// the duplicate row's conversation instead.
|
|
509
|
+
if (channel === "vellum" && existingDelivery.conversationId) {
|
|
510
|
+
vellumPairing = {
|
|
511
|
+
conversationId: existingDelivery.conversationId,
|
|
512
|
+
messageId: existingDelivery.messageId,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
log.info(
|
|
516
|
+
{
|
|
517
|
+
channel,
|
|
518
|
+
signalId: signal.signalId,
|
|
519
|
+
existingDeliveryId: existingDelivery.id,
|
|
520
|
+
},
|
|
521
|
+
"Delivery already exists for this decision+channel -- skipping duplicate",
|
|
522
|
+
);
|
|
523
|
+
results.push({
|
|
524
|
+
channel,
|
|
525
|
+
destination: destination.endpoint ?? channel,
|
|
526
|
+
status: "skipped",
|
|
527
|
+
errorMessage: "Duplicate delivery skipped",
|
|
528
|
+
conversationId: existingDelivery.conversationId ?? undefined,
|
|
529
|
+
messageId: existingDelivery.messageId ?? undefined,
|
|
530
|
+
conversationStrategy:
|
|
531
|
+
existingDelivery.conversationStrategy ?? undefined,
|
|
532
|
+
});
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
445
536
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
: pairing;
|
|
460
|
-
const deepLinkConversationId =
|
|
461
|
-
deepLinkPairing.conversationId ??
|
|
462
|
-
resolveSourceConversationId(signal.sourceContextId) ??
|
|
463
|
-
resolveDeepLinkConversationId(signal.contextPayload);
|
|
464
|
-
if (deepLinkConversationId) {
|
|
465
|
-
deepLinkTarget = {
|
|
466
|
-
...deepLinkTarget,
|
|
467
|
-
conversationId: deepLinkConversationId,
|
|
537
|
+
// Pair the delivery with a conversation before sending, passing the conversation action
|
|
538
|
+
// and destination binding context for channel-scoped continuation
|
|
539
|
+
const pairing = await pairDeliveryWithConversation(
|
|
540
|
+
signal,
|
|
541
|
+
channel,
|
|
542
|
+
copy,
|
|
543
|
+
{ conversationAction, bindingContext: destination.bindingContext },
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
if (channel === "vellum" && pairing.conversationId) {
|
|
547
|
+
vellumPairing = {
|
|
548
|
+
conversationId: pairing.conversationId,
|
|
549
|
+
messageId: pairing.messageId,
|
|
468
550
|
};
|
|
469
|
-
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// For the vellum and platform channels, merge the conversationId into
|
|
554
|
+
// deep-link metadata so notification taps can navigate to the
|
|
555
|
+
// conversation. Prefer the channel's own pairing; platform (push_only,
|
|
556
|
+
// pairs nothing) takes this broadcast's vellum pairing; otherwise fall
|
|
557
|
+
// back to sourceContextId when it resolves to a real row. Sentinel
|
|
558
|
+
// context ids (job IDs, call session IDs, access-req-* strings) leave
|
|
559
|
+
// the deep link without a conversation, and the client opens the app to
|
|
560
|
+
// its default landing.
|
|
561
|
+
let deepLinkTarget = decision.deepLinkTarget;
|
|
562
|
+
if (channel === "vellum" || channel === "platform") {
|
|
563
|
+
const deepLinkPairing =
|
|
564
|
+
channel === "platform" && !pairing.conversationId
|
|
565
|
+
? (vellumPairing ?? pairing)
|
|
566
|
+
: pairing;
|
|
567
|
+
const deepLinkConversationId =
|
|
568
|
+
deepLinkPairing.conversationId ??
|
|
569
|
+
resolveSourceConversationId(signal.sourceContextId) ??
|
|
570
|
+
resolveDeepLinkConversationId(signal.contextPayload);
|
|
571
|
+
if (deepLinkConversationId) {
|
|
470
572
|
deepLinkTarget = {
|
|
471
573
|
...deepLinkTarget,
|
|
472
|
-
|
|
574
|
+
conversationId: deepLinkConversationId,
|
|
473
575
|
};
|
|
576
|
+
if (deepLinkPairing.messageId) {
|
|
577
|
+
deepLinkTarget = {
|
|
578
|
+
...deepLinkTarget,
|
|
579
|
+
messageId: deepLinkPairing.messageId,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
474
582
|
}
|
|
475
583
|
}
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
if (channel === "vellum" && pairing.conversationId) {
|
|
479
|
-
// Resolve guardian scoping for conversation-created events so clients
|
|
480
|
-
// can filter guardian-sensitive conversations the same way they filter
|
|
481
|
-
// guardian-sensitive notification intents.
|
|
482
|
-
const guardianPrincipalId =
|
|
483
|
-
typeof destination.metadata?.guardianPrincipalId === "string"
|
|
484
|
-
? destination.metadata.guardianPrincipalId
|
|
485
|
-
: undefined;
|
|
486
|
-
const targetGuardianPrincipalId =
|
|
487
|
-
guardianPrincipalId &&
|
|
488
|
-
isGuardianSensitiveEvent(signal.sourceEventName)
|
|
489
|
-
? guardianPrincipalId
|
|
490
|
-
: undefined;
|
|
491
|
-
|
|
492
|
-
const conversationTitle =
|
|
493
|
-
copy.conversationTitle ?? copy.title ?? signal.sourceEventName;
|
|
494
|
-
const conversationSilent =
|
|
495
|
-
signal.attentionHints.urgency !== "high" &&
|
|
496
|
-
signal.attentionHints.urgency !== "critical";
|
|
497
|
-
const info: ConversationCreatedInfo = {
|
|
498
|
-
conversationId: pairing.conversationId,
|
|
499
|
-
title: conversationTitle,
|
|
500
|
-
sourceEventName: signal.sourceEventName,
|
|
501
|
-
targetGuardianPrincipalId,
|
|
502
|
-
groupId: signal.conversationMetadata?.groupId,
|
|
503
|
-
source: signal.conversationMetadata?.source,
|
|
504
|
-
silent: conversationSilent,
|
|
505
|
-
};
|
|
506
584
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
585
|
+
if (channel === "vellum" && pairing.conversationId) {
|
|
586
|
+
// Resolve guardian scoping for conversation-created events so clients
|
|
587
|
+
// can filter guardian-sensitive conversations the same way they filter
|
|
588
|
+
// guardian-sensitive notification intents.
|
|
589
|
+
const guardianPrincipalId =
|
|
590
|
+
typeof destination.metadata?.guardianPrincipalId === "string"
|
|
591
|
+
? destination.metadata.guardianPrincipalId
|
|
592
|
+
: undefined;
|
|
593
|
+
const targetGuardianPrincipalId =
|
|
594
|
+
guardianPrincipalId &&
|
|
595
|
+
isGuardianSensitiveEvent(signal.sourceEventName)
|
|
596
|
+
? guardianPrincipalId
|
|
597
|
+
: undefined;
|
|
598
|
+
|
|
599
|
+
const conversationTitle =
|
|
600
|
+
copy.conversationTitle ?? copy.title ?? signal.sourceEventName;
|
|
601
|
+
const conversationSilent =
|
|
602
|
+
signal.attentionHints.urgency !== "high" &&
|
|
603
|
+
signal.attentionHints.urgency !== "critical";
|
|
604
|
+
const info: ConversationCreatedInfo = {
|
|
605
|
+
conversationId: pairing.conversationId,
|
|
606
|
+
title: conversationTitle,
|
|
607
|
+
sourceEventName: signal.sourceEventName,
|
|
608
|
+
targetGuardianPrincipalId,
|
|
609
|
+
groupId: signal.conversationMetadata?.groupId,
|
|
610
|
+
source: signal.conversationMetadata?.source,
|
|
611
|
+
silent: conversationSilent,
|
|
612
|
+
};
|
|
523
613
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
) {
|
|
532
|
-
if (this.onConversationCreated) {
|
|
614
|
+
// The per-dispatch onConversationCreated callback fires whenever a vellum
|
|
615
|
+
// conversation is paired (new or reused) because callers like
|
|
616
|
+
// dispatchGuardianQuestion rely on it to create delivery bookkeeping
|
|
617
|
+
// rows before emitNotificationSignal() returns. A returned promise is
|
|
618
|
+
// awaited so those rows are durable before the client can learn of
|
|
619
|
+
// the conversation and act on its approval card.
|
|
620
|
+
if (options?.onConversationCreated) {
|
|
533
621
|
try {
|
|
534
|
-
await
|
|
622
|
+
await options.onConversationCreated(info);
|
|
535
623
|
} catch (err) {
|
|
536
624
|
log.error(
|
|
537
625
|
{ err, signalId: signal.signalId },
|
|
538
|
-
"onConversationCreated callback failed
|
|
626
|
+
"per-dispatch onConversationCreated callback failed -- continuing broadcast",
|
|
539
627
|
);
|
|
540
628
|
}
|
|
541
629
|
}
|
|
630
|
+
|
|
631
|
+
// Emit notification_conversation_created event only when a NEW
|
|
632
|
+
// conversation was actually created. Reusing an existing conversation
|
|
633
|
+
// should not fire the event -- the client already knows about the
|
|
634
|
+
// conversation.
|
|
635
|
+
if (
|
|
636
|
+
pairing.createdNewConversation &&
|
|
637
|
+
pairing.strategy === "start_new_conversation"
|
|
638
|
+
) {
|
|
639
|
+
if (this.onConversationCreated) {
|
|
640
|
+
try {
|
|
641
|
+
await this.onConversationCreated(info);
|
|
642
|
+
} catch (err) {
|
|
643
|
+
log.error(
|
|
644
|
+
{ err, signalId: signal.signalId },
|
|
645
|
+
"onConversationCreated callback failed -- continuing broadcast",
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
542
650
|
}
|
|
543
|
-
}
|
|
544
651
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
652
|
+
const deliveryId = uuid();
|
|
653
|
+
const destinationLabel = destination.endpoint ?? channel;
|
|
654
|
+
|
|
655
|
+
const payload: ChannelDeliveryPayload = {
|
|
656
|
+
deliveryId,
|
|
657
|
+
correlationId: signal.signalId,
|
|
658
|
+
sourceEventName: signal.sourceEventName,
|
|
659
|
+
copy,
|
|
660
|
+
deepLinkTarget,
|
|
661
|
+
contextPayload: signal.contextPayload,
|
|
662
|
+
urgency: signal.attentionHints.urgency,
|
|
663
|
+
approvalContext,
|
|
664
|
+
accessRequestContext,
|
|
665
|
+
toolApprovalSource,
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
const dispatch: PendingChannelDispatch = {
|
|
669
|
+
adapter,
|
|
670
|
+
channel,
|
|
671
|
+
destination,
|
|
672
|
+
payload,
|
|
673
|
+
deliveryId,
|
|
674
|
+
destinationLabel,
|
|
675
|
+
pairing,
|
|
676
|
+
hasPersistedDecision,
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
// Compute conversation decision audit fields for the delivery record
|
|
680
|
+
const conversationAudit = {
|
|
681
|
+
conversationAction: conversationAction?.action ?? "start_new",
|
|
682
|
+
conversationTargetId:
|
|
683
|
+
conversationAction?.action === "reuse_existing"
|
|
684
|
+
? conversationAction.conversationId
|
|
685
|
+
: undefined,
|
|
686
|
+
conversationFallbackUsed: pairing.conversationFallbackUsed,
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
try {
|
|
690
|
+
if (hasPersistedDecision) {
|
|
691
|
+
createDelivery({
|
|
692
|
+
id: deliveryId,
|
|
693
|
+
notificationDecisionId: persistedDecisionId,
|
|
694
|
+
channel,
|
|
695
|
+
destination: destinationLabel,
|
|
696
|
+
status: "pending",
|
|
697
|
+
attempt: 1,
|
|
698
|
+
renderedTitle: copy.title,
|
|
699
|
+
renderedBody: copy.body,
|
|
700
|
+
conversationId: pairing.conversationId ?? undefined,
|
|
701
|
+
messageId: pairing.messageId ?? undefined,
|
|
702
|
+
conversationStrategy: pairing.strategy,
|
|
703
|
+
...conversationAudit,
|
|
704
|
+
});
|
|
705
|
+
} else {
|
|
706
|
+
log.warn(
|
|
707
|
+
{ channel, signalId: signal.signalId },
|
|
708
|
+
"No persisted decision ID -- skipping delivery record creation",
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
} catch (err) {
|
|
712
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
713
|
+
log.error(
|
|
714
|
+
{ err, channel, signalId: signal.signalId },
|
|
715
|
+
"Failed to create delivery record",
|
|
716
|
+
);
|
|
717
|
+
results.push(
|
|
718
|
+
buildDeliveryResult(dispatch, "failed", { errorMessage }),
|
|
590
719
|
);
|
|
720
|
+
continue;
|
|
591
721
|
}
|
|
592
722
|
|
|
593
|
-
|
|
723
|
+
if (channel === "vellum" && orderedChannels.includes("platform")) {
|
|
724
|
+
// The vellum intent carries remotePushDispatched, so its send waits
|
|
725
|
+
// for the platform outcome; everything else (pairing, events, the
|
|
726
|
+
// pending delivery row) already ran above.
|
|
727
|
+
deferredVellumSend = dispatch;
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
594
730
|
|
|
595
|
-
if (
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
731
|
+
if (channel === "platform" && deferredVellumSend) {
|
|
732
|
+
// The local banner is urgent: wait for the platform outcome only up
|
|
733
|
+
// to the deadline (a duplicate banner beats a late one). On expiry
|
|
734
|
+
// the vellum send flushes with remotePushDispatched=false while the
|
|
735
|
+
// dispatch keeps running, recording its delivery row when it
|
|
736
|
+
// settles; the late outcome cannot re-flush vellum.
|
|
737
|
+
const backgroundResults: NotificationDeliveryResult[] = [];
|
|
738
|
+
const platformSend = this.sendAndRecord(
|
|
739
|
+
dispatch,
|
|
740
|
+
signal,
|
|
741
|
+
backgroundResults,
|
|
742
|
+
{
|
|
743
|
+
onRemotePushPlatforms: (platforms) => {
|
|
744
|
+
platformRemotePushPlatforms = platforms;
|
|
745
|
+
},
|
|
746
|
+
},
|
|
747
|
+
).then((adapterResult) => {
|
|
748
|
+
platformRemotePushAccepted =
|
|
749
|
+
adapterResult?.remotePushAccepted === true;
|
|
750
|
+
platformRemotePushPlatforms = adapterResult?.remotePushPlatforms;
|
|
751
|
+
});
|
|
752
|
+
let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
|
|
753
|
+
const deadline = new Promise<"deadline">((resolve) => {
|
|
754
|
+
deadlineTimer = setTimeout(
|
|
755
|
+
() => resolve("deadline"),
|
|
756
|
+
options?.platformOutcomeDeadlineMs ??
|
|
757
|
+
PLATFORM_OUTCOME_DEADLINE_MS,
|
|
610
758
|
);
|
|
759
|
+
});
|
|
760
|
+
const raced = await Promise.race([
|
|
761
|
+
platformSend.then(() => "settled" as const),
|
|
762
|
+
deadline,
|
|
763
|
+
]);
|
|
764
|
+
clearTimeout(deadlineTimer);
|
|
765
|
+
if (raced === "deadline") {
|
|
766
|
+
log.warn(
|
|
767
|
+
{ channel, signalId: signal.signalId },
|
|
768
|
+
"Platform dispatch outcome unknown at deadline -- sending the local banner without it",
|
|
769
|
+
);
|
|
770
|
+
// The in-flight dispatch's result lands in backgroundResults,
|
|
771
|
+
// which this call no longer reads; its delivery row still gets
|
|
772
|
+
// the real terminal status.
|
|
773
|
+
results.push(buildDeliveryResult(dispatch, "pending"));
|
|
774
|
+
} else {
|
|
775
|
+
results.push(...backgroundResults);
|
|
611
776
|
}
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
777
|
+
await flushDeferredVellumSend();
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
await this.sendAndRecord(dispatch, signal, results);
|
|
782
|
+
}
|
|
783
|
+
} finally {
|
|
784
|
+
// Guarantees the vellum intent is emitted (and its pending delivery
|
|
785
|
+
// row resolved) even when a later channel's prep throws; also covers
|
|
786
|
+
// the common [vellum, platform] selection where no later channel
|
|
787
|
+
// triggered the mid-loop flush.
|
|
788
|
+
await flushDeferredVellumSend();
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
return results;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Dispatch a prepared payload through its channel adapter and record the
|
|
796
|
+
* outcome (delivery row status + results entry). Returns the adapter's
|
|
797
|
+
* result, or null when the send threw.
|
|
798
|
+
*/
|
|
799
|
+
private async sendAndRecord(
|
|
800
|
+
dispatch: PendingChannelDispatch,
|
|
801
|
+
signal: NotificationSignal,
|
|
802
|
+
results: NotificationDeliveryResult[],
|
|
803
|
+
observer?: ChannelDeliveryObserver,
|
|
804
|
+
): Promise<DeliveryResult | null> {
|
|
805
|
+
const {
|
|
806
|
+
adapter,
|
|
807
|
+
channel,
|
|
808
|
+
destination,
|
|
809
|
+
payload,
|
|
810
|
+
deliveryId,
|
|
811
|
+
pairing,
|
|
812
|
+
hasPersistedDecision,
|
|
813
|
+
} = dispatch;
|
|
814
|
+
try {
|
|
815
|
+
const adapterResult = await adapter.send(payload, destination, observer);
|
|
816
|
+
|
|
817
|
+
if (adapterResult.success) {
|
|
818
|
+
// Prefer the channel-native id the adapter just captured (e.g.
|
|
819
|
+
// Slack `ts`) so later edits can target the same message; fall
|
|
820
|
+
// back to the pairing-supplied id for channels that surface it
|
|
821
|
+
// through conversation pairing instead.
|
|
822
|
+
const resolvedMessageId =
|
|
823
|
+
adapterResult.messageId ?? pairing.messageId ?? undefined;
|
|
824
|
+
if (hasPersistedDecision) {
|
|
825
|
+
updateDeliveryStatus(
|
|
826
|
+
deliveryId,
|
|
827
|
+
"sent",
|
|
828
|
+
undefined,
|
|
829
|
+
adapterResult.messageId
|
|
830
|
+
? { messageId: adapterResult.messageId }
|
|
831
|
+
: undefined,
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
results.push(
|
|
835
|
+
buildDeliveryResult(dispatch, "sent", {
|
|
616
836
|
sentAt: Date.now(),
|
|
617
|
-
conversationId: pairing.conversationId ?? undefined,
|
|
618
837
|
messageId: resolvedMessageId,
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
});
|
|
626
|
-
}
|
|
627
|
-
results.push({
|
|
628
|
-
channel,
|
|
629
|
-
destination: destinationLabel,
|
|
630
|
-
status: "failed",
|
|
631
|
-
errorMessage: adapterResult.error,
|
|
632
|
-
conversationId: pairing.conversationId ?? undefined,
|
|
633
|
-
messageId: pairing.messageId ?? undefined,
|
|
634
|
-
conversationStrategy: pairing.strategy,
|
|
838
|
+
}),
|
|
839
|
+
);
|
|
840
|
+
} else {
|
|
841
|
+
if (hasPersistedDecision) {
|
|
842
|
+
updateDeliveryStatus(deliveryId, "failed", {
|
|
843
|
+
message: adapterResult.error,
|
|
635
844
|
});
|
|
636
845
|
}
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
"Unexpected error during channel delivery",
|
|
846
|
+
results.push(
|
|
847
|
+
buildDeliveryResult(dispatch, "failed", {
|
|
848
|
+
errorMessage: adapterResult.error,
|
|
849
|
+
}),
|
|
642
850
|
);
|
|
851
|
+
}
|
|
852
|
+
return adapterResult;
|
|
853
|
+
} catch (err) {
|
|
854
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
855
|
+
log.error(
|
|
856
|
+
{ err, channel, signalId: signal.signalId },
|
|
857
|
+
"Unexpected error during channel delivery",
|
|
858
|
+
);
|
|
643
859
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
}
|
|
860
|
+
if (hasPersistedDecision) {
|
|
861
|
+
try {
|
|
862
|
+
updateDeliveryStatus(deliveryId, "failed", {
|
|
863
|
+
message: errorMessage,
|
|
864
|
+
});
|
|
865
|
+
} catch {
|
|
866
|
+
// Swallow -- best-effort failure-status update
|
|
652
867
|
}
|
|
653
|
-
|
|
654
|
-
results.push({
|
|
655
|
-
channel,
|
|
656
|
-
destination: destinationLabel,
|
|
657
|
-
status: "failed",
|
|
658
|
-
errorMessage,
|
|
659
|
-
conversationId: pairing.conversationId ?? undefined,
|
|
660
|
-
messageId: pairing.messageId ?? undefined,
|
|
661
|
-
conversationStrategy: pairing.strategy,
|
|
662
|
-
});
|
|
663
868
|
}
|
|
664
|
-
}
|
|
665
869
|
|
|
666
|
-
|
|
870
|
+
results.push(buildDeliveryResult(dispatch, "failed", { errorMessage }));
|
|
871
|
+
return null;
|
|
872
|
+
}
|
|
667
873
|
}
|
|
668
874
|
}
|
|
669
875
|
|