@vellumai/assistant 0.12.2-dev.202609181819.66f51a7 → 0.12.2-dev.202609182015.188de8a

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/ARCHITECTURE.md +31 -22
  2. package/node_modules/@vellumai/gateway-client/src/index.ts +4 -1
  3. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +38 -7
  4. package/package.json +1 -1
  5. package/src/__tests__/assistant-event-hub-targeted.test.ts +50 -0
  6. package/src/__tests__/assistant-stream-state.test.ts +22 -0
  7. package/src/__tests__/emit-signal-routing-intent.test.ts +1 -1
  8. package/src/__tests__/events-tail-route.test.ts +25 -0
  9. package/src/__tests__/heartbeat-service.test.ts +2 -59
  10. package/src/__tests__/notification-telegram-adapter.test.ts +2 -2
  11. package/src/__tests__/notification-vellum-adapter.test.ts +34 -0
  12. package/src/api/events/notification-conversation-created.ts +6 -4
  13. package/src/api/events/notification-intent.ts +4 -2
  14. package/src/api/index.ts +0 -6
  15. package/src/calls/__tests__/voice-escalation-judge.test.ts +209 -0
  16. package/src/calls/__tests__/voice-leg-coordinator.test.ts +168 -0
  17. package/src/calls/__tests__/voice-session-bridge.test.ts +161 -0
  18. package/src/calls/call-controller.ts +19 -0
  19. package/src/calls/voice-escalation-judge.ts +254 -0
  20. package/src/calls/voice-leg-coordinator.ts +121 -3
  21. package/src/calls/voice-session-bridge.ts +112 -2
  22. package/src/calls/voice-triage-escalate.ts +7 -0
  23. package/src/cli/commands/__tests__/notifications.test.ts +10 -10
  24. package/src/config/call-site-defaults.ts +5 -0
  25. package/src/config/schemas/call-site-catalog.ts +7 -0
  26. package/src/config/schemas/llm.ts +1 -0
  27. package/src/heartbeat/__tests__/heartbeat-service.test.ts +22 -26
  28. package/src/heartbeat/heartbeat-service.ts +5 -33
  29. package/src/ipc/assistant-server.ts +2 -0
  30. package/src/ipc/routes/__tests__/channel-reply-ipc-routes.test.ts +111 -0
  31. package/src/ipc/routes/channel-reply-ipc-routes.ts +73 -0
  32. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +52 -1
  33. package/src/live-voice/live-voice-session.ts +110 -86
  34. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +65 -11
  35. package/src/messaging/providers/discord/send.ts +3 -2
  36. package/src/messaging/providers/telegram-bot/send.test.ts +236 -1
  37. package/src/messaging/providers/telegram-bot/send.ts +10 -7
  38. package/src/messaging/providers/whatsapp/send.test.ts +169 -4
  39. package/src/notifications/README.md +27 -9
  40. package/src/notifications/__tests__/broadcaster.test.ts +1 -1
  41. package/src/notifications/__tests__/copy-composer.test.ts +1 -1
  42. package/src/notifications/__tests__/decision-engine.test.ts +4 -2
  43. package/src/notifications/__tests__/deterministic-checks.test.ts +10 -10
  44. package/src/notifications/__tests__/home-feed-side-effect.test.ts +6 -4
  45. package/src/notifications/adapters/macos.ts +29 -38
  46. package/src/notifications/copy-composer.ts +0 -23
  47. package/src/notifications/deterministic-checks.ts +1 -1
  48. package/src/notifications/emit-signal.ts +17 -10
  49. package/src/notifications/home-feed-side-effect.ts +4 -6
  50. package/src/notifications/signal.ts +3 -18
  51. package/src/runtime/AGENTS.md +2 -0
  52. package/src/runtime/assistant-event-hub.ts +25 -1
  53. package/src/runtime/assistant-event-publish-options.ts +7 -0
  54. package/src/runtime/assistant-stream-state.ts +12 -0
  55. package/src/runtime/background-job-runner.ts +5 -5
  56. package/src/runtime/routes/events-routes.ts +59 -47
  57. package/src/api/events/heartbeat-alert.ts +0 -20
package/ARCHITECTURE.md CHANGED
@@ -96,7 +96,7 @@ All HTTP API requests use a single `Authorization: Bearer <jwt>` header for auth
96
96
 
97
97
  **Credential storage:** Only hashed tokens are persisted. Access token hashes go in `credential_records`; refresh token hashes in `refresh_token_records`. Raw tokens are returned once and never stored server-side.
98
98
 
99
- **Notification scoping:** Guardian-sensitive notifications are annotated with `targetGuardianPrincipalId` for identity-scoped delivery.
99
+ **Notification scoping:** Guardian-sensitive notifications (`notification_intent`, `notification_conversation_created`) are published with `targetActorPrincipalId`, so the event hub delivers them, live, on reconnect replay, and from the `events/tail` recovery route, only to SSE connections whose verified `actorPrincipalId` is the guardian's. Connections without a principal never receive them. The payload's `targetGuardianPrincipalId` records the scoping.
100
100
 
