@vellumai/assistant 0.8.2 → 0.8.3
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 +11 -12
- package/docker-entrypoint.sh +13 -1
- package/docker-init-apt-root.sh +79 -6
- package/openapi.yaml +336 -21
- package/package.json +1 -1
- package/src/__tests__/agent-loop-exit-reason.test.ts +272 -0
- package/src/__tests__/agent-loop-provider-error-recording.test.ts +195 -0
- package/src/__tests__/compactor-tail-resolution.test.ts +107 -1
- package/src/__tests__/config-get-vision-flag.test.ts +136 -0
- package/src/__tests__/config-loader-backfill.test.ts +115 -18
- package/src/__tests__/context-token-estimator.test.ts +30 -65
- package/src/__tests__/conversation-agent-loop.test.ts +57 -1
- package/src/__tests__/conversation-media-retry.test.ts +19 -8
- package/src/__tests__/conversation-runtime-assembly.test.ts +26 -4
- package/src/__tests__/date-context.test.ts +45 -0
- package/src/__tests__/external-plugin-loader.test.ts +91 -19
- package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +0 -1
- package/src/__tests__/guardian-dispatch.test.ts +1 -0
- package/src/__tests__/heartbeat-service.test.ts +24 -164
- package/src/__tests__/helpers/channel-test-adapter.ts +0 -2
- package/src/__tests__/host-app-control-proxy.test.ts +241 -0
- package/src/__tests__/host-proxy-preactivation.test.ts +200 -13
- package/src/__tests__/injector-background-turn.test.ts +153 -0
- package/src/__tests__/injector-chain.test.ts +5 -0
- package/src/__tests__/lifecycle-memory-v2-seed.test.ts +9 -2
- package/src/__tests__/llm-callsite-catalog.test.ts +25 -0
- package/src/__tests__/llm-catalog-parity.test.ts +3 -0
- package/src/__tests__/llm-request-log-agent-loop-exit-reason.test.ts +116 -0
- package/src/__tests__/llm-request-log-error-payload.test.ts +138 -0
- package/src/__tests__/llm-request-log-source-clickhouse.test.ts +2 -0
- package/src/__tests__/llm-resolver.test.ts +255 -2
- package/src/__tests__/managed-profile-guard.test.ts +10 -0
- package/src/__tests__/notification-decision-fallback.test.ts +0 -91
- package/src/__tests__/notification-decision-strategy.test.ts +14 -31
- package/src/__tests__/notification-deep-link.test.ts +15 -0
- package/src/__tests__/notification-guardian-path.test.ts +1 -2
- package/src/__tests__/notification-platform-adapter.test.ts +5 -4
- package/src/__tests__/notification-telegram-adapter.test.ts +1 -0
- package/src/__tests__/notification-vellum-adapter.test.ts +113 -0
- package/src/__tests__/openai-provider.test.ts +218 -3
- package/src/__tests__/openai-responses-cutover-guard.test.ts +3 -3
- package/src/__tests__/openrouter-provider-only.test.ts +51 -3
- package/src/__tests__/openrouter-token-estimation.test.ts +34 -25
- package/src/__tests__/platform-proxy-context.test.ts +6 -1
- package/src/__tests__/plugin-tool-contribution.test.ts +3 -3
- package/src/__tests__/plugin-types.test.ts +2 -2
- package/src/__tests__/provider-catalog-visibility.test.ts +16 -0
- package/src/__tests__/provider-platform-proxy-integration.test.ts +27 -25
- package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -1
- package/src/__tests__/system-prompt.test.ts +6 -73
- package/src/__tests__/workspace-migration-087-memory-router-balanced-profile.test.ts +228 -0
- package/src/a2a/__tests__/agent-card.test.ts +98 -0
- package/src/a2a/__tests__/e2e-a2a-channel.test.ts +597 -0
- package/src/a2a/__tests__/protocol-helpers.test.ts +113 -0
- package/src/a2a/__tests__/task-store.test.ts +246 -0
- package/src/a2a/agent-card.ts +58 -0
- package/src/a2a/feature-gate.ts +8 -0
- package/src/a2a/protocol-constants.ts +21 -0
- package/src/a2a/protocol-errors.ts +50 -0
- package/src/a2a/protocol-types.ts +162 -0
- package/src/a2a/task-store.ts +168 -0
- package/src/agent/loop.ts +167 -18
- package/src/channels/config.ts +9 -0
- package/src/channels/types.ts +14 -0
- package/src/cli/{__tests__ → commands/__tests__}/notifications.test.ts +201 -28
- package/src/cli/commands/__tests__/schedules.test.ts +469 -0
- package/src/cli/commands/notifications.ts +65 -35
- package/src/cli/commands/plugins.ts +67 -0
- package/src/cli/commands/schedules.ts +297 -5
- package/src/cli/lib/__tests__/search-plugins.test.ts +261 -0
- package/src/cli/lib/install-from-github.ts +8 -9
- package/src/cli/lib/search-plugins.ts +163 -0
- package/src/cli/program.ts +14 -0
- package/src/config/assistant-feature-flags.ts +24 -54
- package/src/config/bundled-skills/app-builder/SKILL.md +117 -1
- package/src/config/bundled-skills/phone-calls/SKILL.md +1 -1
- package/src/config/call-site-defaults.ts +105 -0
- package/src/config/feature-flag-registry.json +21 -29
- package/src/config/llm-resolver.ts +52 -1
- package/src/config/schema.ts +2 -0
- package/src/config/schemas/__tests__/memory-v2.test.ts +3 -3
- package/src/config/schemas/channels.ts +9 -0
- package/src/config/schemas/conversations.ts +10 -0
- package/src/config/schemas/heartbeat.ts +14 -0
- package/src/config/schemas/llm.ts +1 -3
- package/src/config/schemas/memory-retrospective.ts +1 -1
- package/src/config/schemas/memory-v2.ts +4 -4
- package/src/config/schemas/memory.ts +3 -1
- package/src/config/seed-inference-profiles.ts +99 -29
- package/src/context/compactor.ts +72 -12
- package/src/context/token-estimator.ts +32 -34
- package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +3 -22
- package/src/daemon/conversation-agent-loop-handlers.ts +78 -0
- package/src/daemon/conversation-agent-loop.ts +29 -2
- package/src/daemon/conversation-runtime-assembly.ts +9 -0
- package/src/daemon/conversation.ts +0 -7
- package/src/daemon/date-context.ts +40 -0
- package/src/daemon/guardian-action-generators.ts +1 -125
- package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +248 -0
- package/src/daemon/handlers/__tests__/config-a2a-invite.test.ts +154 -0
- package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +133 -0
- package/src/daemon/handlers/__tests__/config-a2a.test.ts +95 -0
- package/src/daemon/handlers/config-a2a.ts +289 -0
- package/src/daemon/handlers/conversations.ts +1 -0
- package/src/daemon/host-app-control-proxy.ts +69 -18
- package/src/daemon/host-proxy-preactivation.ts +85 -18
- package/src/daemon/lifecycle.ts +49 -61
- package/src/daemon/memory-v2-startup.ts +49 -13
- package/src/daemon/message-types/notifications.ts +21 -0
- package/src/daemon/pkb-reminder-builder.test.ts +10 -53
- package/src/daemon/pkb-reminder-builder.ts +4 -19
- package/src/daemon/process-message.ts +3 -0
- package/src/daemon/skill-memory-refresh.ts +5 -1
- package/src/daemon/wake-target-adapter.ts +2 -0
- package/src/export/__tests__/transcript-formatter.test.ts +121 -0
- package/src/export/transcript-formatter.ts +54 -20
- package/src/heartbeat/__tests__/heartbeat-service.test.ts +44 -0
- package/src/heartbeat/heartbeat-service.ts +34 -191
- package/src/home/__tests__/feed-types.test.ts +40 -0
- package/src/home/feed-types.ts +14 -2
- package/src/ipc/cli-client.ts +147 -45
- package/src/memory/__tests__/conversation-queries.test.ts +220 -0
- package/src/memory/__tests__/memory-retrospective-enqueue.test.ts +2 -50
- package/src/memory/__tests__/memory-retrospective-job.test.ts +87 -4
- package/src/memory/conversation-queries.ts +87 -1
- package/src/memory/conversation-title-service.ts +26 -4
- package/src/memory/db-init.ts +6 -0
- package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +84 -3
- package/src/memory/graph/conversation-graph-memory.ts +18 -6
- package/src/memory/graph/tools.ts +6 -37
- package/src/memory/invite-store.ts +53 -0
- package/src/memory/llm-request-log-source-clickhouse.ts +7 -2
- package/src/memory/llm-request-log-store.ts +92 -1
- package/src/memory/memory-retrospective-enqueue.ts +1 -20
- package/src/memory/memory-retrospective-job.ts +33 -6
- package/src/memory/migrations/250-provider-connection-base-url-and-models.ts +28 -0
- package/src/memory/migrations/251-a2a-tasks.ts +49 -0
- package/src/memory/migrations/252-llm-request-log-agent-loop-exit-reason.ts +32 -0
- package/src/memory/migrations/index.ts +3 -0
- package/src/memory/migrations/registry.ts +8 -0
- package/src/memory/schema/a2a.ts +15 -0
- package/src/memory/schema/index.ts +1 -0
- package/src/memory/schema/inference.ts +2 -0
- package/src/memory/schema/infrastructure.ts +1 -0
- package/src/memory/v2/__tests__/activation-store.test.ts +25 -23
- package/src/memory/v2/__tests__/cli-command-store.test.ts +404 -0
- package/src/memory/v2/__tests__/frontmatter-sweep.test.ts +25 -4
- package/src/memory/v2/__tests__/injection.test.ts +190 -3
- package/src/memory/v2/__tests__/static-context.test.ts +12 -1
- package/src/memory/v2/activation-store.ts +14 -16
- package/src/memory/v2/cli-command-content.ts +19 -0
- package/src/memory/v2/cli-command-store.ts +304 -0
- package/src/memory/v2/frontmatter-sweep.ts +7 -1
- package/src/memory/v2/injection.ts +49 -20
- package/src/memory/v2/page-index.ts +38 -13
- package/src/memory/v2/static-context.ts +4 -4
- package/src/memory/v2/types.ts +23 -0
- package/src/messaging/providers/a2a/__tests__/deliver.test.ts +274 -0
- package/src/messaging/providers/a2a/deliver.ts +156 -0
- package/src/messaging/providers/gmail/client.ts +9 -2
- package/src/messaging/providers/index.ts +11 -2
- package/src/notifications/__tests__/broadcaster.test.ts +203 -0
- package/src/notifications/__tests__/decision-engine.test.ts +283 -0
- package/src/notifications/__tests__/deterministic-checks.test.ts +286 -0
- package/src/notifications/__tests__/emit-signal-home-feed.test.ts +1 -0
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +430 -7
- package/src/notifications/adapters/macos.ts +12 -2
- package/src/notifications/broadcaster.ts +29 -4
- package/src/notifications/copy-composer.ts +17 -64
- package/src/notifications/decision-engine.ts +111 -44
- package/src/notifications/deterministic-checks.ts +96 -0
- package/src/notifications/emit-signal.ts +1 -0
- package/src/notifications/home-feed-side-effect.ts +85 -6
- package/src/notifications/signal.ts +0 -4
- package/src/notifications/types.ts +8 -0
- package/src/oauth/platform-connection.test.ts +43 -3
- package/src/oauth/platform-connection.ts +13 -4
- package/src/plugins/defaults/injectors.ts +38 -19
- package/src/plugins/external-plugin-loader.ts +82 -10
- package/src/plugins/types.ts +16 -7
- package/src/prompts/__tests__/system-prompt.test.ts +6 -51
- package/src/prompts/__tests__/task-progress-hint-section.test.ts +4 -8
- package/src/prompts/system-prompt.ts +0 -8
- package/src/prompts/templates/BOOTSTRAP.md +5 -5
- package/src/prompts/templates/system-sections.ts +0 -9
- package/src/providers/__tests__/inference.test.ts +2 -0
- package/src/providers/call-site-routing.ts +24 -6
- package/src/providers/connection-resolution.ts +63 -13
- package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +74 -0
- package/src/providers/inference/__tests__/connections-openai-compatible.test.ts +175 -0
- package/src/providers/inference/__tests__/connections-status-label.test.ts +15 -0
- package/src/providers/inference/adapter-factory.ts +9 -20
- package/src/providers/inference/auth.ts +12 -0
- package/src/providers/inference/backfill.ts +14 -1
- package/src/providers/inference/connections.ts +85 -5
- package/src/providers/inference/resolve-auth.ts +2 -0
- package/src/providers/model-catalog.ts +199 -244
- package/src/providers/model-intents.ts +3 -3
- package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +235 -0
- package/src/providers/openai/chat-completions-provider.ts +159 -6
- package/src/providers/openrouter/client.ts +42 -4
- package/src/providers/platform-proxy/constants.ts +3 -4
- package/src/providers/provider-catalog-visibility.ts +3 -1
- package/src/providers/provider-send-message.ts +27 -12
- package/src/providers/registry.ts +30 -1
- package/src/runtime/agent-wake.ts +61 -1
- package/src/runtime/auth/route-policy.ts +13 -0
- package/src/runtime/http-server.ts +7 -16
- package/src/runtime/http-types.ts +0 -47
- package/src/runtime/routes/__tests__/consolidation-routes.test.ts +258 -0
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +66 -4
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +275 -44
- package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +12 -0
- package/src/runtime/routes/channel-availability-routes.ts +5 -0
- package/src/runtime/routes/consolidation-routes.ts +100 -0
- package/src/runtime/routes/conversation-query-routes.ts +70 -11
- package/src/runtime/routes/conversation-routes.ts +7 -0
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/inference-provider-connection-routes.ts +134 -1
- package/src/runtime/routes/integrations/a2a.ts +235 -0
- package/src/runtime/routes/llm-call-sites-routes.ts +11 -1
- package/src/runtime/routes/subagents-routes.ts +41 -0
- package/src/subagent/manager.ts +2 -0
- package/src/tools/memory/register.ts +1 -9
- package/src/tools/registry.ts +2 -2
- package/src/tools/types.ts +37 -2
- package/src/workspace/migrations/087-memory-router-balanced-profile.ts +91 -0
- package/src/workspace/migrations/registry.ts +2 -0
- package/src/__tests__/guardian-action-conversation-turn.test.ts +0 -441
- package/src/memory/graph/__tests__/remember-description.test.ts +0 -55
- package/src/runtime/guardian-action-conversation-turn.ts +0 -99
|
@@ -422,13 +422,16 @@ async function executeBatchCall(
|
|
|
422
422
|
/** Max concurrent individual getMessage requests (matches batch concurrency) */
|
|
423
423
|
const INDIVIDUAL_CONCURRENCY = BATCH_CONCURRENCY;
|
|
424
424
|
|
|
425
|
+
/** Delay between waves of individual fetches to avoid rate-limit storms (ms). */
|
|
426
|
+
const INTER_WAVE_DELAY_MS = 500;
|
|
427
|
+
|
|
425
428
|
/**
|
|
426
429
|
* Fetch all messages individually using getMessage (no batch endpoint).
|
|
427
430
|
* Used as a fallback when the batch API is unavailable (e.g. platform connections
|
|
428
431
|
* that cannot expose raw tokens for the multipart batch endpoint).
|
|
429
432
|
*
|
|
430
|
-
* Processes messages in waves of INDIVIDUAL_CONCURRENCY
|
|
431
|
-
*
|
|
433
|
+
* Processes messages in waves of INDIVIDUAL_CONCURRENCY with a brief inter-wave
|
|
434
|
+
* delay to avoid triggering upstream rate limits on high-volume paths.
|
|
432
435
|
*/
|
|
433
436
|
async function fetchMessagesIndividually(
|
|
434
437
|
connection: OAuthConnection,
|
|
@@ -441,6 +444,10 @@ async function fetchMessagesIndividually(
|
|
|
441
444
|
const results: GmailMessage[] = [];
|
|
442
445
|
for (let i = 0; i < messageIds.length; i += INDIVIDUAL_CONCURRENCY) {
|
|
443
446
|
signal?.throwIfAborted();
|
|
447
|
+
// Delay between waves (skip before the first wave)
|
|
448
|
+
if (i > 0) {
|
|
449
|
+
await signalAwareSleep(INTER_WAVE_DELAY_MS, signal);
|
|
450
|
+
}
|
|
444
451
|
const wave = messageIds.slice(i, i + INDIVIDUAL_CONCURRENCY);
|
|
445
452
|
const waveResults = await Promise.all(
|
|
446
453
|
wave.map((id) =>
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* matcher and send logic here. The gateway-client consults
|
|
6
6
|
* `isDirectDelivery()` before falling back to the HTTP proxy path.
|
|
7
7
|
*
|
|
8
|
-
* Currently supported: WhatsApp, Telegram, Slack.
|
|
8
|
+
* Currently supported: WhatsApp, Telegram, Slack, A2A.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type {
|
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
import { ChannelDeliveryError } from "@vellumai/gateway-client/http-delivery";
|
|
16
16
|
|
|
17
17
|
import { getLogger } from "../../util/logger.js";
|
|
18
|
+
import { deliverA2AReply } from "./a2a/deliver.js";
|
|
18
19
|
import {
|
|
19
20
|
sendSlackAssistantThreadStatus,
|
|
20
21
|
sendSlackAttachments,
|
|
@@ -59,6 +60,10 @@ function isSlackCallback(callbackUrl: string): boolean {
|
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
function isA2ACallback(callbackUrl: string): boolean {
|
|
64
|
+
return matchesPathname(callbackUrl, "/deliver/a2a");
|
|
65
|
+
}
|
|
66
|
+
|
|
62
67
|
function parseSlackCallbackParams(callbackUrl: string): {
|
|
63
68
|
channel?: string;
|
|
64
69
|
threadTs?: string;
|
|
@@ -233,7 +238,8 @@ export function isDirectDelivery(callbackUrl: string): boolean {
|
|
|
233
238
|
return (
|
|
234
239
|
isWhatsAppCallback(callbackUrl) ||
|
|
235
240
|
isTelegramCallback(callbackUrl) ||
|
|
236
|
-
isSlackCallback(callbackUrl)
|
|
241
|
+
isSlackCallback(callbackUrl) ||
|
|
242
|
+
isA2ACallback(callbackUrl)
|
|
237
243
|
);
|
|
238
244
|
}
|
|
239
245
|
|
|
@@ -254,6 +260,9 @@ export async function deliverDirect(
|
|
|
254
260
|
if (isSlackCallback(callbackUrl)) {
|
|
255
261
|
return deliverSlack(callbackUrl, payload);
|
|
256
262
|
}
|
|
263
|
+
if (isA2ACallback(callbackUrl)) {
|
|
264
|
+
return deliverA2AReply(callbackUrl, payload);
|
|
265
|
+
}
|
|
257
266
|
|
|
258
267
|
// Defensive — isDirectDelivery should have returned false.
|
|
259
268
|
throw new Error(
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifies the NotificationBroadcaster's fail-closed copy-resolution
|
|
3
|
+
* invariant: when neither `decision.renderedCopy[channel]` nor
|
|
4
|
+
* `composeFallbackCopy(...)[channel]` produces usable copy, the channel
|
|
5
|
+
* must be dropped rather than delivered with a synthesized body.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
9
|
+
|
|
10
|
+
import type { NotificationSignal } from "../signal.js";
|
|
11
|
+
import type {
|
|
12
|
+
ChannelAdapter,
|
|
13
|
+
ChannelDeliveryPayload,
|
|
14
|
+
ChannelDestination,
|
|
15
|
+
DeliveryResult,
|
|
16
|
+
NotificationDecision,
|
|
17
|
+
} from "../types.js";
|
|
18
|
+
|
|
19
|
+
// ── Module mocks ────────────────────────────────────────────────────────
|
|
20
|
+
//
|
|
21
|
+
// `mock.module` is hoisted, so these intercepts apply before the module
|
|
22
|
+
// under test resolves its imports. State is reset in `beforeEach`.
|
|
23
|
+
|
|
24
|
+
let composeFallbackReturn: Record<string, unknown> = {};
|
|
25
|
+
|
|
26
|
+
mock.module("../copy-composer.js", () => ({
|
|
27
|
+
composeFallbackCopy: () => composeFallbackReturn,
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
mock.module("../destination-resolver.js", () => ({
|
|
31
|
+
resolveDestinations: (channels: string[]) => {
|
|
32
|
+
const map = new Map<string, ChannelDestination>();
|
|
33
|
+
for (const ch of channels) {
|
|
34
|
+
map.set(ch, { channel: ch as ChannelDestination["channel"] });
|
|
35
|
+
}
|
|
36
|
+
return map;
|
|
37
|
+
},
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
mock.module("../conversation-pairing.js", () => ({
|
|
41
|
+
pairDeliveryWithConversation: async () => ({
|
|
42
|
+
conversationId: undefined,
|
|
43
|
+
messageId: undefined,
|
|
44
|
+
strategy: "start_new_conversation",
|
|
45
|
+
createdNewConversation: false,
|
|
46
|
+
conversationFallbackUsed: false,
|
|
47
|
+
}),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
mock.module("../deliveries-store.js", () => ({
|
|
51
|
+
createDelivery: () => {},
|
|
52
|
+
updateDeliveryStatus: () => {},
|
|
53
|
+
findDeliveryByDecisionAndChannel: () => undefined,
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
mock.module("../adapters/macos.js", () => ({
|
|
57
|
+
isGuardianSensitiveEvent: () => false,
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
mock.module("../../util/logger.js", () => ({
|
|
61
|
+
getLogger: () =>
|
|
62
|
+
new Proxy({} as Record<string, unknown>, {
|
|
63
|
+
get: () => () => {},
|
|
64
|
+
}),
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
const { NotificationBroadcaster } = await import("../broadcaster.js");
|
|
68
|
+
|
|
69
|
+
// ── Test fixtures ───────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
function makeSignal(
|
|
72
|
+
overrides: Partial<NotificationSignal> = {},
|
|
73
|
+
): NotificationSignal {
|
|
74
|
+
return {
|
|
75
|
+
signalId: "sig-test-1",
|
|
76
|
+
createdAt: 1700000000000,
|
|
77
|
+
sourceChannel: "scheduler",
|
|
78
|
+
sourceContextId: "ctx-1",
|
|
79
|
+
sourceEventName: "user.send_notification",
|
|
80
|
+
contextPayload: {},
|
|
81
|
+
attentionHints: {
|
|
82
|
+
requiresAction: false,
|
|
83
|
+
urgency: "medium",
|
|
84
|
+
isAsyncBackground: false,
|
|
85
|
+
visibleInSourceNow: false,
|
|
86
|
+
},
|
|
87
|
+
...overrides,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function makeDecision(
|
|
92
|
+
overrides: Partial<NotificationDecision> = {},
|
|
93
|
+
): NotificationDecision {
|
|
94
|
+
return {
|
|
95
|
+
shouldNotify: true,
|
|
96
|
+
selectedChannels: ["vellum"],
|
|
97
|
+
reasoningSummary: "test",
|
|
98
|
+
renderedCopy: {},
|
|
99
|
+
dedupeKey: "dk-1",
|
|
100
|
+
confidence: 1,
|
|
101
|
+
fallbackUsed: false,
|
|
102
|
+
persistedDecisionId: "dec-1",
|
|
103
|
+
...overrides,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface CapturedSend {
|
|
108
|
+
payload: ChannelDeliveryPayload;
|
|
109
|
+
destination: ChannelDestination;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function makeCapturingAdapter(channel: "vellum"): {
|
|
113
|
+
adapter: ChannelAdapter;
|
|
114
|
+
sends: CapturedSend[];
|
|
115
|
+
} {
|
|
116
|
+
const sends: CapturedSend[] = [];
|
|
117
|
+
const adapter: ChannelAdapter = {
|
|
118
|
+
channel,
|
|
119
|
+
async send(
|
|
120
|
+
payload: ChannelDeliveryPayload,
|
|
121
|
+
destination: ChannelDestination,
|
|
122
|
+
): Promise<DeliveryResult> {
|
|
123
|
+
sends.push({ payload, destination });
|
|
124
|
+
return { success: true };
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
return { adapter, sends };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
beforeEach(() => {
|
|
131
|
+
composeFallbackReturn = {};
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ── Tests ───────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
describe("NotificationBroadcaster last-resort copy resolution", () => {
|
|
137
|
+
test(
|
|
138
|
+
"skips channel and does not leak raw event name when both decision " +
|
|
139
|
+
"copy and fallback composer return no usable copy",
|
|
140
|
+
async () => {
|
|
141
|
+
// Fallback composer returns nothing for the channel — the formerly
|
|
142
|
+
// leaky `??` branch in broadcaster.ts would synthesize
|
|
143
|
+
// `{ title: "Notification", body: signal.sourceEventName }`.
|
|
144
|
+
composeFallbackReturn = {};
|
|
145
|
+
|
|
146
|
+
const { adapter, sends } = makeCapturingAdapter("vellum");
|
|
147
|
+
const broadcaster = new NotificationBroadcaster([adapter]);
|
|
148
|
+
|
|
149
|
+
const signal = makeSignal();
|
|
150
|
+
const decision = makeDecision({ renderedCopy: {} });
|
|
151
|
+
|
|
152
|
+
const results = await broadcaster.broadcastDecision(signal, decision);
|
|
153
|
+
|
|
154
|
+
// Adapter must NOT receive a payload at all — the channel is skipped
|
|
155
|
+
// before the adapter is invoked, so the leak path cannot fire.
|
|
156
|
+
expect(sends.length).toBe(0);
|
|
157
|
+
|
|
158
|
+
expect(results.length).toBe(1);
|
|
159
|
+
expect(results[0]?.status).toBe("skipped");
|
|
160
|
+
expect(results[0]?.errorMessage).toContain("rendered copy");
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
test("skips channel when fallback composer returns an entry with an empty body", async () => {
|
|
165
|
+
// `composeFallbackCopy` can produce empty bodies via `buildGenericCopy`
|
|
166
|
+
// when no template matches the source event. The broadcaster must
|
|
167
|
+
// refuse to deliver empty-body copy rather than passing it through.
|
|
168
|
+
composeFallbackReturn = {
|
|
169
|
+
vellum: { title: "Notification", body: "" },
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const { adapter, sends } = makeCapturingAdapter("vellum");
|
|
173
|
+
const broadcaster = new NotificationBroadcaster([adapter]);
|
|
174
|
+
|
|
175
|
+
const signal = makeSignal();
|
|
176
|
+
const decision = makeDecision({ renderedCopy: {} });
|
|
177
|
+
|
|
178
|
+
const results = await broadcaster.broadcastDecision(signal, decision);
|
|
179
|
+
|
|
180
|
+
expect(sends.length).toBe(0);
|
|
181
|
+
expect(results.length).toBe(1);
|
|
182
|
+
expect(results[0]?.status).toBe("skipped");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("delivers normally when fallback composer returns a usable body", async () => {
|
|
186
|
+
composeFallbackReturn = {
|
|
187
|
+
vellum: { title: "Reminder", body: "Time to drink water" },
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const { adapter, sends } = makeCapturingAdapter("vellum");
|
|
191
|
+
const broadcaster = new NotificationBroadcaster([adapter]);
|
|
192
|
+
|
|
193
|
+
const signal = makeSignal();
|
|
194
|
+
const decision = makeDecision({ renderedCopy: {} });
|
|
195
|
+
|
|
196
|
+
const results = await broadcaster.broadcastDecision(signal, decision);
|
|
197
|
+
|
|
198
|
+
expect(sends.length).toBe(1);
|
|
199
|
+
expect(sends[0]?.payload.copy.body).toBe("Time to drink water");
|
|
200
|
+
expect(results.length).toBe(1);
|
|
201
|
+
expect(results[0]?.status).toBe("sent");
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the assistant_tool pass-through path in the notification
|
|
3
|
+
* decision engine. When a producer hands us a verbatim message via
|
|
4
|
+
* contextPayload.requestedMessage, the engine must skip the LLM call
|
|
5
|
+
* entirely and use the producer-supplied copy as-is.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
9
|
+
|
|
10
|
+
// ── Mocks (must precede imports from mocked modules) ──────────────────
|
|
11
|
+
|
|
12
|
+
mock.module("../../channels/config.js", () => ({
|
|
13
|
+
getDeliverableChannels: () => ["vellum", "telegram"],
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
mock.module("../decisions-store.js", () => ({
|
|
17
|
+
createDecision: () => {},
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
mock.module("../preference-summary.js", () => ({
|
|
21
|
+
getPreferenceSummary: () => undefined,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
mock.module("../conversation-candidates.js", () => ({
|
|
25
|
+
buildConversationCandidates: () => undefined,
|
|
26
|
+
serializeCandidatesForPrompt: () => undefined,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
mock.module("../../prompts/persona-resolver.js", () => ({
|
|
30
|
+
resolveGuardianPersona: () => null,
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
mock.module("../../prompts/system-prompt.js", () => ({
|
|
34
|
+
buildCoreIdentityContext: () => null,
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
mock.module("../../contacts/contact-store.js", () => ({
|
|
38
|
+
listGuardianChannels: () => null,
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
// Provider mock — if `getConfiguredProvider` is ever called by the
|
|
42
|
+
// assistant_tool pass-through path, this throw makes the test fail
|
|
43
|
+
// loudly instead of silently exercising the LLM path.
|
|
44
|
+
mock.module("../../providers/provider-send-message.js", () => ({
|
|
45
|
+
getConfiguredProvider: async () => {
|
|
46
|
+
throw new Error(
|
|
47
|
+
"getConfiguredProvider should NOT be invoked for assistant_tool pass-through",
|
|
48
|
+
);
|
|
49
|
+
},
|
|
50
|
+
createTimeout: () => ({
|
|
51
|
+
signal: new AbortController().signal,
|
|
52
|
+
cleanup: () => {},
|
|
53
|
+
}),
|
|
54
|
+
extractToolUse: () => null,
|
|
55
|
+
userMessage: (text: string) => ({ role: "user", content: text }),
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
mock.module("../../util/logger.js", () => ({
|
|
59
|
+
getLogger: () =>
|
|
60
|
+
new Proxy({} as Record<string, unknown>, {
|
|
61
|
+
get: () => () => {},
|
|
62
|
+
}),
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
// ── Imports (after all mocks) ─────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
import { enforceRoutingIntent, evaluateSignal } from "../decision-engine.js";
|
|
68
|
+
import type { NotificationSignal } from "../signal.js";
|
|
69
|
+
import type { NotificationChannel } from "../types.js";
|
|
70
|
+
|
|
71
|
+
// ── Helpers ───────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
function makeAssistantToolSignal(
|
|
74
|
+
overrides?: Partial<NotificationSignal>,
|
|
75
|
+
): NotificationSignal {
|
|
76
|
+
return {
|
|
77
|
+
signalId: "sig-assistant-tool-test-1",
|
|
78
|
+
createdAt: Date.now(),
|
|
79
|
+
sourceChannel: "assistant_tool",
|
|
80
|
+
sourceContextId: "tool-call-1",
|
|
81
|
+
sourceEventName: "user.send_notification",
|
|
82
|
+
contextPayload: {
|
|
83
|
+
requestedMessage: "exact verbatim text here",
|
|
84
|
+
requestedTitle: "Custom Title",
|
|
85
|
+
},
|
|
86
|
+
attentionHints: {
|
|
87
|
+
requiresAction: false,
|
|
88
|
+
urgency: "low",
|
|
89
|
+
isAsyncBackground: false,
|
|
90
|
+
visibleInSourceNow: false,
|
|
91
|
+
},
|
|
92
|
+
...overrides,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Tests ─────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
describe("assistant_tool pass-through in notification decision engine", () => {
|
|
99
|
+
test("uses producer-supplied title and body verbatim, no LLM call", async () => {
|
|
100
|
+
const signal = makeAssistantToolSignal();
|
|
101
|
+
const decision = await evaluateSignal(signal, [
|
|
102
|
+
"vellum",
|
|
103
|
+
"telegram",
|
|
104
|
+
] as NotificationChannel[]);
|
|
105
|
+
|
|
106
|
+
expect(decision.shouldNotify).toBe(true);
|
|
107
|
+
expect(decision.selectedChannels).toContain("vellum");
|
|
108
|
+
expect(decision.renderedCopy.vellum?.body).toBe("exact verbatim text here");
|
|
109
|
+
expect(decision.renderedCopy.vellum?.title).toBe("Custom Title");
|
|
110
|
+
expect(decision.conversationActions?.vellum?.action).toBe("start_new");
|
|
111
|
+
expect(decision.reasoningSummary).toBe("assistant_tool pass-through");
|
|
112
|
+
expect(decision.fallbackUsed).toBe(false);
|
|
113
|
+
expect(decision.confidence).toBe(1.0);
|
|
114
|
+
expect(decision.dedupeKey).toBe(signal.signalId);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("derives title from body when requestedTitle is not supplied", async () => {
|
|
118
|
+
const signal = makeAssistantToolSignal({
|
|
119
|
+
contextPayload: {
|
|
120
|
+
requestedMessage: "First sentence. Second sentence follows here.",
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
const decision = await evaluateSignal(signal, [
|
|
124
|
+
"vellum",
|
|
125
|
+
] as NotificationChannel[]);
|
|
126
|
+
|
|
127
|
+
expect(decision.shouldNotify).toBe(true);
|
|
128
|
+
expect(decision.renderedCopy.vellum?.body).toBe(
|
|
129
|
+
"First sentence. Second sentence follows here.",
|
|
130
|
+
);
|
|
131
|
+
expect(decision.renderedCopy.vellum?.title).toBe("First sentence.");
|
|
132
|
+
expect(decision.reasoningSummary).toBe("assistant_tool pass-through");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("critical urgency selects all available channels", async () => {
|
|
136
|
+
const signal = makeAssistantToolSignal({
|
|
137
|
+
attentionHints: {
|
|
138
|
+
requiresAction: true,
|
|
139
|
+
urgency: "critical",
|
|
140
|
+
isAsyncBackground: false,
|
|
141
|
+
visibleInSourceNow: false,
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
const availableChannels = ["vellum", "telegram"] as NotificationChannel[];
|
|
145
|
+
const decision = await evaluateSignal(signal, availableChannels);
|
|
146
|
+
|
|
147
|
+
expect(decision.shouldNotify).toBe(true);
|
|
148
|
+
expect(decision.selectedChannels).toEqual(
|
|
149
|
+
expect.arrayContaining(availableChannels),
|
|
150
|
+
);
|
|
151
|
+
expect(decision.selectedChannels.length).toBe(availableChannels.length);
|
|
152
|
+
expect(decision.renderedCopy.vellum?.body).toBe("exact verbatim text here");
|
|
153
|
+
expect(decision.renderedCopy.telegram?.body).toBe(
|
|
154
|
+
"exact verbatim text here",
|
|
155
|
+
);
|
|
156
|
+
expect(decision.conversationActions?.vellum?.action).toBe("start_new");
|
|
157
|
+
expect(decision.conversationActions?.telegram?.action).toBe("start_new");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("threads contextPayload.deepLinkMetadata through to decision.deepLinkTarget", async () => {
|
|
161
|
+
const signal = makeAssistantToolSignal({
|
|
162
|
+
contextPayload: {
|
|
163
|
+
requestedMessage: "with deep link",
|
|
164
|
+
deepLinkMetadata: { route: "settings", anchor: "notifications" },
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
const decision = await evaluateSignal(signal, [
|
|
168
|
+
"vellum",
|
|
169
|
+
] as NotificationChannel[]);
|
|
170
|
+
|
|
171
|
+
expect(decision.deepLinkTarget).toEqual({
|
|
172
|
+
route: "settings",
|
|
173
|
+
anchor: "notifications",
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("omits deepLinkTarget when deepLinkMetadata is not a plain object", async () => {
|
|
178
|
+
const signal = makeAssistantToolSignal({
|
|
179
|
+
contextPayload: {
|
|
180
|
+
requestedMessage: "no deep link",
|
|
181
|
+
deepLinkMetadata: ["not", "a", "plain", "object"],
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
const decision = await evaluateSignal(signal, [
|
|
185
|
+
"vellum",
|
|
186
|
+
] as NotificationChannel[]);
|
|
187
|
+
|
|
188
|
+
expect(decision.deepLinkTarget).toBeUndefined();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("preferredChannels adds to the default channel set (additive, not replacement)", async () => {
|
|
192
|
+
const signal = makeAssistantToolSignal({
|
|
193
|
+
contextPayload: {
|
|
194
|
+
requestedMessage: "also push to telegram",
|
|
195
|
+
preferredChannels: ["telegram"],
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
const decision = await evaluateSignal(signal, [
|
|
199
|
+
"vellum",
|
|
200
|
+
"telegram",
|
|
201
|
+
] as NotificationChannel[]);
|
|
202
|
+
|
|
203
|
+
// Vellum (canonical inbox) stays in selectedChannels; telegram is
|
|
204
|
+
// added on top. `--preferred-channels` is additive push, never a
|
|
205
|
+
// replacement for the inbox.
|
|
206
|
+
expect(decision.selectedChannels).toContain("vellum");
|
|
207
|
+
expect(decision.selectedChannels).toContain("telegram");
|
|
208
|
+
expect(decision.selectedChannels.length).toBe(2);
|
|
209
|
+
expect(decision.renderedCopy.vellum?.body).toBe("also push to telegram");
|
|
210
|
+
expect(decision.renderedCopy.telegram?.body).toBe("also push to telegram");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("urgent + preferredChannels keeps urgent's full broadcast intact", async () => {
|
|
214
|
+
const signal = makeAssistantToolSignal({
|
|
215
|
+
contextPayload: {
|
|
216
|
+
requestedMessage: "urgent broadcast",
|
|
217
|
+
requestedTitle: "Heads up",
|
|
218
|
+
preferredChannels: ["telegram"],
|
|
219
|
+
},
|
|
220
|
+
attentionHints: {
|
|
221
|
+
requiresAction: true,
|
|
222
|
+
urgency: "critical",
|
|
223
|
+
isAsyncBackground: false,
|
|
224
|
+
visibleInSourceNow: false,
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
const available = ["vellum", "telegram", "slack"] as NotificationChannel[];
|
|
228
|
+
const decision = await evaluateSignal(signal, available);
|
|
229
|
+
|
|
230
|
+
// Urgent broadcasts to every available channel; the additive union
|
|
231
|
+
// with preferredChannels is idempotent (telegram already included).
|
|
232
|
+
expect(decision.selectedChannels).toEqual(
|
|
233
|
+
expect.arrayContaining(available),
|
|
234
|
+
);
|
|
235
|
+
expect(decision.selectedChannels.length).toBe(available.length);
|
|
236
|
+
for (const ch of available) {
|
|
237
|
+
expect(decision.renderedCopy[ch]?.body).toBe("urgent broadcast");
|
|
238
|
+
expect(decision.renderedCopy[ch]?.title).toBe("Heads up");
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("routing-intent expansion to all_channels preserves verbatim copy on added channels", async () => {
|
|
243
|
+
const signal = makeAssistantToolSignal({
|
|
244
|
+
contextPayload: {
|
|
245
|
+
requestedMessage: "verbatim broadcast body",
|
|
246
|
+
requestedTitle: "verbatim broadcast title",
|
|
247
|
+
},
|
|
248
|
+
routingIntent: "all_channels",
|
|
249
|
+
});
|
|
250
|
+
const connected = ["vellum", "telegram"] as NotificationChannel[];
|
|
251
|
+
const decision = await evaluateSignal(signal, connected);
|
|
252
|
+
const enforced = enforceRoutingIntent(
|
|
253
|
+
decision,
|
|
254
|
+
"all_channels",
|
|
255
|
+
connected,
|
|
256
|
+
"assistant_tool",
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
expect(enforced.selectedChannels).toEqual(
|
|
260
|
+
expect.arrayContaining(["vellum", "telegram"]),
|
|
261
|
+
);
|
|
262
|
+
for (const ch of enforced.selectedChannels) {
|
|
263
|
+
expect(enforced.renderedCopy[ch]?.body).toBe("verbatim broadcast body");
|
|
264
|
+
expect(enforced.renderedCopy[ch]?.title).toBe("verbatim broadcast title");
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("preferredChannels falls back to default channel set when no overlap with availableChannels", async () => {
|
|
269
|
+
const signal = makeAssistantToolSignal({
|
|
270
|
+
contextPayload: {
|
|
271
|
+
requestedMessage: "fyi",
|
|
272
|
+
preferredChannels: ["disconnected_channel"],
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
const decision = await evaluateSignal(signal, [
|
|
276
|
+
"vellum",
|
|
277
|
+
"telegram",
|
|
278
|
+
] as NotificationChannel[]);
|
|
279
|
+
|
|
280
|
+
expect(decision.selectedChannels).toEqual(["vellum"]);
|
|
281
|
+
expect(decision.renderedCopy.vellum?.body).toBe("fyi");
|
|
282
|
+
});
|
|
283
|
+
});
|