@vellumai/assistant 0.11.8-staging.1 → 0.11.8

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.
Files changed (48) hide show
  1. package/docs/guardian-request-flow.md +3 -3
  2. package/openapi.yaml +0 -24
  3. package/package.json +1 -1
  4. package/src/__tests__/access-request-seed-content-blocks.test.ts +2 -2
  5. package/src/__tests__/anthropic-provider.test.ts +2 -0
  6. package/src/__tests__/channel-availability-routes.test.ts +15 -0
  7. package/src/__tests__/channel-readiness-routes.test.ts +96 -3
  8. package/src/__tests__/channel-readiness-service.test.ts +19 -4
  9. package/src/__tests__/config-loader-backfill.test.ts +2 -2
  10. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  11. package/src/__tests__/email-invite-adapter.test.ts +53 -12
  12. package/src/__tests__/external-binding-chat-name.test.ts +60 -0
  13. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +28 -0
  14. package/src/__tests__/managed-profile-guard.test.ts +1 -1
  15. package/src/__tests__/model-intents.test.ts +2 -2
  16. package/src/__tests__/pricing.test.ts +13 -0
  17. package/src/__tests__/tool-approval-seed-content-blocks.test.ts +16 -3
  18. package/src/api/responses/home.ts +0 -6
  19. package/src/config/__tests__/default-profile-catalog.test.ts +1 -1
  20. package/src/config/call-site-defaults.ts +1 -1
  21. package/src/config/default-profile-catalog.ts +12 -12
  22. package/src/config/default-profile-names.ts +2 -2
  23. package/src/email/byo-email-credential.test.ts +39 -0
  24. package/src/email/byo-email-credential.ts +76 -0
  25. package/src/email/registered-inbox.test.ts +272 -0
  26. package/src/email/registered-inbox.ts +169 -0
  27. package/src/messaging/providers/slack/approval-source.test.ts +70 -0
  28. package/src/messaging/providers/slack/approval-source.ts +18 -5
  29. package/src/notifications/__tests__/guardian-feed-projection.test.ts +13 -14
  30. package/src/notifications/approval-card-data.ts +13 -5
  31. package/src/notifications/emit-signal.ts +5 -8
  32. package/src/notifications/guardian-feed-projection.ts +9 -74
  33. package/src/notifications/guardian-question-mode.ts +33 -1
  34. package/src/notifications/home-feed-side-effect.ts +0 -4
  35. package/src/persistence/external-conversation-store.ts +5 -1
  36. package/src/providers/anthropic/client.ts +1 -1
  37. package/src/providers/model-catalog.ts +54 -0
  38. package/src/providers/model-intents.ts +3 -3
  39. package/src/runtime/approval-source-link.ts +6 -0
  40. package/src/runtime/channel-invite-transports/email.ts +10 -9
  41. package/src/runtime/channel-readiness-service.ts +83 -23
  42. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +1 -1
  43. package/src/runtime/routes/channel-availability-routes.ts +18 -31
  44. package/src/runtime/routes/credential-routes.ts +3 -0
  45. package/src/runtime/routes/email-routes.ts +35 -47
  46. package/src/runtime/verification-outbound-actions.ts +8 -12
  47. package/src/tools/credentials/store.ts +3 -0
  48. package/src/util/pricing.ts +2 -1