101
101
  **Key source files:**
102
102
 
@@ -289,32 +289,35 @@ The WhatsApp channel enables inbound and outbound messaging via the Meta WhatsAp
289
289
 
290
290
  **Ingress** (`GET /webhooks/whatsapp` — verification, `POST /webhooks/whatsapp` — messages):
291
291
 
292
- 1. **Webhook verification**: Meta sends a `GET` with `hub.mode=subscribe`, `hub.verify_token`, and `hub.challenge`. The gateway compares `hub.verify_token` against `WHATSAPP_WEBHOOK_VERIFY_TOKEN` and echoes `hub.challenge` as plain text.
293
- 2. On `POST`, the gateway verifies the `X-Hub-Signature-256` header (HMAC-SHA256 of the raw request body using `WHATSAPP_APP_SECRET`) when the app secret is configured. Fail-closed: requests are rejected when the secret is set but the signature fails.
292
+ 1. **Webhook verification**: Meta sends a `GET` with `hub.mode=subscribe`, `hub.verify_token`, and `hub.challenge`. The gateway compares `hub.verify_token` against the `webhook_verify_token` credential and echoes `hub.challenge` as plain text.
293
+ 2. On `POST`, the gateway verifies the `X-Hub-Signature-256` header (HMAC-SHA256 of the raw request body using the `app_secret` credential). Fail-closed: a request is rejected with 500 when no app secret is configured, and with 403 when the signature does not match.
294
294
  3. **Normalization**: Text and media messages (image, audio, video, document, sticker) from `messages` change fields are forwarded. Delivery receipts, read receipts, and unsupported message types (contacts, location) are silently acknowledged with `{ ok: true }`. Media attachments are downloaded from the WhatsApp Cloud API, uploaded to the runtime attachment store, and their IDs are passed alongside the message content.
295
295
  4. **`/new` command**: When the message body is `/new` (case-insensitive), the gateway resolves routing, resets the conversation, and sends a confirmation message without forwarding to the runtime.
296
296
  5. The payload is normalized into a `GatewayInboundEvent` with `sourceChannel: "whatsapp"` and `conversationExternalId` set to the sender's WhatsApp phone number (E.164).
297
297
  6. WhatsApp message IDs are deduplicated via `StringDedupCache` (24-hour TTL).
298
298
  7. The gateway marks each inbound message as read (best-effort, fire-and-forget).
299
- 8. The event is forwarded to the runtime via `POST /channels/inbound` with WhatsApp-specific transport hints and a `replyCallbackUrl` pointing to `/deliver/whatsapp`.
299
+ 8. The event is forwarded to the runtime via `POST /channels/inbound` with WhatsApp-specific transport hints and a `replyCallbackUrl` of `<gatewayInternalBaseUrl>/deliver/whatsapp`. The gateway serves no route at that path: the daemon uses the URL only to address the reply (see Egress).
300
300
 
301
- **Egress** (`POST /deliver/whatsapp`):
301
+ **Egress** (daemon WhatsApp transport, `src/messaging/providers/whatsapp/`):
302
302
 
303
- 1. The runtime calls the gateway's `/deliver/whatsapp` endpoint with `{ to, text }` or `{ chatId, text }` (alias).
304
- 2. The gateway authenticates the request via bearer token (same fail-closed model as other deliver endpoints).
305
- 3. The gateway sends the message via the WhatsApp Cloud API `/{phoneNumberId}/messages` endpoint using the configured access token.
306
- 4. Text is split at 4096 characters if needed.
303
+ 1. Replies, approval prompts, and proactive sends go out through the daemon's `whatsappTransport` (`transport.ts`), which calls the Meta Cloud API itself. `isDirectDelivery()` in `src/messaging/providers/index.ts` resolves the `/deliver/whatsapp` callback URL to this transport through `channelForCallback()` (`src/messaging/providers/callback-routing.ts`), so the daemon never posts back to the gateway or dials the URL's host and port. The gateway's own replies to invite and verification codes it intercepts at ingress reach this transport the same way: `deliverVerificationReply` (`gateway/src/verification/reply-delivery.ts`) hands the reply and the inbound message's callback URL to the daemon's IPC-only `deliver_gateway_reply` method (`src/ipc/routes/channel-reply-ipc-routes.ts`), which resolves the transport from that URL.
304
+ 2. `api.ts` reads `phone_number_id` and `access_token` from the secure store and sends through the Cloud API `/{phoneNumberId}/messages` endpoint.
305
+ 3. `send.ts` splits text into 4096-character chunks, preferring newline and then whitespace boundaries.
306
+ 4. An approval prompt renders as an interactive message with up to three reply buttons whose ids follow the shared `apr:<requestId>:<action>` convention. When the cap forces a cut, the reject and block actions are kept. The gateway normalizes the guardian's `button_reply` back into callback data on ingress.
307
+ 5. Attachments up to 25 MB are uploaded to the Cloud API media endpoint and sent as image, video, or document messages. Any that fail are listed in a follow-up text notice.
307
308
 
