@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 CHANGED
@@ -3,7 +3,7 @@
3
3
  openapi: 3.1.0
4
4
  info:
5
5
  title: Vellum Assistant API
6
- version: 0.8.8
6
+ version: 0.8.9
7
7
  description: Auto-generated OpenAPI specification for the Vellum Assistant runtime HTTP server.
8
8
  servers:
9
9
  - url: http://127.0.0.1:7821
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.8.8-dev.202606091702.2771079",
3
+ "version": "0.8.9-dev.202606091853.fbaa2ae",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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-composer.js";
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: missing credential fails bootstrap with the plugin named", async () => {
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
- let caught: unknown;
189
- try {
190
- await bootstrapPlugins();
191
- } catch (err) {
192
- caught = err;
193
- }
194
- expect(caught).toBeInstanceOf(PluginExecutionError);
195
- const msg = (caught as PluginExecutionError).message;
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 throws a PluginExecutionError naming the plugin", async () => {
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
- let caught: unknown;
221
- try {
222
- await bootstrapPlugins();
223
- } catch (err) {
224
- caught = err;
225
- }
226
- expect(caught).toBeInstanceOf(PluginExecutionError);
227
- const msg = (caught as PluginExecutionError).message;
228
- expect(msg).toContain("broken");
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("partial-init failure: earlier plugins' onShutdown runs in reverse before the error propagates", async () => {
233
- // If plugin N throws during init, every plugin 1..N-1 that already made
234
- // it through its full init+contribution phase must have onShutdown()
235
- // invoked in reverse registration order before bootstrap re-throws.
236
- // Without this, earlier plugins leak live tools/routes/skills because
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("survivor-a", {
241
- async init() {},
232
+ buildPlugin("before", {
233
+ async init() {
234
+ initialized.push("before");
235
+ },
242
236
  async onShutdown() {
243
- callOrder.push("survivor-a");
237
+ shutDownDuringBootstrap.push("before");
244
238
  },
245
239
  }),
246
240
  );
247
241
  registerPlugin(
248
- buildPlugin("survivor-b", {
249
- async init() {},
250
- async onShutdown() {
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("failing", {
249
+ buildPlugin("after", {
257
250
  async init() {
258
- throw new Error("mid-bootstrap failure");
251
+ initialized.push("after");
259
252
  },
260
253
  async onShutdown() {
261
- // Never called — this plugin never completes init, so it was never
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
- let caught: unknown;
269
- try {
270
- await bootstrapPlugins();
271
- } catch (err) {
272
- caught = err;
273
- }
274
- expect(caught).toBeInstanceOf(PluginExecutionError);
275
-
276
- // Reverse order: survivor-b registered after survivor-a, so it tears
277
- // down first; "failing" never entered the active list.
278
- expect(callOrder).toEqual(["survivor-b", "survivor-a"]);
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 fails bootstrap with the plugin named", async () => {
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
- let caught: unknown;
223
- try {
224
- await bootstrapPlugins();
225
- } catch (err) {
226
- caught = err;
227
- }
228
- expect(caught).toBeInstanceOf(PluginExecutionError);
229
- // The error must identify which plugin tripped the collision so
230
- // operators can deploy a fix.
231
- const msg = (caught as PluginExecutionError).message;
232
- expect(msg).toContain("second-plugin");
233
- expect(msg).toContain("contested-id");
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
- // A plugin whose init throws must not contribute tools — the bootstrap
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
- await expect(bootstrapPlugins()).rejects.toThrow(/delta-broken/);
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 surfaces as a
41
- * {@link PluginExecutionError} naming the offending plugin and aborts
42
- * bootstrap — later plugins' `init()` never runs and the daemon fails
43
- * startup cleanly rather than coming up in a half-wired state. The
44
- * failing plugin's already-registered tools, routes, and skills are
45
- * rolled back before the error propagates so the registry never
46
- * carries state contributed by a plugin that never finished
47
- * initializing.
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 finished initialising successfully. Throws a
222
- * {@link PluginExecutionError} on the first failure — the error message names
223
- * the offending plugin so operators see which `init()` bailed.
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 bootstrap-failure rollback and the normal
269
- // shutdown hook below. Only `assistantVersion` is exposed today; future
270
- // additions live on {@link PluginShutdownContext}.
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
- await teardownPartialInit();
308
- throw err;
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
+ });