@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.
- package/ARCHITECTURE.md +31 -22
- package/node_modules/@vellumai/gateway-client/src/index.ts +4 -1
- package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +38 -7
- package/package.json +1 -1
- package/src/__tests__/assistant-event-hub-targeted.test.ts +50 -0
- package/src/__tests__/assistant-stream-state.test.ts +22 -0
- package/src/__tests__/emit-signal-routing-intent.test.ts +1 -1
- package/src/__tests__/events-tail-route.test.ts +25 -0
- package/src/__tests__/heartbeat-service.test.ts +2 -59
- package/src/__tests__/notification-telegram-adapter.test.ts +2 -2
- package/src/__tests__/notification-vellum-adapter.test.ts +34 -0
- package/src/api/events/notification-conversation-created.ts +6 -4
- package/src/api/events/notification-intent.ts +4 -2
- package/src/api/index.ts +0 -6
- package/src/calls/__tests__/voice-escalation-judge.test.ts +209 -0
- package/src/calls/__tests__/voice-leg-coordinator.test.ts +168 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +161 -0
- package/src/calls/call-controller.ts +19 -0
- package/src/calls/voice-escalation-judge.ts +254 -0
- package/src/calls/voice-leg-coordinator.ts +121 -3
- package/src/calls/voice-session-bridge.ts +112 -2
- package/src/calls/voice-triage-escalate.ts +7 -0
- package/src/cli/commands/__tests__/notifications.test.ts +10 -10
- package/src/config/call-site-defaults.ts +5 -0
- package/src/config/schemas/call-site-catalog.ts +7 -0
- package/src/config/schemas/llm.ts +1 -0
- package/src/heartbeat/__tests__/heartbeat-service.test.ts +22 -26
- package/src/heartbeat/heartbeat-service.ts +5 -33
- package/src/ipc/assistant-server.ts +2 -0
- package/src/ipc/routes/__tests__/channel-reply-ipc-routes.test.ts +111 -0
- package/src/ipc/routes/channel-reply-ipc-routes.ts +73 -0
- package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +52 -1
- package/src/live-voice/live-voice-session.ts +110 -86
- package/src/messaging/providers/__tests__/transport-dispatch.test.ts +65 -11
- package/src/messaging/providers/discord/send.ts +3 -2
- package/src/messaging/providers/telegram-bot/send.test.ts +236 -1
- package/src/messaging/providers/telegram-bot/send.ts +10 -7
- package/src/messaging/providers/whatsapp/send.test.ts +169 -4
- package/src/notifications/README.md +27 -9
- package/src/notifications/__tests__/broadcaster.test.ts +1 -1
- package/src/notifications/__tests__/copy-composer.test.ts +1 -1
- package/src/notifications/__tests__/decision-engine.test.ts +4 -2
- package/src/notifications/__tests__/deterministic-checks.test.ts +10 -10
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +6 -4
- package/src/notifications/adapters/macos.ts +29 -38
- package/src/notifications/copy-composer.ts +0 -23
- package/src/notifications/deterministic-checks.ts +1 -1
- package/src/notifications/emit-signal.ts +17 -10
- package/src/notifications/home-feed-side-effect.ts +4 -6
- package/src/notifications/signal.ts +3 -18
- package/src/runtime/AGENTS.md +2 -0
- package/src/runtime/assistant-event-hub.ts +25 -1
- package/src/runtime/assistant-event-publish-options.ts +7 -0
- package/src/runtime/assistant-stream-state.ts +12 -0
- package/src/runtime/background-job-runner.ts +5 -5
- package/src/runtime/routes/events-routes.ts +59 -47
- 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
|
|
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 `
|
|
293
|
-
2. On `POST`, the gateway verifies the `X-Hub-Signature-256` header (HMAC-SHA256 of the raw request body using `
|
|
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`
|
|
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** (`
|
|
301
|
+
**Egress** (daemon WhatsApp transport, `src/messaging/providers/whatsapp/`):
|
|
302
302
|
|
|
303
|
-
1.
|
|
304
|
-
2.
|
|
305
|
-
3.
|
|
306
|
-
4.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
- **
|
|
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
|
|
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/
|
|
2054
|
-
| `assistant/src/notifications/
|
|
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
|
|
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
|
-
*
|
|
2
|
+
* Outbound channel delivery contract.
|
|
3
3
|
*
|
|
4
|
-
* Zod schemas
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
@@ -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.
|
|
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
|
|
338
|
-
//
|
|
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.
|
|
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
|
|
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`).
|
|
8
|
-
*
|
|
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
|
|
25
|
-
* guardian
|
|
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
|
|
6
|
-
*
|
|
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,
|