308
- **Required credentials**:
309
+ The gateway sends only its own notices to WhatsApp (the `/new` confirmation and routing-rejection notices), through `gateway/src/whatsapp/send.ts`.
309
310
 
310
- - `WHATSAPP_PHONE_NUMBER_ID` — the numeric WhatsApp Business phone number ID from Meta
311
- - `WHATSAPP_ACCESS_TOKEN` — System User or temporary access token
312
- - `WHATSAPP_APP_SECRET` — App secret for webhook signature verification
313
- - `WHATSAPP_WEBHOOK_VERIFY_TOKEN` — Token for the Meta webhook subscription handshake
311
+ **Required credentials** (credential vault, `whatsapp` service):
314
312
 
315
- These can be set via environment variables or stored in the credential vault (CES / encrypted store) under the `whatsapp` service prefix.
313
+ - `phone_number_id`: the numeric WhatsApp Business phone number ID from Meta
314
+ - `access_token`: System User or temporary access token
315
+ - `app_secret`: app secret for webhook signature verification
316
+ - `webhook_verify_token`: token for the Meta webhook subscription handshake
316
317
 
317
- **Limitations (v1)**: Rich approval UI (inline buttons) is not supported. Contacts and location message types are acknowledged but not forwarded.
318
+ The daemon reads `phone_number_id` and `access_token` to send. The gateway reads all four: the last two for ingress, and the first two for its own notices.
319
+
320
+ **Limitations**: Contacts and location message types are acknowledged but not forwarded.
318
321
 
319
322
  **Channel Readiness**: The channel readiness HTTP endpoints (`GET /v1/channels/readiness`, `POST /v1/channels/readiness/refresh`) backed by `ChannelReadinessService` in `src/runtime/channel-readiness-service.ts` provide a unified readiness subsystem for all channels. Each channel registers a `ChannelProbe` that runs synchronous local checks (credential presence, ingress config) and optional async remote checks with a 5-minute TTL cache. Built-in probes: Telegram (bot token, webhook secret, ingress). The GET endpoint returns cached snapshots; the refresh endpoint invalidates the cache first. Unknown channels return `unsupported_channel`. Route handlers live in `src/runtime/routes/channel-readiness-routes.ts`.
320
323
 
@@ -1993,7 +1996,7 @@ An SSE push event surfaces new conversations in the macOS client sidebar:
1993
1996
 
1994
1997
  - **`notification_conversation_created`** — Emitted by `broadcaster.ts` when a notification delivery **creates** a new vellum conversation (strategy `start_new_conversation`, `createdNewConversation: true`). **Not** emitted when a conversation is reused. Payload: `{ conversationId, title, sourceEventName }`.
1995
1998
 
1996
- The event follows this pattern: the daemon creates a server-side conversation, persists an initial message, and broadcasts the SSE event so the macOS `ConversationManager` can create a visible conversation in the sidebar.
1999
+ The daemon persists the conversation and its initial message before broadcasting, so a client that receives the event can fetch the conversation immediately. A guardian-sensitive conversation is announced only to the guardian's own connections.
1997
2000
 
1998
2001
  ### Conversation Routing Decision Flow
1999
2002
 
