@vellumai/assistant 0.10.5-staging.2 → 0.10.5-staging.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.
@@ -151,13 +151,15 @@ export interface SlackSendResult {
151
151
  * Those errors fault the Block Kit payload, not the target — the retry repeats
152
152
  * the *same* operation (same `chat.update` ts, same `chat.postMessage` thread)
153
153
  * without blocks, so it edits/posts in place rather than spawning a second
154
- * message. Any other error propagates to the caller.
154
+ * message. `fallbackText` replaces the message text on that retry (used to
155
+ * re-attach reply instructions whose Block Kit equivalent was dropped). Any
156
+ * other error propagates to the caller.
155
157
  */
156
158
  async function sendWithBlockFallback(
157
159
  method: string,
158
160
  baseBody: Record<string, unknown>,
159
161
  blocks: KnownBlock[],
160
- options: { fallbackWithoutBlocks: boolean },
162
+ options: { fallbackWithoutBlocks: boolean; fallbackText?: string },
161
163
  ): Promise<SlackSendResult> {
162
164
  try {
163
165
  const result = await callSlackApi(
@@ -177,13 +179,36 @@ async function sendWithBlockFallback(
177
179
  { method, slackError: err.slackError },
178
180
  "Slack rejected blocks; retrying without blocks",
179
181
  );
180
- const result = await callSlackApi(method, baseBody);
182
+ const retryBody =
183
+ options.fallbackText !== undefined
184
+ ? { ...baseBody, text: options.fallbackText }
185
+ : baseBody;
186
+ const result = await callSlackApi(method, retryBody);
181
187
  return { ok: true, ts: result.ts };
182
188
  }
183
189
  throw err;
184
190
  }
185
191
  }
186
192
 
193
+ /**
194
+ * Text for the block-free retry of an approval prompt. Dropping an approval's
195
+ * blocks removes its Approve/Reject buttons, so the retry re-attaches the
196
+ * plain-text reply instructions to keep the message actionable. Returns
197
+ * `undefined` when the approval has no usable instructions — such a prompt
198
+ * must not be retried bare, since a message with no way to respond is worse
199
+ * than a failed delivery the delivery layer can surface.
200
+ */
201
+ function buildApprovalFallbackText(
202
+ text: string,
203
+ approval: ApprovalUIMetadata,
204
+ ): string | undefined {
205
+ const instructions = approval.plainTextFallback.trim();
206
+ if (instructions.length === 0) {
207
+ return undefined;
208
+ }
209
+ return text.includes(instructions) ? text : `${text}\n\n${instructions}`;
210
+ }
211
+
187
212
  /**
188
213
  * Send a Slack text message with optional Block Kit formatting.
189
214
  *
@@ -219,29 +244,40 @@ export async function sendSlackReply(
219
244
  }
220
245
 
221
246
  const postBase: Record<string, unknown> = { channel: chatId, text };
222
- if (options?.threadTs) postBase.thread_ts = options.threadTs;
247
+ if (options?.threadTs) {
248
+ postBase.thread_ts = options.threadTs;
249
+ }
250
+
251
+ // Approval prompts carry their action buttons in `blocks`. When Slack
252
+ // rejects the payload, the block-free retry re-attaches the plain-text
253
+ // reply instructions so the recipient can still act by text reply; an
254
+ // approval without usable instructions is never retried bare.
255
+ const approvalFallbackText = options?.approval
256
+ ? buildApprovalFallbackText(text, options.approval)
257
+ : undefined;
258
+ const fallbackOptions = {
259
+ fallbackWithoutBlocks:
260
+ !options?.approval || approvalFallbackText !== undefined,
261
+ fallbackText: approvalFallbackText,
262
+ };
223
263
 
224
264
  if (options?.ephemeral) {
225
- if (!options.user)
265
+ if (!options.user) {
226
266
  throw new Error("user is required for ephemeral messages");
267
+ }
227
268
  return sendWithBlockFallback(
228
269
  "chat.postEphemeral",
229
270
  { ...postBase, user: options.user },
230
271
  blocks,
231
- { fallbackWithoutBlocks: !options.approval },
272
+ fallbackOptions,
232
273
  );
233
274
  }
234
275
 
235
- // Approval prompts carry their action buttons in `blocks`; dropping them when
236
- // Slack rejects the payload would post a card with no way to respond, so only
237
- // non-approval posts fall back to a block-free retry.
238
276
  const result = await sendWithBlockFallback(
239
277
  "chat.postMessage",
240
278
  postBase,
241
279
  blocks,
242
- {
243
- fallbackWithoutBlocks: !options?.approval,
244
- },
280
+ fallbackOptions,
245
281
  );
246
282
  log.info({ chatId, hasThreadTs: !!options?.threadTs }, "Slack message sent");
247
283
  return result;
@@ -57,7 +57,7 @@ Hard invariants that the LLM cannot override:
57
57
 
58
58
  **Post-generation enforcement** (`decision-engine.ts`):
59
59
 
60
- - **Guardian question request-code enforcement** — `enforceGuardianRequestCode()` ensures request-code instructions (approve/reject or free-text answer) appear in all `guardian.question` notification copy, even when the LLM omits them.
60
+ - **Guardian question request-code enforcement** — `enforceGuardianRequestCode()` ensures request-code instructions (approve/reject or free-text answer) appear in all `guardian.question` notification copy, even when the LLM omits them. Exception: for approval-mode questions the Slack adapter renders an interactive card with Approve/Reject buttons, so Slack copy is instead **stripped** of request-code instructions and bare code mentions (`stripGuardianRequestCodeInstructions()`).
61
61
  - **Access-request instruction enforcement** — `enforceAccessRequestInstructions()` validates that `ingress.access_request` copy contains: (1) the request-code approve/reject directive, (2) the exact "open invite flow" phrase. If any required element is missing, the full deterministic contract text is appended. This prevents model-generated copy from dropping security-critical action directives.
62
62
 
63
63
  **Pre-send gate checks** (`deterministic-checks.ts`) — these all depend on the decision, so they run here, after it:
@@ -3,10 +3,10 @@
3
3
  * using native Card blocks for approval notifications.
4
4
  *
5
5
  * Approval notifications (access requests, tool approvals) render as a
6
- * single Slack Card block with Approve/Reject action buttons, plus
7
- * optional companion context blocks for details that exceed the card's
8
- * character limits. Non-approval notifications use standard Block Kit
9
- * text sections.
6
+ * single Slack Card block with Approve/Reject action buttons. Text that
7
+ * exceeds the card body's 200-character cap continues in a companion
8
+ * section block below the card. Non-approval notifications use standard
9
+ * Block Kit text sections.
10
10
  *
11
11
  * Card block reference:
12
12
  * https://docs.slack.dev/reference/block-kit/blocks/card-block
@@ -204,11 +204,40 @@ function buildAccessRequestCardBlocks(
204
204
  // Tool approval card
205
205
  // ---------------------------------------------------------------------------
206
206
 
207
+ /** Slack caps a card block's `body` text at 200 characters. */
208
+ const CARD_BODY_MAX_LENGTH = 200;
209
+
210
+ /** Marker signalling the card body continues in the section below. */
211
+ const CARD_BODY_CONTINUATION_MARKER = " ↓";
212
+
213
+ /**
214
+ * Split message text so the head fits Slack's card body cap (marker
215
+ * included) and the tail continues in a companion section below the card.
216
+ * Splits on the last whitespace inside the budget when there is one, so
217
+ * neither piece cuts mid-word. Text within the cap needs no split.
218
+ */
219
+ function splitAtCardBodyLimit(text: string): { head: string; tail?: string } {
220
+ if (text.length <= CARD_BODY_MAX_LENGTH) {
221
+ return { head: text };
222
+ }
223
+
224
+ const budget = CARD_BODY_MAX_LENGTH - CARD_BODY_CONTINUATION_MARKER.length;
225
+ const window = text.slice(0, budget + 1);
226
+ const lastWhitespace = window.search(/\s\S*$/);
227
+ const cut = lastWhitespace > 0 ? lastWhitespace : budget;
228
+
229
+ return {
230
+ head: text.slice(0, cut).trimEnd() + CARD_BODY_CONTINUATION_MARKER,
231
+ tail: text.slice(cut).trimStart(),
232
+ };
233
+ }
234
+
207
235
  /**
208
236
  * Build Slack blocks for a tool approval notification using a native Card block.
209
237
  *
210
238
  * Layout:
211
239
  * Card — title + subtitle (tool + requester) + body (notification text) + actions
240
+ * Section — continuation of body text exceeding the card's 200-char cap
212
241
  */
213
242
  function buildToolApprovalCardBlocks(
214
243
  payload: ChannelDeliveryPayload,
@@ -227,28 +256,25 @@ function buildToolApprovalCardBlocks(
227
256
  subtitle = truncate(toolName, 150);
228
257
  }
229
258
 
230
- const needsOverflow = messageText.length > 200;
259
+ const { head, tail } = splitAtCardBodyLimit(messageText);
231
260
  const card: CardBlock = {
232
261
  type: "card",
233
262
  title: {
234
263
  type: "mrkdwn",
235
264
  text: details ? "Tool Approval" : "Approval Request",
236
265
  },
237
- body: {
238
- type: "mrkdwn",
239
- text: needsOverflow ? truncate(messageText, 197) + " ↓" : messageText,
240
- },
266
+ body: { type: "mrkdwn", text: head },
241
267
  actions: buildCardActions(approval),
242
268
  };
243
269
  if (subtitle) card.subtitle = { type: "mrkdwn", text: subtitle };
244
270
  blocks.push(card);
245
271
 
246
- // When the message exceeds the card body limit, show the full text in a
247
- // companion section so the approver can see the complete command/context.
248
- if (needsOverflow) {
272
+ // The companion section carries only the remainder repeating the full
273
+ // text would render the same message twice (once in the card, once below).
274
+ if (tail) {
249
275
  blocks.push({
250
276
  type: "section",
251
- text: { type: "mrkdwn", text: truncate(messageText, 3000) },
277
+ text: { type: "mrkdwn", text: truncate(`… ${tail}`, 3000) },
252
278
  });
253
279
  }
254
280
 
@@ -305,9 +331,14 @@ export class SlackAdapter implements ChannelAdapter {
305
331
  const messageText = resolveMessageText(payload);
306
332
 
307
333
  try {
334
+ // `approval` rides along with the prebuilt card blocks so the send
335
+ // layer treats a rejected Block Kit payload as an approval prompt:
336
+ // its block-free retry re-attaches `plainTextFallback` reply
337
+ // instructions instead of posting text with no way to respond.
308
338
  const result = payload.approvalContext
309
339
  ? await sendSlackReply(chatId, messageText, {
310
340
  blocks: buildApprovalNotificationBlocks(payload, messageText),
341
+ approval: payload.approvalContext,
311
342
  })
312
343
  : await sendSlackReply(chatId, messageText, { useBlocks: true });
313
344
 
@@ -28,10 +28,7 @@ import {
28
28
  updateDeliveryStatus,
29
29
  } from "./deliveries-store.js";
30
30
  import { resolveDestinations } from "./destination-resolver.js";
31
- import {
32
- parseGuardianQuestionPayload,
33
- resolveGuardianInstructionModeFromPayload,
34
- } from "./guardian-question-mode.js";
31
+ import { parseInteractiveApprovalPayload } from "./guardian-question-mode.js";
35
32
  import { nonEmpty } from "./notification-utils.js";
36
33
  import type { NotificationSignal } from "./signal.js";
37
34
  import type {
@@ -59,13 +56,17 @@ function resolveApprovalContext(
59
56
  signal: NotificationSignal,
60
57
  ): ApprovalUIMetadata | undefined {
61
58
  const payload = signal.contextPayload;
62
- if (!payload) return undefined;
59
+ if (!payload) {
60
+ return undefined;
61
+ }
63
62
 
64
63
  if (signal.sourceEventName === "ingress.access_request") {
65
64
  const requestId = nonEmpty(
66
65
  typeof payload.requestId === "string" ? payload.requestId : undefined,
67
66
  );
68
- if (!requestId) return undefined;
67
+ if (!requestId) {
68
+ return undefined;
69
+ }
69
70
  return {
70
71
  requestId,
71
72
  actions: APPROVAL_ACTIONS,
@@ -74,12 +75,11 @@ function resolveApprovalContext(
74
75
  }
75
76
 
76
77
  if (signal.sourceEventName === "guardian.question") {
77
- const parsed = parseGuardianQuestionPayload(payload);
78
- if (!parsed) return undefined;
79
- const { mode } = resolveGuardianInstructionModeFromPayload(parsed);
80
- if (mode !== "approval") return undefined;
81
- const requestId = nonEmpty(parsed.requestId);
82
- if (!requestId) return undefined;
78
+ const parsed = parseInteractiveApprovalPayload(payload);
79
+ if (!parsed) {
80
+ return undefined;
81
+ }
82
+ const requestId = parsed.requestId;
83
83
 
84
84
  // Extract tool context so channel adapters can render structured
85
85
  // approval cards without re-parsing contextPayload.
@@ -184,8 +184,12 @@ export class NotificationBroadcaster {
184
184
  // event fires immediately, before slower channel sends (e.g. Telegram 30s
185
185
  // timeout) can delay it past the macOS deep-link retry window.
186
186
  const orderedChannels = [...decision.selectedChannels].sort((a, b) => {
187
- if (a === "vellum") return -1;
188
- if (b === "vellum") return 1;
187
+ if (a === "vellum") {
188
+ return -1;
189
+ }
190
+ if (b === "vellum") {
191
+ return 1;
192
+ }
189
193
  return 0;
190
194
  });
191
195
 
@@ -552,7 +556,9 @@ export class NotificationBroadcaster {
552
556
  function resolveSourceConversationId(
553
557
  sourceContextId: string | undefined,
554
558
  ): string | undefined {
555
- if (!sourceContextId) return undefined;
559
+ if (!sourceContextId) {
560
+ return undefined;
561
+ }
556
562
  try {
557
563
  return getConversation(sourceContextId) ? sourceContextId : undefined;
558
564
  } catch {
@@ -47,8 +47,10 @@ import { createDecision } from "./decisions-store.js";
47
47
  import {
48
48
  buildGuardianRequestCodeInstruction,
49
49
  hasGuardianRequestCodeInstruction,
50
+ parseInteractiveApprovalPayload,
50
51
  resolveGuardianQuestionInstructionMode,
51
52
  stripConflictingGuardianRequestInstructions,
53
+ stripGuardianRequestCodeInstructions,
52
54
  } from "./guardian-question-mode.js";
53
55
  import { nonEmpty, readPayloadString } from "./notification-utils.js";
54
56
  import { getPreferenceSummary } from "./preference-summary.js";
@@ -143,6 +145,7 @@ function buildSystemPrompt(
143
145
  ` - Avoid meta-send phrasing (e.g. "I'd like to send a notification", "May I go ahead with that?"). Write the recipient-facing message directly.`,
144
146
  ` - Avoid intermediary-instruction phrasing like "consider telling the guardian", "ask the recipient to", or "the assistant should remind them". Rewrite it as final copy the recipient can act on directly.`,
145
147
  ` - For telegram: 1-2 concise sentences.`,
148
+ ` - For slack approval requests: the message renders inside an interactive card with Approve/Reject buttons. Describe only what needs approval — never include approval/reference codes, code-reply instructions, or directions to respond in another app.`,
146
149
  `- \`conversationSeedMessage\` is the opening message in the internal notification conversation and also the expanded detail shown in the home feed. It should be richer and more contextual than the popup body.`,
147
150
  ` - For vellum (desktop): use structured markdown for readability. Break content into bullet points, numbered lists, or short sections with **bold** labels. Avoid long unbroken paragraphs — scan-friendly formatting is preferred.`,
148
151
  ` - Never dump raw JSON. Include only human-readable context.`,
@@ -382,11 +385,19 @@ function validateDecisionOutput(
382
385
  availableChannels: NotificationChannel[],
383
386
  candidateSet?: ConversationCandidateSet,
384
387
  ): NotificationDecision | null {
385
- if (typeof input.shouldNotify !== "boolean") return null;
386
- if (typeof input.reasoningSummary !== "string") return null;
387
- if (typeof input.dedupeKey !== "string") return null;
388
+ if (typeof input.shouldNotify !== "boolean") {
389
+ return null;
390
+ }
391
+ if (typeof input.reasoningSummary !== "string") {
392
+ return null;
393
+ }
394
+ if (typeof input.dedupeKey !== "string") {
395
+ return null;
396
+ }
388
397
 
389
- if (!Array.isArray(input.selectedChannels)) return null;
398
+ if (!Array.isArray(input.selectedChannels)) {
399
+ return null;
400
+ }
390
401
  const validatedChannels = (input.selectedChannels as unknown[]).filter(
391
402
  (ch): ch is NotificationChannel =>
392
403
  typeof ch === "string" &&
@@ -482,7 +493,9 @@ export function validateConversationActions(
482
493
  ): Partial<Record<NotificationChannel, ConversationAction>> {
483
494
  const result: Partial<Record<NotificationChannel, ConversationAction>> = {};
484
495
 
485
- if (!raw || typeof raw !== "object") return result;
496
+ if (!raw || typeof raw !== "object") {
497
+ return result;
498
+ }
486
499
 
487
500
  const actionsObj = raw as Record<string, unknown>;
488
501
  const channelSet = new Set(validChannels);
@@ -502,8 +515,12 @@ export function validateConversationActions(
502
515
  }
503
516
 
504
517
  for (const [ch, actionRaw] of Object.entries(actionsObj)) {
505
- if (!channelSet.has(ch as NotificationChannel)) continue;
506
- if (!actionRaw || typeof actionRaw !== "object") continue;
518
+ if (!channelSet.has(ch as NotificationChannel)) {
519
+ continue;
520
+ }
521
+ if (!actionRaw || typeof actionRaw !== "object") {
522
+ continue;
523
+ }
507
524
 
508
525
  const channel = ch as NotificationChannel;
509
526
  const action = actionRaw as Record<string, unknown>;
@@ -558,8 +575,9 @@ function ensureGuardianRequestCodeInCopy(
558
575
  requestCode,
559
576
  mode,
560
577
  );
561
- if (hasGuardianRequestCodeInstruction(sanitized, requestCode, mode))
578
+ if (hasGuardianRequestCodeInstruction(sanitized, requestCode, mode)) {
562
579
  return sanitized;
580
+ }
563
581
  return sanitized.length > 0
564
582
  ? `${sanitized}\n\n${instruction}`
565
583
  : instruction;
@@ -577,36 +595,79 @@ function ensureGuardianRequestCodeInCopy(
577
595
  };
578
596
  }
579
597
 
598
+ /**
599
+ * Remove request-code reply instructions from copy for a channel whose
600
+ * approval UI makes them redundant. Instruction-only fields keep their
601
+ * original text rather than becoming empty — downstream treats an empty
602
+ * body as missing copy.
603
+ */
604
+ function stripGuardianRequestCodeInCopy(
605
+ copy: RenderedChannelCopy,
606
+ requestCode: string,
607
+ ): RenderedChannelCopy {
608
+ const strip = (text: string): string => {
609
+ const stripped = stripGuardianRequestCodeInstructions(text, requestCode);
610
+ return stripped.length > 0 ? stripped : text;
611
+ };
612
+
613
+ return {
614
+ ...copy,
615
+ body: strip(copy.body),
616
+ deliveryText: copy.deliveryText
617
+ ? strip(copy.deliveryText)
618
+ : copy.deliveryText,
619
+ conversationSeedMessage: copy.conversationSeedMessage
620
+ ? strip(copy.conversationSeedMessage)
621
+ : copy.conversationSeedMessage,
622
+ };
623
+ }
624
+
580
625
  /**
581
626
  * Guardian questions that share a conversation require explicit request-code
582
627
  * targeting. Enforce request-code instructions in rendered copy so guardians
583
628
  * can always disambiguate replies even when model copy omits them.
629
+ *
630
+ * Slack is the exception for approval-mode questions: the Slack adapter
631
+ * renders those as an interactive card with Approve/Reject buttons (see
632
+ * `resolveApprovalContext` in broadcaster.ts, gated on the same
633
+ * `parseInteractiveApprovalPayload`), so code-reply instructions are
634
+ * redundant noise there and are stripped instead of enforced in.
584
635
  */
585
636
  function enforceGuardianRequestCode(
586
637
  decision: NotificationDecision,
587
638
  signal: NotificationSignal,
588
639
  ): NotificationDecision {
589
- if (signal.sourceEventName !== "guardian.question") return decision;
640
+ if (signal.sourceEventName !== "guardian.question") {
641
+ return decision;
642
+ }
590
643
  const rawCode = signal.contextPayload.requestCode;
591
- if (typeof rawCode !== "string" || rawCode.trim().length === 0)
644
+ if (typeof rawCode !== "string" || rawCode.trim().length === 0) {
592
645
  return decision;
646
+ }
593
647
 
594
648
  const requestCode = rawCode.trim().toUpperCase();
595
649
  const modeResolution = resolveGuardianQuestionInstructionMode(
596
650
  signal.contextPayload,
597
651
  );
652
+ const slackRendersApprovalButtons =
653
+ parseInteractiveApprovalPayload(signal.contextPayload) != null;
598
654
  const nextCopy: Partial<Record<NotificationChannel, RenderedChannelCopy>> = {
599
655
  ...decision.renderedCopy,
600
656
  };
601
657
 
602
658
  for (const channel of Object.keys(nextCopy) as NotificationChannel[]) {
603
659
  const copy = nextCopy[channel];
604
- if (!copy) continue;
605
- nextCopy[channel] = ensureGuardianRequestCodeInCopy(
606
- copy,
607
- requestCode,
608
- modeResolution.mode,
609
- );
660
+ if (!copy) {
661
+ continue;
662
+ }
663
+ nextCopy[channel] =
664
+ channel === "slack" && slackRendersApprovalButtons
665
+ ? stripGuardianRequestCodeInCopy(copy, requestCode)
666
+ : ensureGuardianRequestCodeInCopy(
667
+ copy,
668
+ requestCode,
669
+ modeResolution.mode,
670
+ );
610
671
  }
611
672
 
612
673
  return {
@@ -624,14 +685,18 @@ function ensureSeedContentBlocks(
624
685
  decision: NotificationDecision,
625
686
  blocks: unknown[] | null | undefined,
626
687
  ): NotificationDecision {
627
- if (!blocks) return decision;
688
+ if (!blocks) {
689
+ return decision;
690
+ }
628
691
 
629
692
  const nextCopy: Partial<Record<NotificationChannel, RenderedChannelCopy>> = {
630
693
  ...decision.renderedCopy,
631
694
  };
632
695
  for (const channel of Object.keys(nextCopy) as NotificationChannel[]) {
633
696
  const copy = nextCopy[channel];
634
- if (!copy) continue;
697
+ if (!copy) {
698
+ continue;
699
+ }
635
700
  if (!copy.seedContentBlocks) {
636
701
  nextCopy[channel] = { ...copy, seedContentBlocks: blocks };
637
702
  }
@@ -651,7 +716,9 @@ function enforceToolApprovalSeedBlocks(
651
716
  decision: NotificationDecision,
652
717
  signal: NotificationSignal,
653
718
  ): NotificationDecision {
654
- if (signal.sourceEventName !== "guardian.question") return decision;
719
+ if (signal.sourceEventName !== "guardian.question") {
720
+ return decision;
721
+ }
655
722
  return ensureSeedContentBlocks(
656
723
  decision,
657
724
  buildToolApprovalSeedContentBlocks(signal.contextPayload),
@@ -675,7 +742,9 @@ function enforceAccessRequestInstructions(
675
742
  decision: NotificationDecision,
676
743
  signal: NotificationSignal,
677
744
  ): NotificationDecision {
678
- if (signal.sourceEventName !== "ingress.access_request") return decision;
745
+ if (signal.sourceEventName !== "ingress.access_request") {
746
+ return decision;
747
+ }
679
748
 
680
749
  const rawCode = signal.contextPayload.requestCode;
681
750
  const hasRequestCode =
@@ -691,7 +760,9 @@ function enforceAccessRequestInstructions(
691
760
 
692
761
  for (const channel of Object.keys(nextCopy) as NotificationChannel[]) {
693
762
  const copy = nextCopy[channel];
694
- if (!copy) continue;
763
+ if (!copy) {
764
+ continue;
765
+ }
695
766
  nextCopy[channel] = ensureAccessRequestInstructionsInCopy(
696
767
  copy,
697
768
  requestCode,
@@ -704,7 +775,9 @@ function enforceAccessRequestInstructions(
704
775
 
705
776
  for (const channel of Object.keys(nextCopy) as NotificationChannel[]) {
706
777
  const copy = nextCopy[channel];
707
- if (!copy) continue;
778
+ if (!copy) {
779
+ continue;
780
+ }
708
781
  nextCopy[channel] = ensureInviteFlowDirectiveInCopy(
709
782
  copy,
710
783
  inviteDirective,
@@ -755,7 +828,9 @@ function ensureInviteFlowDirectiveInCopy(
755
828
  ): RenderedChannelCopy {
756
829
  const ensureText = (text: string | undefined): string => {
757
830
  const base = typeof text === "string" ? text.trim() : "";
758
- if (hasInviteFlowDirective(base)) return base;
831
+ if (hasInviteFlowDirective(base)) {
832
+ return base;
833
+ }
759
834
  return base.length > 0 ? `${base}\n\n${inviteDirective}` : inviteDirective;
760
835
  };
761
836
 
@@ -1138,15 +1213,21 @@ export function enforceRoutingIntent(
1138
1213
  // Preserve the decision's selected channels first, then add connected
1139
1214
  // channels until we reach two channels total.
1140
1215
  for (const ch of selectedConnected) {
1141
- if (seen.has(ch)) continue;
1216
+ if (seen.has(ch)) {
1217
+ continue;
1218
+ }
1142
1219
  expanded.push(ch);
1143
1220
  seen.add(ch);
1144
1221
  }
1145
1222
  for (const ch of connectedChannels) {
1146
- if (seen.has(ch)) continue;
1223
+ if (seen.has(ch)) {
1224
+ continue;
1225
+ }
1147
1226
  expanded.push(ch);
1148
1227
  seen.add(ch);
1149
- if (expanded.length >= 2) break;
1228
+ if (expanded.length >= 2) {
1229
+ break;
1230
+ }
1150
1231
  }
1151
1232
 
1152
1233
  const enforced = { ...decision };
@@ -1184,15 +1265,20 @@ export function enforceGuardianCallConversationAffinity(
1184
1265
  decision: NotificationDecision,
1185
1266
  signal: NotificationSignal,
1186
1267
  ): NotificationDecision {
1187
- if (signal.sourceEventName !== "guardian.question") return decision;
1268
+ if (signal.sourceEventName !== "guardian.question") {
1269
+ return decision;
1270
+ }
1188
1271
 
1189
1272
  const callSessionId = signal.contextPayload?.callSessionId;
1190
- if (typeof callSessionId !== "string" || callSessionId.trim().length === 0)
1273
+ if (typeof callSessionId !== "string" || callSessionId.trim().length === 0) {
1191
1274
  return decision;
1275
+ }
1192
1276
 
1193
1277
  // If an affinity hint already exists for vellum, the second+ dispatch
1194
1278
  // will be handled by enforceConversationAffinity — nothing to do here.
1195
- if (signal.conversationAffinityHint?.vellum) return decision;
1279
+ if (signal.conversationAffinityHint?.vellum) {
1280
+ return decision;
1281
+ }
1196
1282
 
1197
1283
  const enforced = { ...decision };
1198
1284
  const conversationActions: Partial<
@@ -1226,13 +1312,17 @@ function enforceConversationAffinity(
1226
1312
  decision: NotificationDecision,
1227
1313
  affinityHint: Partial<Record<string, string>> | undefined,
1228
1314
  ): NotificationDecision {
1229
- if (!affinityHint) return decision;
1315
+ if (!affinityHint) {
1316
+ return decision;
1317
+ }
1230
1318
 
1231
1319
  const entries = Object.entries(affinityHint).filter(
1232
1320
  ([, conversationId]) =>
1233
1321
  typeof conversationId === "string" && conversationId.length > 0,
1234
1322
  );
1235
- if (entries.length === 0) return decision;
1323
+ if (entries.length === 0) {
1324
+ return decision;
1325
+ }
1236
1326
 
1237
1327
  const enforced = { ...decision };
1238
1328
  const conversationActions: Partial<