@@ -0,0 +1,70 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+
3
+ // Spread the actual modules so transitive importers of names these
4
+ // factories do not stub keep resolving (a partial factory breaks at
5
+ // import time when the graph gains a new named import).
6
+ const actualDeliveryCrud =
7
+ await import("../../../persistence/delivery-crud.js");
8
+ const actualBindingStore =
9
+ await import("../../../persistence/external-conversation-store.js");
10
+
11
+ let bindingRow: {
12
+ sourceChannel: string;
13
+ externalChatId: string;
14
+ externalChatName: string | null;
15
+ conversationId: string;
16
+ } | null = null;
17
+
18
+ mock.module("../../../persistence/delivery-crud.js", () => ({
19
+ ...actualDeliveryCrud,
20
+ getLatestInboundEventReference: () => null,
21
+ }));
22
+
23
+ mock.module("../../../persistence/external-conversation-store.js", () => ({
24
+ ...actualBindingStore,
25
+ getBindingByConversation: () => bindingRow,
26
+ }));
27
+
28
+ const { resolveSlackApprovalSource } = await import("./approval-source.js");
29
+
30
+ const HINT = {
31
+ requesterChatId: "C0123CHANNEL",
32
+ sourceMessageId: "1700000000.000100",
33
+ };
34
+
35
+ describe("resolveSlackApprovalSource chat names", () => {
36
+ test("the binding's chat name rides the reference", () => {
37
+ bindingRow = {
38
+ sourceChannel: "slack",
39
+ externalChatId: "C0123CHANNEL",
40
+ externalChatName: "user-feedback",
41
+ conversationId: "conv-1",
42
+ };
43
+ const reference = resolveSlackApprovalSource("conv-1", HINT);
44
+ expect(reference?.sourceChatId).toBe("C0123CHANNEL");
45
+ expect(reference?.sourceChatName).toBe("user-feedback");
46
+ });
47
+
48
+ test("a binding for a different chat never lends its name", () => {
49
+ bindingRow = {
50
+ sourceChannel: "slack",
51
+ externalChatId: "C0OTHER",
52
+ externalChatName: "somewhere-else",
53
+ conversationId: "conv-1",
54
+ };
55
+ const reference = resolveSlackApprovalSource("conv-1", HINT);
56
+ expect(reference?.sourceChatId).toBe("C0123CHANNEL");
57
+ expect(reference?.sourceChatName).toBeUndefined();
58
+ });
59
+
60
+ test("an unnamed binding leaves the reference nameless", () => {
61
+ bindingRow = {
62
+ sourceChannel: "slack",
63
+ externalChatId: "C0123CHANNEL",
64
+ externalChatName: null,
65
+ conversationId: "conv-1",
66
+ };
67
+ const reference = resolveSlackApprovalSource("conv-1", HINT);
68
+ expect(reference?.sourceChatName).toBeUndefined();
69
+ });
70
+ });
@@ -26,16 +26,19 @@ import { isSlackTs } from "./message-metadata.js";
26
26
 