@@ -2031,12 +2034,15 @@ Reminders carry optional `routingIntent` (`single_channel` | `multi_channel` | `
2031
2034
 
2032
2035
  ### Channel Delivery
2033
2036
 
2034
- Notifications are delivered to three channel types:
2037
+ Notifications are delivered to five channel types. The daemon sends to external channels itself, calling each provider's API through the send modules in `src/messaging/providers/<channel>/`; nothing goes through the gateway.
2035
2038
 
2036
2039
  - **Vellum (always connected)**: SSE via the daemon's broadcast mechanism. The `VellumAdapter` emits a `notification_intent` message with rendered copy and optional `deepLinkMetadata` (includes `conversationId` for conversation navigation and `messageId` for message-level scroll anchoring).
2037
- - **Telegram (when guardian binding exists)**: HTTP POST to the gateway's `/deliver/telegram` endpoint. Requires an active guardian binding for the assistant.
2040
+ - **Platform (always connected)**: the `PlatformPushAdapter` posts the notification to the platform's `/v1/assistants/{id}/push/dispatch/` endpoint for native mobile push. Without platform credentials the delivery is recorded as failed.
2041
+ - **Telegram (when the guardian has a chat ID)**: the `TelegramAdapter` calls the Telegram Bot API through `telegram-bot/send.ts`. Approval cards carry inline keyboard buttons and fall back to plain text with typed-reply instructions if the rich send fails.
2042
+ - **Slack (when the guardian's chat is a DM)**: the `SlackAdapter` posts through `slack/send.ts`. Only `D`-prefixed DM channels qualify, so a binding made from a shared channel never receives notifications.
2043
+ - **Discord (when the guardian binding names a user)**: the `DiscordAdapter` opens the guardian's DM through the Discord REST API and sends through `discord/send.ts`.
2038
2044
 
2039
- Connected channels are resolved at signal emission time: vellum is always included, and binding-based channels (Telegram) are included only when an active guardian binding exists for the assistant.
2045
+ Connected channels are resolved at signal emission time by `getConnectedChannels()` in `emit-signal.ts`, from the guardian delivery list (`getGuardianDelivery()`): vellum and platform are always included, and Telegram, Slack, and Discord are included only when that list gives the guardian a deliverable endpoint on the channel.
2040
2046
 
2041
2047
  **Key modules:**
2042
2048
 
@@ -2050,8 +2056,11 @@ Connected channels are resolved at signal emission time: vellum is always includ
2050
2056
  | `assistant/src/notifications/conversation-pairing.ts` | Materializes conversation + message per delivery; executes conversation reuse decisions |
2051
2057
  | `assistant/src/notifications/conversation-candidates.ts` | Builds per-channel candidate set of recent conversations for the decision engine |
2052
2058
  | `assistant/src/notifications/adapters/macos.ts` | Vellum adapter — broadcasts `notification_intent` via SSE with deep-link metadata |
2053
- | `assistant/src/notifications/adapters/telegram.ts` | Telegram adapter — POSTs to gateway `/deliver/telegram` |
2054
- | `assistant/src/notifications/destination-resolver.ts` | Resolves per-channel endpoints (vellum SSE, Telegram chat ID from guardian binding) |
2059
+ | `assistant/src/notifications/adapters/platform.ts` | Platform adapter: posts to the platform push-dispatch endpoint for native mobile push |
2060
+ | `assistant/src/notifications/adapters/telegram.ts` | Telegram adapter: calls the Telegram Bot API through `messaging/providers/telegram-bot/send.ts` |
2061
+ | `assistant/src/notifications/adapters/slack.ts` | Slack adapter: posts to the guardian's DM through `messaging/providers/slack/send.ts` |
2062
+ | `assistant/src/notifications/adapters/discord.ts` | Discord adapter: sends to the guardian's DM through `messaging/providers/discord/send.ts` |
2063
+ | `assistant/src/notifications/destination-resolver.ts` | Resolves per-channel endpoints from the guardian delivery list (Telegram and Slack chat ID, Discord user ID) |
2055
2064
  | `assistant/src/notifications/copy-composer.ts` | Template-based fallback copy when LLM copy is unavailable |
2056
2065
  | `assistant/src/notifications/preference-extractor.ts` | Detects preference statements in conversation messages |
2057
2066
  | `assistant/src/notifications/preferences-store.ts` | CRUD for user notification preferences |
@@ -24,13 +24,15 @@ export {
24
24
  PersistentIpcClient,
25
25
  } from "./ipc-client.js";
26
26
 
27
- // Outbound delivery contract (daemon → gateway) — Zod schemas + derived types
27
+ // Outbound delivery contract: Zod schemas + derived types
28
28
  export {
29
29
  ApprovalActionOptionSchema,
30
30
  ApprovalUIMetadataSchema,
31
31
  AttachmentMetadataSchema,
32
32
  ChannelDeliveryResultSchema,
33
33
  ChannelReplyPayloadSchema,
34
+ DELIVER_GATEWAY_REPLY_IPC_METHOD,
35
+ GatewayReplyRequestSchema,
34
36
  MessageAudienceSchema,
35
37
  PermissionRequestDetailsSchema,
36
38
  StreamOpSchema,
@@ -45,6 +47,7 @@ export type {
45
47
  AttachmentMetadata,
46
48
  ChannelDeliveryResult,
47
49
  ChannelReplyPayload,
50
+ GatewayReplyRequest,
48
51
  MessageAudience,
49
52
  PermissionRequestDetails,
50
53
  StreamOp,
@@ -1,13 +1,17 @@
1
1
  /**
2
- * Daemon → gateway outbound delivery contract.
2
+ * Outbound channel delivery contract.
3
3
  *
4
- * Zod schemas defining the wire format for channel replies delivered from
5
- * the daemon to the gateway via `POST /deliver/{channel}`. Both services
6
- * import from here so the contract is enforced at compile time.
4
+ * Zod schemas for a reply to a channel chat. The daemon constructs these
5
+ * payloads in `deliverChannelReply()` and `deliverApprovalPrompt()` and hands
6
+ * them to the channel transport its callback URL names
7
+ * (`messaging/providers`). `/deliver/{channel}` is only that callback URL's
8
+ * addressing form: no service serves it. A callback URL no
9
+ * transport owns (a managed callback carrying a `callback_token`) is POSTed
10
+ * over HTTP by `http-delivery.ts` instead.
7
11
  *
8
- * The daemon constructs these payloads in `deliverChannelReply()` and
9
- * `deliverApprovalPrompt()`; the gateway validates and dispatches them
10
- * to the target channel provider.
12
+ * The gateway sends through the same transports, never to a provider itself:
13
+ * a reply it composes for a message it answered at ingress goes to the daemon
14
+ * as a {@link GatewayReplyRequest}.
11
15
  */
12
16
 
13
17
  import type { KnownBlock } from "@slack/types";
@@ -279,6 +283,33 @@ export const ChannelReplyPayloadSchema = z.object({
279
283
 
280
284
  export type ChannelReplyPayload = z.infer<typeof ChannelReplyPayloadSchema>;
281
285
 
286
+ // ---------------------------------------------------------------------------
287
+ // Gateway reply: gateway to daemon
288
+ // ---------------------------------------------------------------------------
289
+
290
+ /**
291
+ * Daemon IPC method that delivers a {@link GatewayReplyRequest}. IPC-only:
292
+ * it has no HTTP route.
293
+ */
294
+ export const DELIVER_GATEWAY_REPLY_IPC_METHOD = "deliver_gateway_reply";
295
+
296
+ /**
297
+ * A text reply the gateway composed for an inbound message it answered
298
+ * itself (a verification code, an invite redemption), which the daemon
299
+ * delivers through the channel transport `callbackUrl` names. The callback
300
+ * URL is the one the inbound message carried, so the reply lands in the chat
301
+ * and thread the person wrote from.
302
+ */
303
+ export const GatewayReplyRequestSchema = ChannelReplyPayloadSchema.pick({
304
+ chatId: true,
305
+ assistantId: true,
306
+ }).extend({
307
+ callbackUrl: z.string().min(1),
308
+ text: z.string().min(1),
309
+ });
310
+
311
+ export type GatewayReplyRequest = z.infer<typeof GatewayReplyRequestSchema>;
312
+
282
313
  // ---------------------------------------------------------------------------
283
314
  // Channel delivery result — gateway response
284
315
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.2-dev.202609181819.66f51a7",
3
+ "version": "0.12.2-dev.202609182015.188de8a",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -257,3 +257,53 @@ describe("AssistantEventHub — getClientById()", () => {
257
257
  expect(hub.getClientById("client-x")).toBeUndefined();
258
258
  });
259
259
  });
260
+
261
+ // ── Principal targeting ───────────────────────────────────────────────────────
262
+
263
+ describe("AssistantEventHub principal targeting (targetActorPrincipalId)", () => {
264
+ test("delivers only to clients authenticated as that principal", async () => {
265
+ const hub = new AssistantEventHub();
266
+ const received: Record<string, number> = {
267
+ guardianMac: 0,
268
+ guardianWeb: 0,
269
+ otherUser: 0,
270
+ noPrincipal: 0,
271
+ process: 0,
272
+ };
273
+ const client = (
274
+ clientId: string,
275
+ key: string,
276
+ actorPrincipalId: string | undefined,
277
+ ) =>
278
+ hub.subscribe({
279
+ type: "client",
280
+ clientId,
281
+ interfaceId: "web",
282
+ capabilities: [],
283
+ ...(actorPrincipalId ? { actorPrincipalId } : {}),
284
+ callback: () => {
285
+ received[key]! += 1;
286
+ },
287
+ });
288
+ client("mac", "guardianMac", "principal-g");
289
+ client("web", "guardianWeb", "principal-g");
290
+ client("other", "otherUser", "principal-x");
291
+ client("legacy", "noPrincipal", undefined);
292
+ hub.subscribe({
293
+ type: "process",
294
+ callback: () => {
295
+ received.process! += 1;
296
+ },
297
+ });
298
+
299
+ await hub.publish(makeEvent(), { targetActorPrincipalId: "principal-g" });
300
+
301
+ expect(received).toEqual({
302
+ guardianMac: 1,
303
+ guardianWeb: 1,
304
+ otherUser: 0,
305
+ noPrincipal: 0,
306
+ process: 0,
307
+ });
308
+ });
309
+ });
@@ -491,6 +491,28 @@ describe("assistant-stream-state", () => {
491
491
  expect(procReplay).toEqual([]);
492
492
  });
493
493
 
494
+ test("principal-targeted events only replay to that principal's clients", () => {
495
+ stampAndBuffer(mkEvent(), {
496
+ targeting: { targetActorPrincipalId: "principal-g" },
497
+ });
498
+
499
+ const guardian = getReplayWindow(0, {
500
+ ...WEB_CLIENT,
501
+ actorPrincipalId: "principal-g",
502
+ });
503
+ const other = getReplayWindow(0, {
504
+ ...WEB_CLIENT,
505
+ actorPrincipalId: "principal-x",
506
+ });
507
+ const noPrincipal = getReplayWindow(0, WEB_CLIENT);
508
+ const proc = getReplayWindow(0, PROCESS_SUB);
509
+
510
+ expect(guardian!.map((e) => e.seq)).toEqual([1]);
511
+ expect(other).toEqual([]);
512
+ expect(noPrincipal).toEqual([]);
513
+ expect(proc).toEqual([]);
514
+ });
515
+
494
516
  test("mixed targeting across events filters per-entry", () => {
495
517
  /**
496
518
  * A ring with untargeted, capability-targeted, and excluded events
@@ -662,7 +662,7 @@ describe("high/critical urgency channel force", () => {
662
662
  sourceChannel: "watcher" | "assistant_tool",
663
663
  ) {
664
664
  return emitNotificationSignal({
665
- sourceEventName: "watcher.escalation",
665
+ sourceEventName: "watcher.notification",
666
666
  sourceChannel,
667
667
  sourceContextId: "watch-1",
668
668
  routingIntent: "single_channel",
@@ -170,6 +170,31 @@ describe("GET events/tail", () => {
170
170
  expect(other.events.map((e) => e.seq)).toEqual([1, 2]);
171
171
  });
172
172
 
173
+ test("returns a principal-targeted event only to that principal's client", () => {
174
+ stampAndBuffer(mkEvent()); // seq 1, untargeted
175
+ stampAndBuffer(mkEvent(), {
176
+ targeting: { targetActorPrincipalId: "principal-g" },
177
+ }); // seq 2
178
+
179
+ const client = {
180
+ "x-vellum-client-id": "c",
181
+ "x-vellum-interface-id": "web",
182
+ };
183
+ const guardian = callTail(
184
+ { conversationId: CONV, fromSeq: "0" },
185
+ { ...client, "x-vellum-actor-principal-id": "principal-g" },
186
+ );
187
+ const other = callTail(
188
+ { conversationId: CONV, fromSeq: "0" },
189
+ { ...client, "x-vellum-actor-principal-id": "principal-x" },
190
+ );
191
+ const anonymous = callTail({ conversationId: CONV, fromSeq: "0" });
192
+
193
+ expect(guardian.events.map((e) => e.seq)).toEqual([1, 2]);
194
+ expect(other.events.map((e) => e.seq)).toEqual([1]);
195
+ expect(anonymous.events.map((e) => e.seq)).toEqual([1]);
196
+ });
197
+
173
198
  test("toSeq bounds the window inclusively and moves the frontier", () => {
174
199
  // GIVEN five buffered events for the conversation (seq 1..5)
175
200
  for (let i = 0; i < 5; i++) {
@@ -334,11 +334,10 @@ const SCAFFOLD_PERSONA = stripCommentLines(GUARDIAN_PERSONA_TEMPLATE).trim();
334
334
  const { resolveCallSiteConfig } = await import("../config/llm-resolver.js");
335
335
  const { LLMSchema } = await import("../config/schemas/llm.js");
336
336
 
337
- // Capture broadcastMessage so tests can observe the alerts and
338
- // conversation-created events the heartbeat service emits directly.
337
+ // Capture broadcastMessage so tests can observe the conversation-created
338
+ // events the heartbeat service emits directly.
339
339
  type BroadcastedMessage = { type: string; [key: string]: unknown };
340
340
  const broadcastedMessages: BroadcastedMessage[] = [];
341
- let onBroadcast: ((msg: BroadcastedMessage) => void) | null = null;
342
341
 
343
342
  mock.module("../runtime/assistant-event-hub.js", () => ({
344
343
  assistantEventHub: {
@@ -347,7 +346,6 @@ mock.module("../runtime/assistant-event-hub.js", () => ({
347
346
  },
348
347
  broadcastMessage: (msg: BroadcastedMessage) => {
349
348
  broadcastedMessages.push(msg);
350
- onBroadcast?.(msg);
351
349
  },
352
350
  }));
353
351
 
@@ -357,7 +355,6 @@ describe("HeartbeatService", () => {
357
355
  content: string;
358
356
  options?: { callSite?: string };
359
357
  }>;
360
- let alerterCalls: Array<{ type: string; title: string; body: string }>;
361
358
 
362
359
  afterEach(() => {
363
360
  // Clean up workspace files between tests so file-existence tests don't leak
@@ -368,13 +365,7 @@ describe("HeartbeatService", () => {
368
365
 
369
366
  beforeEach(() => {
370
367
  processMessageCalls = [];
371
- alerterCalls = [];
372
368
  broadcastedMessages.length = 0;
373
- onBroadcast = (msg) => {
374
- if (msg.type === "heartbeat_alert") {
375
- alerterCalls.push(msg as { type: string; title: string; body: string });
376
- }
377
- };
378
369
  createdConversations.length = 0;
379
370
  conversationIdCounter = 0;
380
371
  mockStoredMessages.length = 0;
@@ -693,21 +684,6 @@ describe("HeartbeatService", () => {
693
684
  expect(processMessageCalls).toHaveLength(1);
694
685
  });
695
686
 
696
- test("alerts on processMessage failure", async () => {
697
- const service = createService({
698
- processMessage: async () => {
699
- throw new Error("LLM timeout");
700
- },
701
- });
702
-
703
- await service.runOnce();
704
-
705
- expect(alerterCalls).toHaveLength(1);
706
- expect(alerterCalls[0].type).toBe("heartbeat_alert");
707
- expect(alerterCalls[0].title).toBe("Heartbeat Failed");
708
- expect(alerterCalls[0].body).toBe("LLM timeout");
709
- });
710
-
711
687
  test("successful run updates lastRunAt and nextRunAt", async () => {
712
688
  const service = createService();
713
689
  expect(service.lastRunAt).toBeNull();
@@ -724,24 +700,6 @@ describe("HeartbeatService", () => {
724
700
  );
725
701
  });
726
702
 
727
- test("alerts on conversation creation failure", async () => {
728
- // Override createConversation to throw via a fresh import trick:
729
- // Since createConversation is mocked at module level, we simulate
730
- // this by having processMessage throw before it's called — but the
731
- // real fix is that executeRun wraps createConversation in the try/catch.
732
- // We verify by checking that any error in executeRun triggers the alert.
733
- const service = createService({
734
- processMessage: async () => {
735
- throw new Error("DB locked");
736
- },
737
- });
738
-
739
- await service.runOnce();
740
-
741
- expect(alerterCalls).toHaveLength(1);
742
- expect(alerterCalls[0].body).toBe("DB locked");
743
- });
744
-
745
703
  test("resetTimer() pushes nextRunAt forward", () => {
746
704
  const service = createService();
747
705
  service.start();
@@ -1467,21 +1425,6 @@ describe("HeartbeatService", () => {
1467
1425
  });
1468
1426
  });
1469
1427
 
1470
- test("CAS false suppresses failure alerter and feed event", async () => {
1471
- mockCompleteHeartbeatRun.mockImplementation(() => false);
1472
-
1473
- const service = createService({
1474
- processMessage: async () => {
1475
- throw new Error("LLM timeout");
1476
- },
1477
- });
1478
-
1479
- await service.runOnce();
1480
-
1481
- // completeHeartbeatRun returned false, so alerter should NOT be called
1482
- expect(alerterCalls).toHaveLength(0);
1483
- });
1484
-
1485
1428
  test("active-hours skip calls skipHeartbeatRun", async () => {
1486
1429
  setHeartbeatConfig({ activeHoursStart: 9, activeHoursEnd: 17 });
1487
1430
 
@@ -183,7 +183,7 @@ describe("TelegramAdapter", () => {
183
183
 
184
184
  await adapter.send(
185
185
  makePayload({
186
- sourceEventName: "watcher.escalation",
186
+ sourceEventName: "watcher.notification",
187
187
  copy: {
188
188
  title: " ",
189
189
  body: "",
@@ -191,7 +191,7 @@ describe("TelegramAdapter", () => {
191
191
  }),
192
192
  makeDestination(),
193
193
  );
194
- expect(sendCalls[2]?.text).toBe("watcher escalation");
194
+ expect(sendCalls[2]?.text).toBe("watcher notification");
195
195
  });
196
196
 
197
197
  // ── Access request inline keyboard tests ──────────────────────────────
@@ -35,6 +35,7 @@ import type {
35
35
  ChannelDeliveryPayload,
36
36
  ChannelDestination,
37
37
  } from "../notifications/types.js";
38
+ import type { BroadcastMessageOptions } from "../runtime/assistant-event-hub.js";
38
39
 
39
40
  function makePayload(
40
41
  overrides?: Partial<ChannelDeliveryPayload>,
@@ -362,3 +363,36 @@ describe("VellumAdapter update", () => {
362
363
  expect(result.error).toBe("DB write failed");
363
364
  });
364
365
  });
366
+
367
+ describe("VellumAdapter guardian scoping", () => {
368
+ function captureOptions(): {
369
+ adapter: VellumAdapter;
370
+ options: Array<BroadcastMessageOptions | undefined>;
371
+ } {
372
+ const options: Array<BroadcastMessageOptions | undefined> = [];
373
+ const adapter = new VellumAdapter((_msg, _conversationId, opts) => {
374
+ options.push(opts);
375
+ });
376
+ return { adapter, options };
377
+ }
378
+
379
+ test("a guardian-sensitive intent is delivered only to the guardian's connections", async () => {
380
+ const { adapter, options } = captureOptions();
381
+ await adapter.send(
382
+ makePayload({ sourceEventName: "guardian.question", urgency: "high" }),
383
+ makeDestination({ metadata: { guardianPrincipalId: "principal-g" } }),
384
+ );
385
+
386
+ expect(options).toEqual([{ targetActorPrincipalId: "principal-g" }]);
387
+ });
388
+
389
+ test("an ordinary intent is broadcast to every connection", async () => {
390
+ const { adapter, options } = captureOptions();
391
+ await adapter.send(
392
+ makePayload({ sourceEventName: "schedule.notify" }),
393
+ makeDestination({ metadata: { guardianPrincipalId: "principal-g" } }),
394
+ );
395
+
396
+ expect(options).toEqual([{ targetActorPrincipalId: undefined }]);
397
+ });
398
+ });
@@ -4,8 +4,9 @@
4
4
  * Server → client broadcast emitted when an incoming notification
5
5
  * creates a new vellum conversation. Clients use it to place the
6
6
  * conversation in the sidebar (`groupId`, `source`) and decide whether
7
- * to raise a fallback OS banner (`silent`). `targetGuardianPrincipalId`
8
- * scopes guardian-sensitive conversations to a bound identity.
7
+ * to raise a fallback OS banner (`silent`). A guardian-sensitive
8
+ * conversation is announced only to connections authenticated as the
9
+ * guardian named in `targetGuardianPrincipalId`.
9
10
  *
10
11
  * Canonical wire-contract source. Daemon code imports the type
11
12
  * directly from this file; external consumers import via
@@ -21,8 +22,9 @@ export const NotificationConversationCreatedEventSchema = z.object({
21
22
  sourceEventName: z.string(),
22
23
  /**
23
24
  * When set, this conversation was created for a guardian-sensitive
24
- * notification and should only be surfaced by clients bound to this
25
- * guardian identity.
25
+ * notification. The daemon delivers the event only to connections whose
26
+ * verified principal is this guardian, so every client that receives it
27
+ * is one it was meant for.
26
28
  */
27
29
  targetGuardianPrincipalId: z.string().optional(),
28
30
  /**
@@ -2,8 +2,10 @@
2
2
  * `notification_intent` SSE event.
3
3
  *
4
4
  * Broadcast when a notification should be displayed. Clients turn it
5
- * into a local OS / in-app notification, filter guardian-scoped intents
6
- * to matching devices, and ack delivery back to the daemon.
5
+ * into a local OS / in-app notification and ack delivery back to the
6
+ * daemon. A guardian-scoped intent (`targetGuardianPrincipalId` set) is
7
+ * delivered only to connections whose verified principal is that
8
+ * guardian, so every client that receives one is meant to show it.
7
9
  *
8
10
  * `silent` tells the client not to post the intent to the OS
9
11
  * notification surface (non-banner side effects still run); the server
package/src/api/index.ts CHANGED
@@ -50,7 +50,6 @@ import { DocumentEditorUpdateEventSchema } from "./events/document-editor-update
50
50
  import { ErrorEventSchema } from "./events/error.js";
51
51
  import { GenerationCancelledEventSchema } from "./events/generation-cancelled.js";
52
52
  import { GenerationHandoffEventSchema } from "./events/generation-handoff.js";
53
- import { HeartbeatAlertEventSchema } from "./events/heartbeat-alert.js";
54
53
  import { HeartbeatConversationCreatedEventSchema } from "./events/heartbeat-conversation-created.js";
55
54
  import { HomeFeedUpdatedEventSchema } from "./events/home-feed-updated.js";
56
55
  import { HookEventSchema } from "./events/hook-event.js";
@@ -395,10 +394,6 @@ export {
395
394
  type GenerationHandoffEvent,
396
395
  GenerationHandoffEventSchema,
397
396
  } from "./events/generation-handoff.js";
398
- export {
399
- type HeartbeatAlertEvent,
400
- HeartbeatAlertEventSchema,
401
- } from "./events/heartbeat-alert.js";
402
397
  export {
403
398
  type HeartbeatConversationCreatedEvent,
404
399
  HeartbeatConversationCreatedEventSchema,
@@ -1039,7 +1034,6 @@ export const AssistantEventSchema = z.discriminatedUnion("type", [
1039
1034
  ErrorEventSchema,
1040
1035
  GenerationCancelledEventSchema,
1041
1036
  GenerationHandoffEventSchema,
1042
- HeartbeatAlertEventSchema,
1043
1037
  HeartbeatConversationCreatedEventSchema,
1044
1038
  HomeFeedUpdatedEventSchema,
1045
1039
  HookEventSchema,