@vellumai/assistant 0.8.8-dev.202606091702.2771079 → 0.8.9-dev.202606091853.fbaa2ae
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/openapi.yaml +1 -1
- package/package.json +1 -1
- package/src/__tests__/notification-decision-strategy.test.ts +3 -3
- package/src/__tests__/plugin-bootstrap.test.ts +79 -58
- package/src/__tests__/plugin-skill-contribution.test.ts +15 -14
- package/src/__tests__/plugin-tool-contribution.test.ts +7 -4
- package/src/daemon/external-plugins-bootstrap.ts +27 -37
- package/src/notifications/__tests__/copy-composer.test.ts +244 -0
- package/src/notifications/access-request-copy.ts +298 -0
- package/src/notifications/adapters/slack.ts +3 -3
- package/src/notifications/adapters/telegram.ts +2 -1
- package/src/notifications/copy-composer.ts +49 -267
- package/src/notifications/decision-engine.ts +16 -35
- package/src/notifications/home-feed-side-effect.ts +1 -6
- package/src/providers/model-catalog.ts +34 -0
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +33 -34
- package/src/notifications/__tests__/emit-signal-home-feed.test.ts +0 -187
package/openapi.yaml
CHANGED
package/package.json
CHANGED
|
@@ -9,17 +9,17 @@
|
|
|
9
9
|
|
|
10
10
|
import { describe, expect, test } from "bun:test";
|
|
11
11
|
|
|
12
|
-
import type { ConversationCandidateSet } from "../notifications/conversation-candidates.js";
|
|
13
12
|
import {
|
|
14
13
|
buildAccessRequestContractText,
|
|
15
14
|
buildAccessRequestIdentityLine,
|
|
16
|
-
composeFallbackCopy,
|
|
17
15
|
hasAccessRequestInstructions,
|
|
18
16
|
hasInviteFlowDirective,
|
|
19
17
|
normalizeForDirectiveMatching,
|
|
20
18
|
sanitizeIdentityField,
|
|
21
19
|
sanitizeMessagePreview,
|
|
22
|
-
} from "../notifications/copy
|
|
20
|
+
} from "../notifications/access-request-copy.js";
|
|
21
|
+
import type { ConversationCandidateSet } from "../notifications/conversation-candidates.js";
|
|
22
|
+
import { composeFallbackCopy } from "../notifications/copy-composer.js";
|
|
23
23
|
import {
|
|
24
24
|
enforceGuardianCallConversationAffinity,
|
|
25
25
|
validateConversationActions,
|
|
@@ -42,11 +42,7 @@ import {
|
|
|
42
42
|
registerPlugin,
|
|
43
43
|
resetPluginRegistryForTests,
|
|
44
44
|
} from "../plugins/registry.js";
|
|
45
|
-
import {
|
|
46
|
-
type Plugin,
|
|
47
|
-
PluginExecutionError,
|
|
48
|
-
type PluginInitContext,
|
|
49
|
-
} from "../plugins/types.js";
|
|
45
|
+
import { type Plugin, type PluginInitContext } from "../plugins/types.js";
|
|
50
46
|
import { APP_VERSION } from "../version.js";
|
|
51
47
|
import { setOverridesForTesting } from "./feature-flag-test-helpers.js";
|
|
52
48
|
|
|
@@ -174,9 +170,9 @@ describe("plugin bootstrap", () => {
|
|
|
174
170
|
expect(received?.credentials).toEqual({ "some-key": "super-secret-value" });
|
|
175
171
|
});
|
|
176
172
|
|
|
177
|
-
test("credential resolution:
|
|
173
|
+
test("credential resolution: a plugin whose required credential is missing is skipped, not fatal", async () => {
|
|
174
|
+
// GIVEN a plugin that requires a credential the store cannot resolve
|
|
178
175
|
getSecureKeyAsyncMock.mockImplementation(async () => undefined);
|
|
179
|
-
|
|
180
176
|
registerPlugin(
|
|
181
177
|
buildPlugin(
|
|
182
178
|
"missing-cred",
|
|
@@ -185,16 +181,14 @@ describe("plugin bootstrap", () => {
|
|
|
185
181
|
),
|
|
186
182
|
);
|
|
187
183
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
expect(msg).toContain("missing-cred");
|
|
197
|
-
expect(msg).toContain("absent-key");
|
|
184
|
+
// WHEN bootstrap runs
|
|
185
|
+
// THEN it completes without throwing — the unresolvable credential is
|
|
186
|
+
// contained to that plugin (same per-plugin isolation as an init throw)
|
|
187
|
+
await bootstrapPlugins();
|
|
188
|
+
|
|
189
|
+
// AND the plugin is dropped from the registry
|
|
190
|
+
const names = getRegisteredPlugins().map((p) => p.manifest.name);
|
|
191
|
+
expect(names).not.toContain("missing-cred");
|
|
198
192
|
});
|
|
199
193
|
|
|
200
194
|
test("version mismatch: external plugin loader rejects when peerDependency unsatisfied", async () => {
|
|
@@ -208,7 +202,8 @@ describe("plugin bootstrap", () => {
|
|
|
208
202
|
expect(() => registerPlugin(plugin)).not.toThrow();
|
|
209
203
|
});
|
|
210
204
|
|
|
211
|
-
test("plugin init throw: bootstrap
|
|
205
|
+
test("plugin init throw: bootstrap contains the failure and does not throw", async () => {
|
|
206
|
+
// GIVEN a plugin whose init throws
|
|
212
207
|
registerPlugin(
|
|
213
208
|
buildPlugin("broken", {
|
|
214
209
|
async init() {
|
|
@@ -217,65 +212,91 @@ describe("plugin bootstrap", () => {
|
|
|
217
212
|
}),
|
|
218
213
|
);
|
|
219
214
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
expect(
|
|
229
|
-
expect(msg).toContain("kaboom");
|
|
215
|
+
// WHEN bootstrap runs
|
|
216
|
+
// THEN it completes without throwing — a single plugin's init failure is
|
|
217
|
+
// contained to that plugin rather than aborting the whole plugin layer
|
|
218
|
+
await bootstrapPlugins();
|
|
219
|
+
|
|
220
|
+
// AND the failing plugin is dropped from the registry so its hooks never
|
|
221
|
+
// participate in the turn lifecycle
|
|
222
|
+
const names = getRegisteredPlugins().map((p) => p.manifest.name);
|
|
223
|
+
expect(names).not.toContain("broken");
|
|
230
224
|
});
|
|
231
225
|
|
|
232
|
-
test("
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
// the shutdown hook is only registered once the entire loop completes.
|
|
238
|
-
const callOrder: string[] = [];
|
|
226
|
+
test("mid-list init failure: surrounding plugins still initialize and survive", async () => {
|
|
227
|
+
// GIVEN two healthy plugins surrounding one whose init throws, registered
|
|
228
|
+
// in order
|
|
229
|
+
const initialized: string[] = [];
|
|
230
|
+
const shutDownDuringBootstrap: string[] = [];
|
|
239
231
|
registerPlugin(
|
|
240
|
-
buildPlugin("
|
|
241
|
-
async init() {
|
|
232
|
+
buildPlugin("before", {
|
|
233
|
+
async init() {
|
|
234
|
+
initialized.push("before");
|
|
235
|
+
},
|
|
242
236
|
async onShutdown() {
|
|
243
|
-
|
|
237
|
+
shutDownDuringBootstrap.push("before");
|
|
244
238
|
},
|
|
245
239
|
}),
|
|
246
240
|
);
|
|
247
241
|
registerPlugin(
|
|
248
|
-
buildPlugin("
|
|
249
|
-
async init() {
|
|
250
|
-
|
|
251
|
-
callOrder.push("survivor-b");
|
|
242
|
+
buildPlugin("failing", {
|
|
243
|
+
async init() {
|
|
244
|
+
throw new Error("mid-bootstrap failure");
|
|
252
245
|
},
|
|
253
246
|
}),
|
|
254
247
|
);
|
|
255
248
|
registerPlugin(
|
|
256
|
-
buildPlugin("
|
|
249
|
+
buildPlugin("after", {
|
|
257
250
|
async init() {
|
|
258
|
-
|
|
251
|
+
initialized.push("after");
|
|
259
252
|
},
|
|
260
253
|
async onShutdown() {
|
|
261
|
-
|
|
262
|
-
// added to the active list that teardown walks.
|
|
263
|
-
callOrder.push("failing");
|
|
254
|
+
shutDownDuringBootstrap.push("after");
|
|
264
255
|
},
|
|
265
256
|
}),
|
|
266
257
|
);
|
|
267
258
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
//
|
|
278
|
-
|
|
259
|
+
// WHEN bootstrap runs
|
|
260
|
+
await bootstrapPlugins();
|
|
261
|
+
|
|
262
|
+
// THEN both healthy plugins initialized — the one registered after the
|
|
263
|
+
// failure is not skipped
|
|
264
|
+
expect(initialized).toEqual(["before", "after"]);
|
|
265
|
+
// AND neither healthy plugin was torn down by the failure (their
|
|
266
|
+
// onShutdown only runs at real shutdown, not during bootstrap)
|
|
267
|
+
expect(shutDownDuringBootstrap).toEqual([]);
|
|
268
|
+
// AND the failing plugin is dropped while both survivors remain registered
|
|
269
|
+
const names = getRegisteredPlugins().map((p) => p.manifest.name);
|
|
270
|
+
expect(names).toContain("before");
|
|
271
|
+
expect(names).toContain("after");
|
|
272
|
+
expect(names).not.toContain("failing");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("user-plugin init failure leaves the first-party defaults registered", async () => {
|
|
276
|
+
// GIVEN the first-party defaults are registered ahead of user plugins, as
|
|
277
|
+
// at daemon startup
|
|
278
|
+
registerDefaultPlugins();
|
|
279
|
+
// AND a user plugin registered after them whose init throws
|
|
280
|
+
registerPlugin(
|
|
281
|
+
buildPlugin("breaking-user-plugin", {
|
|
282
|
+
async init() {
|
|
283
|
+
throw new Error("user plugin boom");
|
|
284
|
+
},
|
|
285
|
+
}),
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
// WHEN bootstrap runs
|
|
289
|
+
await bootstrapPlugins();
|
|
290
|
+
|
|
291
|
+
// THEN the failing user plugin is dropped from the registry
|
|
292
|
+
const names = getRegisteredPlugins().map((p) => p.manifest.name);
|
|
293
|
+
expect(names).not.toContain("breaking-user-plugin");
|
|
294
|
+
// AND every first-party default survived, so core turn behavior (memory
|
|
295
|
+
// retrieval, history repair, title generation) keeps running in degraded
|
|
296
|
+
// mode instead of being torn down with the failing plugin
|
|
297
|
+
expect(names).toContain("default-memory-retrieval");
|
|
298
|
+
expect(names).toContain("default-history-repair");
|
|
299
|
+
expect(names).toContain("default-title-generate");
|
|
279
300
|
});
|
|
280
301
|
|
|
281
302
|
test("shutdown order: onShutdown fires in reverse registration order", async () => {
|
|
@@ -202,7 +202,8 @@ describe("plugin skill contributions", () => {
|
|
|
202
202
|
expect(getPluginContributedSkillSummaries()).toEqual([]);
|
|
203
203
|
});
|
|
204
204
|
|
|
205
|
-
test("duplicate skill id across plugins
|
|
205
|
+
test("duplicate skill id across plugins drops the colliding plugin, keeping the first", async () => {
|
|
206
|
+
// GIVEN two plugins that both declare the same skill id
|
|
206
207
|
const shared: PluginSkillRegistration = {
|
|
207
208
|
id: "contested-id",
|
|
208
209
|
name: "contested",
|
|
@@ -215,22 +216,22 @@ describe("plugin skill contributions", () => {
|
|
|
215
216
|
description: "Second",
|
|
216
217
|
body: "from second plugin",
|
|
217
218
|
};
|
|
218
|
-
|
|
219
|
+
// AND the first registered plugin claims the id; the second collides on it
|
|
219
220
|
registerPlugin(buildSkillPlugin("first-plugin", [shared]));
|
|
220
221
|
registerPlugin(buildSkillPlugin("second-plugin", [duplicate]));
|
|
221
222
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
expect(
|
|
233
|
-
expect(
|
|
223
|
+
// WHEN bootstrap runs
|
|
224
|
+
// THEN it does not throw — the collision is contained to the colliding
|
|
225
|
+
// plugin rather than aborting the whole plugin layer
|
|
226
|
+
await bootstrapPlugins();
|
|
227
|
+
|
|
228
|
+
// AND the first plugin keeps the contested skill, while the second
|
|
229
|
+
// plugin's colliding contribution is rolled back and dropped
|
|
230
|
+
expect(
|
|
231
|
+
getPluginContributedSkillDefinition("contested-id")?.description,
|
|
232
|
+
).toBe("First");
|
|
233
|
+
expect(getPluginSkillRefCount("first-plugin")).toBe(1);
|
|
234
|
+
expect(getPluginSkillRefCount("second-plugin")).toBe(0);
|
|
234
235
|
});
|
|
235
236
|
|
|
236
237
|
test("intra-batch duplicate skill id in one plugin is rejected at registration", () => {
|
|
@@ -218,9 +218,7 @@ describe("plugin tool contributions", () => {
|
|
|
218
218
|
});
|
|
219
219
|
|
|
220
220
|
test("tools are only registered after init() succeeds", async () => {
|
|
221
|
-
//
|
|
222
|
-
// aborts with a PluginExecutionError, and nothing from this plugin
|
|
223
|
-
// should leak into the tool registry.
|
|
221
|
+
// GIVEN a plugin that declares a tool but throws during init()
|
|
224
222
|
const plugin = buildPlugin("delta-broken", {
|
|
225
223
|
async init() {
|
|
226
224
|
throw new Error("boom");
|
|
@@ -229,7 +227,12 @@ describe("plugin tool contributions", () => {
|
|
|
229
227
|
});
|
|
230
228
|
registerPlugin(plugin);
|
|
231
229
|
|
|
232
|
-
|
|
230
|
+
// WHEN bootstrap runs
|
|
231
|
+
// THEN it does not throw — the init failure is contained to this plugin
|
|
232
|
+
await bootstrapPlugins();
|
|
233
|
+
|
|
234
|
+
// AND the failing plugin's tool is rolled back, never leaking into the
|
|
235
|
+
// registry
|
|
233
236
|
expect(getTool("delta-tool")).toBeUndefined();
|
|
234
237
|
});
|
|
235
238
|
});
|
|
@@ -37,14 +37,15 @@
|
|
|
37
37
|
* surface is already wired — useful for plugins that want to attach
|
|
38
38
|
* metadata, warm caches, or otherwise interact with their own
|
|
39
39
|
* contributions during initialization.
|
|
40
|
-
* 8. Awaits `plugin.init(ctx)` sequentially. An init failure
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
40
|
+
* 8. Awaits `plugin.init(ctx)` sequentially. An init failure is contained to
|
|
41
|
+
* the offending plugin: its already-registered tools, routes, and skills
|
|
42
|
+
* are rolled back, it is dropped from the registry, the failure is logged,
|
|
43
|
+
* and bootstrap continues with the remaining plugins. A single plugin's
|
|
44
|
+
* failure never deregisters plugins that already initialized — in
|
|
45
|
+
* particular the first-party defaults, which carry core turn behavior
|
|
46
|
+
* (memory retrieval, history repair, title generation) — so the daemon
|
|
47
|
+
* comes up with the failing plugin absent rather than blacking out the
|
|
48
|
+
* whole plugin layer.
|
|
48
49
|
*
|
|
49
50
|
* A single shutdown hook is registered via
|
|
50
51
|
* {@link registerShutdownHook} that walks the plugin list in **reverse
|
|
@@ -218,9 +219,10 @@ export async function initializePlugins(): Promise<void> {
|
|
|
218
219
|
* Run every registered plugin's `init()` hook sequentially and install a
|
|
219
220
|
* reverse-order shutdown hook. See the module docstring for full semantics.
|
|
220
221
|
*
|
|
221
|
-
* Returns once every plugin has
|
|
222
|
-
*
|
|
223
|
-
* the
|
|
222
|
+
* Returns once every plugin has been processed. A plugin whose `init()` throws
|
|
223
|
+
* is rolled back, dropped from the registry, and logged; bootstrap continues
|
|
224
|
+
* with the rest, so one failing plugin never blocks the others — or the
|
|
225
|
+
* first-party defaults — from coming up.
|
|
224
226
|
*
|
|
225
227
|
* Must be called after any custom/third-party plugin registrations have run
|
|
226
228
|
* and before the first conversation is served. First-party defaults are
|
|
@@ -259,35 +261,14 @@ export async function bootstrapPlugins(): Promise<void> {
|
|
|
259
261
|
// onShutdown" invariant.
|
|
260
262
|
const activePlugins: ActivePlugin[] = [];
|
|
261
263
|
|
|
262
|
-
// Tear down a plugin's contributions AND remove it from the registry. The
|
|
263
|
-
// two steps always move together on the bootstrap failure path: the former
|
|
264
|
-
// clears tools/routes/skills (so they stop appearing to the model/HTTP
|
|
265
|
-
// server), the latter drops the plugin's entry from the Map (so the registry
|
|
266
|
-
// doesn't expose an uninitialized plugin's hooks).
|
|
267
264
|
// Shutdown context is identical for every plugin in this boot — construct
|
|
268
|
-
// once and reuse across the
|
|
269
|
-
//
|
|
270
|
-
//
|
|
265
|
+
// once and reuse across the per-plugin teardown and the normal shutdown
|
|
266
|
+
// hook below. Only `assistantVersion` is exposed today; future additions
|
|
267
|
+
// live on {@link PluginShutdownContext}.
|
|
271
268
|
const shutdownContext: PluginShutdownContext = {
|
|
272
269
|
assistantVersion: APP_VERSION,
|
|
273
270
|
};
|
|
274
271
|
|
|
275
|
-
async function rollbackPlugin(active: ActivePlugin): Promise<void> {
|
|
276
|
-
await teardownPlugin(active, "bootstrap-failed", shutdownContext);
|
|
277
|
-
unregisterPlugin(active.plugin.manifest.name);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// If one plugin's init or contribution phase throws, tear down any plugins
|
|
281
|
-
// that already fully initialized (in reverse registration order) before
|
|
282
|
-
// re-throwing. Without this, a mid-loop failure would leave earlier plugins
|
|
283
|
-
// with live tools/routes/skills and no `onShutdown()` call — the shutdown
|
|
284
|
-
// hook is only registered once the loop completes successfully.
|
|
285
|
-
async function teardownPartialInit(): Promise<void> {
|
|
286
|
-
for (let i = activePlugins.length - 1; i >= 0; i--) {
|
|
287
|
-
await rollbackPlugin(activePlugins[i]!);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
272
|
for (const plugin of plugins) {
|
|
292
273
|
const name = plugin.manifest.name;
|
|
293
274
|
const disabledFlag = getDisabledPluginFlag(plugin, assistantConfig);
|
|
@@ -303,9 +284,18 @@ export async function bootstrapPlugins(): Promise<void> {
|
|
|
303
284
|
try {
|
|
304
285
|
activePlugins.push(await initializePlugin(plugin, assistantConfig));
|
|
305
286
|
} catch (err) {
|
|
287
|
+
// Contain the failure to this plugin. `initializePlugin` already rolled
|
|
288
|
+
// back its own partial tool/route/skill contributions, so we just drop
|
|
289
|
+
// it from the registry and move on. A single plugin's init failure must
|
|
290
|
+
// never deregister the plugins that already came up — above all the
|
|
291
|
+
// first-party defaults, which carry core turn behavior. The daemon stays
|
|
292
|
+
// reachable with the failing plugin absent rather than losing the whole
|
|
293
|
+
// plugin layer.
|
|
306
294
|
unregisterPlugin(name);
|
|
307
|
-
|
|
308
|
-
|
|
295
|
+
log.warn(
|
|
296
|
+
{ err, plugin: name },
|
|
297
|
+
`plugin ${name} failed to initialize — skipping (continuing with remaining plugins)`,
|
|
298
|
+
);
|
|
309
299
|
}
|
|
310
300
|
}
|
|
311
301
|
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the notification copy-composer — specifically the fallback
|
|
3
|
+
* path that composeFallbackCopy uses when the LLM is unavailable, and
|
|
4
|
+
* the shared deriveTitle utility.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
|
|
9
|
+
import { composeFallbackCopy, deriveTitle } from "../copy-composer.js";
|
|
10
|
+
import type { NotificationSignal } from "../signal.js";
|
|
11
|
+
import type { NotificationChannel } from "../types.js";
|
|
12
|
+
|
|
13
|
+
// ── Helpers ───────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
function makeSignal(
|
|
16
|
+
overrides?: Partial<NotificationSignal>,
|
|
17
|
+
): NotificationSignal {
|
|
18
|
+
return {
|
|
19
|
+
signalId: "sig-copy-test-1",
|
|
20
|
+
createdAt: Date.now(),
|
|
21
|
+
sourceChannel: "scheduler",
|
|
22
|
+
sourceContextId: "ctx-1",
|
|
23
|
+
sourceEventName: "user.send_notification",
|
|
24
|
+
contextPayload: {},
|
|
25
|
+
attentionHints: {
|
|
26
|
+
requiresAction: false,
|
|
27
|
+
urgency: "low",
|
|
28
|
+
isAsyncBackground: false,
|
|
29
|
+
visibleInSourceNow: false,
|
|
30
|
+
},
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const CHANNELS: NotificationChannel[] = ["vellum" as NotificationChannel];
|
|
36
|
+
|
|
37
|
+
// ── composeFallbackCopy with requestedMessage ─────────────────────────
|
|
38
|
+
|
|
39
|
+
describe("composeFallbackCopy honors requestedMessage / requestedTitle", () => {
|
|
40
|
+
test("uses requestedMessage as body and requestedTitle as title", () => {
|
|
41
|
+
const signal = makeSignal({
|
|
42
|
+
contextPayload: {
|
|
43
|
+
requestedMessage: "Take out the trash",
|
|
44
|
+
requestedTitle: "Household Reminder",
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
const copy = composeFallbackCopy(signal, CHANNELS);
|
|
48
|
+
|
|
49
|
+
expect(copy.vellum?.body).toBe("Take out the trash");
|
|
50
|
+
expect(copy.vellum?.title).toBe("Household Reminder");
|
|
51
|
+
expect(copy.vellum?.conversationSeedMessage).toBe("Take out the trash");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("derives title from body when requestedTitle is absent", () => {
|
|
55
|
+
const signal = makeSignal({
|
|
56
|
+
contextPayload: {
|
|
57
|
+
requestedMessage: "First sentence. Second sentence follows.",
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
const copy = composeFallbackCopy(signal, CHANNELS);
|
|
61
|
+
|
|
62
|
+
expect(copy.vellum?.body).toBe("First sentence. Second sentence follows.");
|
|
63
|
+
expect(copy.vellum?.title).toBe("First sentence.");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("derives title from body when requestedTitle is empty string", () => {
|
|
67
|
+
const signal = makeSignal({
|
|
68
|
+
contextPayload: {
|
|
69
|
+
requestedMessage: "Some message body here",
|
|
70
|
+
requestedTitle: "",
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
const copy = composeFallbackCopy(signal, CHANNELS);
|
|
74
|
+
|
|
75
|
+
expect(copy.vellum?.body).toBe("Some message body here");
|
|
76
|
+
expect(copy.vellum?.title).toBe("Some message body here");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("derives title from body when requestedTitle is whitespace", () => {
|
|
80
|
+
const signal = makeSignal({
|
|
81
|
+
contextPayload: {
|
|
82
|
+
requestedMessage: "Whitespace title test",
|
|
83
|
+
requestedTitle: " ",
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
const copy = composeFallbackCopy(signal, CHANNELS);
|
|
87
|
+
|
|
88
|
+
expect(copy.vellum?.body).toBe("Whitespace title test");
|
|
89
|
+
expect(copy.vellum?.title).toBe("Whitespace title test");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("trims whitespace from requestedMessage", () => {
|
|
93
|
+
const signal = makeSignal({
|
|
94
|
+
contextPayload: {
|
|
95
|
+
requestedMessage: " padded message ",
|
|
96
|
+
requestedTitle: " padded title ",
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
const copy = composeFallbackCopy(signal, CHANNELS);
|
|
100
|
+
|
|
101
|
+
expect(copy.vellum?.body).toBe("padded message");
|
|
102
|
+
expect(copy.vellum?.title).toBe("padded title");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("populates copy for all requested channels", () => {
|
|
106
|
+
const channels = [
|
|
107
|
+
"vellum" as NotificationChannel,
|
|
108
|
+
"telegram" as NotificationChannel,
|
|
109
|
+
];
|
|
110
|
+
const signal = makeSignal({
|
|
111
|
+
contextPayload: {
|
|
112
|
+
requestedMessage: "Multi-channel test",
|
|
113
|
+
requestedTitle: "Multi Title",
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
const copy = composeFallbackCopy(signal, channels);
|
|
117
|
+
|
|
118
|
+
expect(copy.vellum?.body).toBe("Multi-channel test");
|
|
119
|
+
expect(copy.vellum?.title).toBe("Multi Title");
|
|
120
|
+
expect(copy.telegram?.body).toBe("Multi-channel test");
|
|
121
|
+
expect(copy.telegram?.title).toBe("Multi Title");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("falls through to template when requestedMessage is absent", () => {
|
|
125
|
+
const signal = makeSignal({
|
|
126
|
+
sourceEventName: "schedule.notify",
|
|
127
|
+
contextPayload: {
|
|
128
|
+
message: "Standup time",
|
|
129
|
+
label: "Daily Standup",
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
const copy = composeFallbackCopy(signal, CHANNELS);
|
|
133
|
+
|
|
134
|
+
expect(copy.vellum?.title).toBe("Daily Standup");
|
|
135
|
+
expect(copy.vellum?.body).toBe("Standup time");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("falls through to template when requestedMessage is empty string", () => {
|
|
139
|
+
const signal = makeSignal({
|
|
140
|
+
sourceEventName: "schedule.notify",
|
|
141
|
+
contextPayload: {
|
|
142
|
+
requestedMessage: "",
|
|
143
|
+
message: "Standup time",
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
const copy = composeFallbackCopy(signal, CHANNELS);
|
|
147
|
+
|
|
148
|
+
expect(copy.vellum?.title).toBe("Reminder");
|
|
149
|
+
expect(copy.vellum?.body).toBe("Standup time");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("falls through to template when requestedMessage is whitespace", () => {
|
|
153
|
+
const signal = makeSignal({
|
|
154
|
+
sourceEventName: "schedule.notify",
|
|
155
|
+
contextPayload: {
|
|
156
|
+
requestedMessage: " ",
|
|
157
|
+
message: "Standup time",
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
const copy = composeFallbackCopy(signal, CHANNELS);
|
|
161
|
+
|
|
162
|
+
expect(copy.vellum?.title).toBe("Reminder");
|
|
163
|
+
expect(copy.vellum?.body).toBe("Standup time");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("falls through to generic copy when no requestedMessage and no template match", () => {
|
|
167
|
+
const signal = makeSignal({
|
|
168
|
+
sourceEventName: "unknown.event",
|
|
169
|
+
contextPayload: {},
|
|
170
|
+
});
|
|
171
|
+
const copy = composeFallbackCopy(signal, CHANNELS);
|
|
172
|
+
|
|
173
|
+
expect(copy.vellum?.title).toBe("Notification");
|
|
174
|
+
expect(copy.vellum?.body).toBe("");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("requestedMessage takes priority over event-name template", () => {
|
|
178
|
+
const signal = makeSignal({
|
|
179
|
+
sourceEventName: "schedule.notify",
|
|
180
|
+
contextPayload: {
|
|
181
|
+
requestedMessage: "User-supplied content",
|
|
182
|
+
requestedTitle: "User Title",
|
|
183
|
+
message: "Template message field",
|
|
184
|
+
label: "Template label field",
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
const copy = composeFallbackCopy(signal, CHANNELS);
|
|
188
|
+
|
|
189
|
+
expect(copy.vellum?.body).toBe("User-supplied content");
|
|
190
|
+
expect(copy.vellum?.title).toBe("User Title");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("works with non-assistant_tool source channels", () => {
|
|
194
|
+
for (const sourceChannel of ["scheduler", "watcher", "slack"] as const) {
|
|
195
|
+
const signal = makeSignal({
|
|
196
|
+
sourceChannel,
|
|
197
|
+
contextPayload: {
|
|
198
|
+
requestedMessage: `Message from ${sourceChannel}`,
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
const copy = composeFallbackCopy(signal, CHANNELS);
|
|
202
|
+
|
|
203
|
+
expect(copy.vellum?.body).toBe(`Message from ${sourceChannel}`);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ── deriveTitle ───────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
describe("deriveTitle", () => {
|
|
211
|
+
test("extracts first sentence when period is present", () => {
|
|
212
|
+
expect(deriveTitle("First sentence. Second sentence.")).toBe(
|
|
213
|
+
"First sentence.",
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("extracts first sentence on exclamation mark", () => {
|
|
218
|
+
expect(deriveTitle("Alert! More details follow.")).toBe("Alert!");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("extracts first sentence on question mark", () => {
|
|
222
|
+
expect(deriveTitle("Ready? Let me know.")).toBe("Ready?");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("uses full body when no sentence terminator", () => {
|
|
226
|
+
expect(deriveTitle("No terminator here")).toBe("No terminator here");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("truncates to 60 characters with ellipsis", () => {
|
|
230
|
+
const long = "A".repeat(80);
|
|
231
|
+
const result = deriveTitle(long);
|
|
232
|
+
expect(result.length).toBeLessThanOrEqual(61); // 60 + ellipsis char
|
|
233
|
+
expect(result.endsWith("\u2026")).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("does not truncate at exactly 60 characters", () => {
|
|
237
|
+
const exact = "A".repeat(60);
|
|
238
|
+
expect(deriveTitle(exact)).toBe(exact);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("trims whitespace", () => {
|
|
242
|
+
expect(deriveTitle(" trimmed ")).toBe("trimmed");
|
|
243
|
+
});
|
|
244
|
+
});
|