27
27
  function toReference(
28
28
  chatId: string,
29
+ chatName: string | null,
29
30
  messageTs: string | undefined,
30
31
  threadTs: string | undefined,
31
32
  ): ApprovalSourceReference {
33
+ const named = chatName ? { sourceChatName: chatName } : {};
32
34
  // Without a message ts, the thread root is still a valid anchor.
33
35
  const anchorTs = messageTs ?? threadTs;
34
36
  if (!anchorTs) {
35
- return { sourceChatId: chatId };
37
+ return { sourceChatId: chatId, ...named };
36
38
  }
37
39
  return {
38
40
  sourceChatId: chatId,
41
+ ...named,
39
42
  sourceLink: {
40
43
  webUrl: buildSlackPermalink({
41
44
  channelId: chatId,
@@ -50,20 +53,30 @@ export function resolveSlackApprovalSource(
50
53
  conversationId: string,
51
54
  hint: ApprovalSourceHint | undefined,
52
55
  ): ApprovalSourceReference | null {
56
+ const rawBinding = getBindingByConversation(conversationId);
57
+ const binding = rawBinding?.sourceChannel === "slack" ? rawBinding : null;
58
+
59
+ // The chat's display name lives on the binding row, which ingress
60
+ // upserts from every inbound message. Matched by chat id so a binding
61
+ // that moved to another chat never lends its name.
62
+ const chatNameFor = (chatId: string): string | null =>
63
+ binding?.externalChatId === chatId
64
+ ? binding.externalChatName?.trim() || null
65
+ : null;
66
+
53
67
  // Exact provenance from the turn's trust context. An absent thread id here
54
68
  // is authoritative (the message arrived at the chat root), so no binding
55
- // lookup is needed.
69
+ // lookup is needed for the reference itself.
56
70
  if (hint?.requesterChatId && isSlackTs(hint.sourceMessageId)) {
57
71
  return toReference(
58
72
  hint.requesterChatId,
73
+ chatNameFor(hint.requesterChatId),
59
74
  hint.sourceMessageId,
60
75
  isSlackTs(hint.sourceThreadId) ? hint.sourceThreadId : undefined,
61
76
  );
62
77
  }
63
78
 
64
79
  const inbound = getLatestInboundEventReference(conversationId, "slack");
65
- const rawBinding = getBindingByConversation(conversationId);
66
- const binding = rawBinding?.sourceChannel === "slack" ? rawBinding : null;
67
80
 
68
81
  const chatId =
69
82
  hint?.requesterChatId ?? inbound?.externalChatId ?? binding?.externalChatId;
@@ -83,5 +96,5 @@ export function resolveSlackApprovalSource(
83
96
  ? binding.externalThreadId
84
97
  : undefined;
85
98
 
86
- return toReference(chatId, messageTs, threadTs);
99
+ return toReference(chatId, chatNameFor(chatId), messageTs, threadTs);
87
100
  }
@@ -48,7 +48,7 @@ const {
48
48
  } = await import("../guardian-feed-projection.js");
49
49
  const { appendFeedItem, bulkSetFeedItemStatus, getHomeFeedPath, readHomeFeed } =
50
50
  await import("../../home/feed-writer.js");
51
- const { isPendingGuardianFeedItem } =
51
+ const { GUARDIAN_TERMINAL_REASON_SUPERSEDED, isPendingGuardianFeedItem } =
52
52
  await import("../../api/responses/home.js");
53
53
  type FeedItem = import("../../api/responses/home.js").FeedItem;
54
54
 
@@ -115,7 +115,7 @@ describe("buildPendingGuardianProjection", () => {
115
115
  requesterLabel: "Alice",
116
116
  toolName: "linear_graphql",
117
117
  sourceChannel: "slack",
118
- sourceContextLabel: "Slack #C0123456789",
118
+ sourceContextLabel: "#C0123456789",
119
119
  });
120
120
  });
121
121
 
@@ -130,14 +130,12 @@ describe("buildPendingGuardianProjection", () => {
130
130
  expect(projection?.status).toBe("pending");
131
131
  });
132
132
 
133
- test("a sent Slack delivery yields the card deep link", () => {
134
- const projection = buildPendingGuardianProjection(toolApprovalPayload, {
135
- channel: "slack",
136
- destination: "D0AAAAAAAAA",
137
- status: "sent",
138
- messageId: "1725100000.000100",
133
+ test("a captured channel name becomes the source context label", () => {
134
+ const projection = buildPendingGuardianProjection({
135
+ ...toolApprovalPayload,
136
+ sourceChatName: "user-feedback",
139
137
  });
140
- expect(projection?.slackCardUrl).toContain("D0AAAAAAAAA");
138
+ expect(projection?.sourceContextLabel).toBe("#user-feedback");
141
139
  });
142
140
 
143
141
  test("a payload without a requestId projects nothing", () => {
@@ -154,7 +152,6 @@ describe("buildPendingGuardianProjection", () => {
154
152
  sourceChannel: "telegram",
155
153
  senderIdentifier: "Alice",
156
154
  },
157
- undefined,
158
155
  "access_request",
159
156
  );
160
157
  expect(projection).toMatchObject({
@@ -250,19 +247,21 @@ describe("writeGuardianFeedReceipt", () => {
250
247
  expect(item && isPendingGuardianFeedItem(item)).toBe(false);
251
248
  });
252
249
 
253
- test("a superseded auto-deny carries its reason and no decider", async () => {
250
+ test("a superseded auto-deny carries its reason", async () => {
254
251
  await appendFeedItem(pendingGuardianItem("req-4"));
255
252
  await writeGuardianFeedReceipt({
256
253
  requestId: "req-4",
257
254
  status: "denied",
258
255
  decidedAction: "reject",
259
- terminalReason: "superseded",
256
+ terminalReason: GUARDIAN_TERMINAL_REASON_SUPERSEDED,
260
257
  });
261
258
  const item = readHomeFeed().items.find(
262
259
  (i) => i.id === guardianFeedItemId("req-4"),
263
260
  );
264
- expect(item?.guardianRequest?.terminalReason).toBe("superseded");
265
- expect(item?.guardianRequest?.decidedByLabel).toBeUndefined();
261
+ expect(item?.guardianRequest?.terminalReason).toBe(
262
+ GUARDIAN_TERMINAL_REASON_SUPERSEDED,
263
+ );
264
+ expect(item?.guardianRequest?.status).toBe("denied");
266
265
  });
267
266
 
268
267
  test("a request with no item resolves true (nothing to retry)", async () => {
@@ -31,6 +31,7 @@ import {
31
31
  buildGuardianRequestCodeInstruction,
32
32
  buildQuestionDeliveryText,
33
33
  buildToolApprovalSourceView,
34
+ describeSlackChatLabel,
34
35
  type GuardianQuestionPayload,
35
36
  type LenientToolApprovalPayload,
36
37
  LenientToolApprovalPayloadSchema,
@@ -151,12 +152,17 @@ function sourceMetadataRow(
151
152
  channel: string | undefined,
152
153
  slackChatId: string | undefined,
153
154
  isSlackDm: boolean,
155
+ chatName?: string,
154
156
  ): { label: string; value: string } | undefined {
155
- if (channel === "slack" && slackChatId) {
156
- return {
157
- label: "Source",
158
- value: isSlackDm ? "Slack — Direct message" : `Slack — #${slackChatId}`,
159
- };
157
+ if (channel === "slack") {
158
+ const chat = describeSlackChatLabel({
159
+ chatId: slackChatId,
160
+ chatName,
161
+ isSlackDm,
162
+ });
163
+ if (chat) {
164
+ return { label: "Source", value: `Slack · ${chat}` };
165
+ }
160
166
  }
161
167
  if (channel) {
162
168
  return { label: "Source", value: channel };
@@ -298,6 +304,7 @@ function extractToolApprovalCard(
298
304
  nonEmpty(p.sourceChannel),
299
305
  sourceView?.chatId,
300
306
  sourceView?.isSlackDm ?? false,
307
+ sourceView?.chatName,
301
308
  );
302
309
  if (sourceRow) {
303
310
  metadata.push(sourceRow);
@@ -364,6 +371,7 @@ function extractQuestionCard(
364
371
  nonEmpty(p.sourceChannel),
365
372
  sourceView?.chatId,
366
373
  sourceView?.isSlackDm ?? false,
374
+ sourceView?.chatName,
367
375
  );
368
376
  if (sourceRow) {
369
377
  metadata.push(sourceRow);
@@ -547,14 +547,11 @@ export async function emitNotificationSignal<TEventName extends string>(
547
547
  const vellumDelivery = dispatchResult.deliveryResults.find(
548
548
  (r) => r.channel === "vellum",
549
549
  );
550
- await writeHomeFeedItemForSignal(
551
- signal,
552
- decision,
553
- vellumDelivery,
554
- dispatchResult.deliveryResults,
555
- ).catch((err) => {
556
- log.warn({ err, signalId }, "writeHomeFeedItemForSignal threw");
557
- });
550
+ await writeHomeFeedItemForSignal(signal, decision, vellumDelivery).catch(
551
+ (err) => {
552
+ log.warn({ err, signalId }, "writeHomeFeedItemForSignal threw");
553
+ },
554
+ );
558
555
 
559
556
  log.info(
560
557
  {
@@ -28,27 +28,22 @@ import {
28
28
  type GuardianRequestWire,
29
29
  listGuardianRequests,
30
30
  } from "../channels/gateway-guardian-requests.js";
31
- import { getConfig } from "../config/loader.js";
32
31
  import {
33
32
  appendFeedItem,
34
33
  patchFeedItemContent,
35
34
  readHomeFeed,
36
35
  } from "../home/feed-writer.js";
37
- import { buildSlackMessageDeepLinks } from "../messaging/providers/slack/deep-link.js";
38
- import {
39
- DEFAULT_USER_REFERENCE,
40
- resolveGuardianName,
41
- } from "../prompts/user-reference.js";
42
36
  import { getLogger } from "../util/logger.js";
43
37
  import {
44
38
  buildToolApprovalSourceView,
39
+ describeSlackChatLabel,
45
40
  type GuardianQuestionRequestKind,
46
41
  LenientToolApprovalPayloadSchema,
47
42
  resolveGuardianInstructionModeFromFields,
48
43
  resolveGuardianQuestionInstructionMode,
44
+ type ToolApprovalSourceView,
49
45
  } from "./guardian-question-mode.js";
50
46
  import { readPayloadString } from "./notification-utils.js";
51
- import type { NotificationDeliveryResult } from "./types.js";
52
47
 
53
48
  const log = getLogger("guardian-feed-projection");
54
49
 
@@ -90,29 +85,13 @@ export function requestIdFromGuardianFeedItemId(itemId: string): string | null {
90
85
  : null;
91
86
  }
92
87
 
93
- /**
94
- * Guardian display name for receipt copy, or undefined when only the
95
- * prompt-voice fallback ("my human") is available. A receipt with no
96
- * decider label renders as the bare status word.
97
- */
98
- function guardianReceiptName(): string | undefined {
99
- const name = resolveGuardianName();
100
- return name === DEFAULT_USER_REFERENCE ? undefined : name;
101
- }
102
-
103
88
  /**
104
89
  * Build the pending `guardianRequest` projection for a `guardian.question`
105
90
  * signal's feed item. Returns null when the payload does not carry the
106
91
  * request id the projection is keyed by.
107
- *
108
- * `slackDelivery` is the signal's Slack delivery outcome, when one
109
- * shipped: its destination chat + message ts locate the guardian-DM
110
- * approval card, and the derived deep links are what the client's
111
- * "Open in Slack" affordance opens.
112
92
  */
113
93
  export function buildPendingGuardianProjection(
114
94
  contextPayload: unknown,
115
- slackDelivery?: NotificationDeliveryResult,
116
95
  fallbackKind?: GuardianQuestionRequestKind,
117
96
  ): FeedItemGuardianRequest | null {
118
97
  // Access-request payloads predate the kind registry and carry no
@@ -140,7 +119,6 @@ export function buildPendingGuardianProjection(
140
119
  );
141
120
 
142
121
  const sourceView = buildToolApprovalSourceView(payload);
143
- const slackLinks = buildSlackCardLinks(slackDelivery);
144
122
  // Access-request payloads name their requester differently.
145
123
  const requesterLabel =
146
124
  payload.requesterIdentifier?.trim() ||
@@ -159,8 +137,6 @@ export function buildPendingGuardianProjection(
159
137
  ? { sourceContextLabel: describeApprovalSourceContext(sourceView) }
160
138
  : {}),
161
139
  ...(sourceView?.permalink ? { sourceUrl: sourceView.permalink } : {}),
162
- ...(slackLinks?.webUrl ? { slackCardUrl: slackLinks.webUrl } : {}),
163
- ...(slackLinks?.appUrl ? { slackCardAppUrl: slackLinks.appUrl } : {}),
164
140
  };
165
141
  }
166
142
 
@@ -177,43 +153,18 @@ function intentFromInstructionMode(
177
153
  }
178
154
 
179
155
  /**
180
- * Display label for the originating chat, mirroring the wording the
181
- * in-app approval card's source row uses (`sourceMetadataRow` in
182
- * `approval-card-data.ts`): Slack chats are named, other channels fall
183
- * back to the channel id.
156
+ * Display label for the originating chat: the bare chat label from the
157
+ * shared derivation (the bell's context line joins it with the tool, so
158
+ * the channel word would be noise), or the channel id for channels the
159
+ * view carries no chat facts for.
184
160
  */
185
- function describeApprovalSourceContext(view: {
186
- channel: string;
187
- chatId?: string | null;
188
- isSlackDm: boolean;
189
- }): string {
190
- if (view.channel === "slack" && view.chatId) {
191
- return view.isSlackDm ? "Slack direct message" : `Slack #${view.chatId}`;
161
+ function describeApprovalSourceContext(view: ToolApprovalSourceView): string {
162
+ if (view.channel === "slack") {
163
+ return describeSlackChatLabel(view) || view.channel;
192
164
  }
193
165
  return view.channel;
194
166
  }
195
167
 
196
- /** Deep links to the Slack guardian-DM approval card, when one shipped. */
197
- function buildSlackCardLinks(
198
- slackDelivery?: NotificationDeliveryResult,
199
- ): { appUrl?: string; webUrl?: string } | undefined {
200
- if (
201
- !slackDelivery ||
202
- slackDelivery.status !== "sent" ||
203
- !slackDelivery.destination ||
204
- !slackDelivery.messageId
205
- ) {
206
- return undefined;
207
- }
208
- const slackConfig = getConfig().slack;
209
- return buildSlackMessageDeepLinks({
210
- teamId: slackConfig?.teamId,
211
- teamUrl: slackConfig?.teamUrl,
212
- channelId: slackDelivery.destination,
213
- messageTs: slackDelivery.messageId,
214
- });
215
- }
216
-
217
168
  export interface GuardianFeedReceiptParams {
218
169
  requestId: string;
219
170
  status: GuardianRequestStatus;
@@ -267,13 +218,6 @@ export async function writeGuardianFeedReceipt(
267
218
  ...(params.decidedAction
268
219
  ? { decidedAction: params.decidedAction }
269
220
  : {}),
270
- // A decider label only when a person decided: a non-decision
271
- // terminal (superseded, expired) must not read as the guardian's
272
- // own rejection.
273
- ...((params.status === "approved" || params.status === "denied") &&
274
- !params.terminalReason
275
- ? decidedByLabelPatch()
276
- : {}),
277
221
  decidedAt: new Date(params.decidedAtMs ?? Date.now()).toISOString(),
278
222
  ...(params.terminalReason
279
223
  ? { terminalReason: params.terminalReason }
@@ -297,15 +241,6 @@ export async function writeGuardianFeedReceipt(
297
241
  }
298
242
  }
299
243
 
300
- /** `{ decidedByLabel }` when a real display name exists, `{}` otherwise. */
301
- function decidedByLabelPatch(): Pick<
302
- Partial<FeedItemGuardianRequest>,
303
- "decidedByLabel"
304
- > {
305
- const name = guardianReceiptName();
306
- return name === undefined ? {} : { decidedByLabel: name };
307
- }
308
-
309
244
  /**
310
245
  * Rewrite a request's feed item into its receipt when the canonical row
311
246
  * is already terminal. Converges the write-vs-resolve race from the
@@ -99,6 +99,8 @@ export const PendingQuestionPayloadSchema =
99
99
  const sourceReferenceFields = {
100
100
  /** Channel-native chat/conversation id the request originated from. */
101
101
  sourceChatId: z.string().optional(),
102
+ /** Display name of the originating chat, when ingress captured one. */
103
+ sourceChatName: z.string().optional(),
102
104
  /** Deep link to the originating message/thread, when derivable. */
103
105
  sourceLink: externalSourceLinkSchema.optional(),
104
106
  };
@@ -162,6 +164,7 @@ export const LenientToolApprovalPayloadSchema = z.object({
162
164
  riskLevel: z.string().nullable().optional(),
163
165
  commandPreview: z.string().nullable().optional(),
164
166
  sourceChatId: z.string().nullable().optional(),
167
+ sourceChatName: z.string().nullable().optional(),
165
168
  sourceLink: sourceReferenceFields.sourceLink.nullable(),
166
169
  });
167
170
 
@@ -190,6 +193,8 @@ export const ToolApprovalSourceViewSchema = z.object({
190
193
  channel: z.string(),
191
194
  /** Channel-native chat id, when the payload carries one. */
192
195
  chatId: z.string().optional(),
196
+ /** Display name of the originating chat, when ingress captured one. */
197
+ chatName: z.string().optional(),
193
198
  /** Whether the source chat is a Slack direct message (`false` for other channels). */
194
199
  isSlackDm: z.boolean(),
195
200
  /** Link to the originating message/thread, when derivable. */
@@ -205,10 +210,36 @@ export type ToolApprovalSourceView = z.infer<
205
210
  * facts. Returns `undefined` when the payload names no chat and carries no
206
211
  * link — renderers then fall back to the plain channel label.
207
212
  */
213
+ /**
214
+ * Bare display label for the source chat, derived once for every
215
+ * renderer: `Direct message` for a DM, `#name` for a named channel,
216
+ * `#<id>` when only the id is known, empty when the view names no chat.
217
+ * Slack-scoped like the facts it reads; surfaces add their own framing
218
+ * (the card prefixes the channel word, the bell uses it bare).
219
+ */
220
+ export function describeSlackChatLabel(
221
+ view: Pick<ToolApprovalSourceView, "chatId" | "chatName" | "isSlackDm">,
222
+ ): string {
223
+ if (view.isSlackDm) {
224
+ return "Direct message";
225
+ }
226
+ if (view.chatName) {
227
+ return `#${view.chatName}`;
228
+ }
229
+ if (view.chatId) {
230
+ return `#${view.chatId}`;
231
+ }
232
+ return "";
233
+ }
234
+
208
235
  export function buildToolApprovalSourceView(
209
236
  p: Pick<
210
237
  LenientToolApprovalPayload,
211
- "sourceChannel" | "sourceChatId" | "sourceLink" | "requesterChatId"
238
+ | "sourceChannel"
239
+ | "sourceChatId"
240
+ | "sourceChatName"
241
+ | "sourceLink"
242
+ | "requesterChatId"
212
243
  >,
213
244
  ): ToolApprovalSourceView | undefined {
214
245
  const channel = nonEmpty(p.sourceChannel);
@@ -225,6 +256,7 @@ export function buildToolApprovalSourceView(
225
256
  return {
226
257
  channel,
227
258
  chatId,
259
+ chatName: nonEmpty(p.sourceChatName),
228
260
  isSlackDm:
229
261
  channel === "slack" && chatId != null && isSlackDmConversation(chatId),
230
262
  permalink,
@@ -85,7 +85,6 @@ export async function writeHomeFeedItemForSignal(
85
85
  signal: NotificationSignal,
86
86
  decision: NotificationDecision,
87
87
  vellumDelivery?: NotificationDeliveryResult,
88
- deliveryResults?: NotificationDeliveryResult[],
89
88
  ): Promise<FeedItem | null> {
90
89
  const { mirror, sourceConversationId, sourceScheduleJobId } =
91
90
  resolveHomeFeedMirror(signal, vellumDelivery?.conversationId);
@@ -178,9 +177,6 @@ export async function writeHomeFeedItemForSignal(
178
177
  )
179
178
  ? buildPendingGuardianProjection(
180
179
  signal.contextPayload,
181
- deliveryResults?.find(
182
- (r) => r.channel === "slack" && r.status === "sent",
183
- ),
184
180
  signal.sourceEventName === "ingress.access_request"
185
181
  ? "access_request"
186
182
  : undefined,
@@ -113,9 +113,13 @@ export function upsertBinding(input: UpsertBindingInput): void {
113
113
  set: {
114
114
  sourceChannel: input.sourceChannel,
115
115
  externalChatId: input.externalChatId,
116
+ // An absent chat name preserves the stored one only while the
117
+ // binding still addresses the same chat; a move to a chat that
118
+ // reported no name clears it, so the old chat's name can never
119
+ // label the new one.
116
120
  externalChatName:
117
121
  externalChatName ??
118
- sql`${externalConversationBindings.externalChatName}`,
122
+ sql`CASE WHEN ${externalConversationBindings.sourceChannel} = ${input.sourceChannel} AND ${externalConversationBindings.externalChatId} = ${input.externalChatId} THEN ${externalConversationBindings.externalChatName} ELSE NULL END`,
119
123
  externalThreadId,
120
124
  // An omitted sender field keeps what is stored; an explicit value,
121
125
  // including null, replaces it. A caller that knows only the chat
@@ -1056,7 +1056,7 @@ export class AnthropicProvider implements Provider {
1056
1056
  /claude-opus-4-[78]\b/.test(effectiveModel) ||
1057
1057
  /claude-opus-5\b/.test(effectiveModel) ||
1058
1058
  /claude-sonnet-5\b/.test(effectiveModel) ||
1059
- effectiveModel.startsWith("claude-fable-");
1059
+ /claude-fable-/.test(effectiveModel);
1060
1060
  const mergedOutputConfig = {
1061
1061
  ...(output_config ?? {}),
1062
1062
  ...(effort && effort !== "none" && supportsEffort
@@ -193,6 +193,24 @@ const RAW_PROVIDER_CATALOG: ProviderCatalogEntry[] = [
193
193
  linkLabel: "Open Anthropic Console",
194
194
  },
195
195
  models: [
196
+ {
197
+ id: "claude-fable-5-1",
198
+ displayName: "Claude Fable 5.1",
199
+ contextWindowTokens: 1000000,
200
+ maxOutputTokens: 128000,
201
+ longContextPricingThresholdTokens: 200000,
202
+ supportsThinking: true,
203
+ adaptiveThinkingOnly: true,
204
+ supportsCaching: true,
205
+ supportsVision: true,
206
+ supportsToolUse: true,
207
+ pricing: {
208
+ inputPer1mTokens: 10,
209
+ outputPer1mTokens: 50,
210
+ cacheWritePer1mTokens: 12.5,
211
+ cacheReadPer1mTokens: 0.25,
212
+ },
213
+ },
196
214
  {
197
215
  id: "claude-fable-5",
198
216
  displayName: "Claude Fable 5",
@@ -1086,6 +1104,24 @@ const RAW_PROVIDER_CATALOG: ProviderCatalogEntry[] = [
1086
1104
  // OpenRouter proxies anthropic/* through Anthropic's Messages API, so
1087
1105
  // prompt caching and cache TTL metadata pass through unchanged and
1088
1106
  // billing matches Anthropic's direct rates.
1107
+ {
1108
+ id: "anthropic/claude-fable-5.1",
1109
+ displayName: "Claude Fable 5.1",
1110
+ contextWindowTokens: 1000000,
1111
+ maxOutputTokens: 128000,
1112
+ longContextPricingThresholdTokens: 200000,
1113
+ supportsThinking: true,
1114
+ adaptiveThinkingOnly: true,
1115
+ supportsCaching: true,
1116
+ supportsVision: true,
1117
+ supportsToolUse: true,
1118
+ pricing: {
1119
+ inputPer1mTokens: 10,
1120
+ outputPer1mTokens: 50,
1121
+ cacheWritePer1mTokens: 12.5,
1122
+ cacheReadPer1mTokens: 0.25,
1123
+ },
1124
+ },
1089
1125
  {
1090
1126
  id: "anthropic/claude-fable-5",
1091
1127
  displayName: "Claude Fable 5",
@@ -1934,6 +1970,24 @@ const RAW_PROVIDER_CATALOG: ProviderCatalogEntry[] = [
1934
1970
  // The gateway proxies anthropic/* through Anthropic's Messages API, so
1935
1971
  // prompt caching and cache TTL metadata pass through unchanged and
1936
1972
  // billing matches Anthropic's direct rates.
1973
+ {
1974
+ id: "anthropic/claude-fable-5.1",
1975
+ displayName: "Claude Fable 5.1",
1976
+ contextWindowTokens: 1000000,
1977
+ maxOutputTokens: 128000,
1978
+ longContextPricingThresholdTokens: 200000,
1979
+ supportsThinking: true,
1980
+ adaptiveThinkingOnly: true,
1981
+ supportsCaching: true,
1982
+ supportsVision: true,
1983
+ supportsToolUse: true,
1984
+ pricing: {
1985
+ inputPer1mTokens: 10,
1986
+ outputPer1mTokens: 50,
1987
+ cacheWritePer1mTokens: 12.5,
1988
+ cacheReadPer1mTokens: 0.25,
1989
+ },
1990
+ },
1937
1991
  {
1938
1992
  id: "anthropic/claude-fable-5",
1939
1993
  displayName: "Claude Fable 5",
@@ -20,7 +20,7 @@ const PROVIDER_MODEL_INTENTS: Record<string, Record<ModelIntent, string>> = {
20
20
  balanced: "claude-sonnet-4-6",
21
21
  "cost-optimized": "claude-haiku-4-5-20251001",
22
22
  "latency-optimized": "claude-haiku-4-5-20251001",
23
- "quality-optimized": "claude-fable-5",
23
+ "quality-optimized": "claude-fable-5-1",
24
24
  "vision-optimized": "claude-opus-4-6",
25
25
  },
26
26
  openai: {
@@ -55,14 +55,14 @@ const PROVIDER_MODEL_INTENTS: Record<string, Record<ModelIntent, string>> = {
55
55
  balanced: "anthropic/claude-sonnet-4.6",
56
56
  "cost-optimized": "deepseek/deepseek-v4-flash",
57
57
  "latency-optimized": "anthropic/claude-haiku-4.5",
58
- "quality-optimized": "anthropic/claude-fable-5",
58
+ "quality-optimized": "anthropic/claude-fable-5.1",
59
59
  "vision-optimized": "anthropic/claude-opus-4.6",
60
60
  },
61
61
  "vercel-ai-gateway": {
62
62
  balanced: "anthropic/claude-sonnet-4.6",
63
63
  "cost-optimized": "deepseek/deepseek-v4-flash",
64
64
  "latency-optimized": "anthropic/claude-haiku-4.5",
65
- "quality-optimized": "anthropic/claude-fable-5",
65
+ "quality-optimized": "anthropic/claude-fable-5.1",
66
66
  "vision-optimized": "anthropic/claude-opus-4.6",
67
67
  },
68
68
  };
@@ -45,6 +45,12 @@ const log = getLogger("approval-source-link");
45
45
  export interface ApprovalSourceReference {
46
46
  /** Channel-native chat/conversation id the request originated from. */
47
47
  sourceChatId: string;
48
+ /**
49
+ * Display name of the originating chat, when ingress captured one (a
50
+ * Slack channel's name, without the `#`). Renderers prefer it over the
51
+ * raw chat id and fall back when absent (DMs, pre-capture rows).
52
+ */
53
+ sourceChatName?: string;
48
54
  /** Deep link to the originating message/thread, when derivable. */
49
55
  sourceLink?: ExternalSourceLink;
50
56
  }