@vellumai/assistant 0.11.8-staging.1 → 0.11.8-staging.2

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
@@ -64,9 +64,9 @@ it end to end:
64
64
  `guardian:<requestId>`, so the feed's replace-in-place merge keeps one
65
65
  item per request. Its typed `guardianRequest` field
66
66
  (`FeedItemGuardianRequestSchema` in `api/responses/home.ts`) is a read
67
- projection of the gateway row: intent, status, requester, tool, source
68
- reference, and the Slack-DM card deep links. It is never a delivery
69
- row of its own; clients derive every affordance from `status`.
67
+ projection of the gateway row: intent, status, requester, tool, and
68
+ source reference. It is never a delivery row of its own; clients
69
+ derive every affordance from `status`.
70
70
  - `withdrawGuardianRequestCards` rewrites it into a terminal receipt as
71
71
  one of the surfaces it settles, and its failure gates `complete` the
72
72
  same way a failed card edit does. The write-vs-resolve race converges
package/openapi.yaml CHANGED
@@ -13792,14 +13792,8 @@ paths:
13792
13792
  type: string
13793
13793
  sourceUrl:
13794
13794
  type: string
13795
- slackCardUrl:
13796
- type: string
13797
- slackCardAppUrl:
13798
- type: string
13799
13795
  decidedAction:
13800
13796
  type: string
13801
- decidedByLabel:
13802
- type: string
13803
13797
  decidedAt:
13804
13798
  type: string
13805
13799
  terminalReason:
@@ -14033,14 +14027,8 @@ paths:
14033
14027
  type: string
14034
14028
  sourceUrl:
14035
14029
  type: string
14036
- slackCardUrl:
14037
- type: string
14038
- slackCardAppUrl:
14039
- type: string
14040
14030
  decidedAction:
14041
14031
  type: string
14042
- decidedByLabel:
14043
- type: string
14044
14032
  decidedAt:
14045
14033
  type: string
14046
14034
  terminalReason:
@@ -14368,14 +14356,8 @@ paths:
14368
14356
  type: string
14369
14357
  sourceUrl:
14370
14358
  type: string
14371
- slackCardUrl:
14372
- type: string
14373
- slackCardAppUrl:
14374
- type: string
14375
14359
  decidedAction:
14376
14360
  type: string
14377
- decidedByLabel:
14378
- type: string
14379
14361
  decidedAt:
14380
14362
  type: string
14381
14363
  terminalReason:
@@ -23005,14 +22987,8 @@ paths:
23005
22987
  type: string
23006
22988
  sourceUrl:
23007
22989
  type: string
23008
- slackCardUrl:
23009
- type: string
23010
- slackCardAppUrl:
23011
- type: string
23012
22990
  decidedAction:
23013
22991
  type: string
23014
- decidedByLabel:
23015
- type: string
23016
22992
  decidedAt:
23017
22993
  type: string
23018
22994
  terminalReason:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.8-staging.1",
3
+ "version": "0.11.8-staging.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -90,7 +90,7 @@ describe("buildAccessRequestSeedContentBlocks", () => {
90
90
  });
91
91
  expect(data.metadata).toContainEqual({
92
92
  label: "Source",
93
- value: "Slack — #C01ABC",
93
+ value: "Slack · #C01ABC",
94
94
  });
95
95
  });
96
96
 
@@ -101,7 +101,7 @@ describe("buildAccessRequestSeedContentBlocks", () => {
101
101
  });
102
102
  expect(surfaceOf(blocks).data.metadata).toContainEqual({
103
103
  label: "Source",
104
- value: "Slack — Direct message",
104
+ value: "Slack · Direct message",
105
105
  });
106
106
  });
107
107
 
@@ -3602,6 +3602,8 @@ describe("AnthropicProvider — deprecated sampling params (temperature / top_p
3602
3602
  "claude-sonnet-5",
3603
3603
  "anthropic/claude-sonnet-5",
3604
3604
  "claude-fable-5",
3605
+ "claude-fable-5-1",
3606
+ "anthropic/claude-fable-5.1",
3605
3607
  ]) {
3606
3608
  test(`strips temperature, top_p, and top_k for ${model}`, async () => {
3607
3609
  const provider = new AnthropicProvider("sk-ant-test", model);
@@ -13,6 +13,7 @@
13
13
  import { beforeEach, describe, expect, mock, test } from "bun:test";
14
14
 
15
15
  import { CHANNEL_METADATA } from "../channels/types.js";
16
+ import { credentialKey } from "../security/credential-key.js";
16
17
 
17
18
  // ---------------------------------------------------------------------------
18
19
  // Mock state — flipped per-test
@@ -29,6 +30,11 @@ let mockEmailAddressesResponse: {
29
30
  body: { count: 0, results: [] },
30
31
  };
31
32
  let mockFetchThrows = false;
33
+ let mockSecureKeys: Record<string, string> = {};
34
+
35
+ mock.module("../security/secure-keys.js", () => ({
36
+ getSecureKeyAsync: async (key: string) => mockSecureKeys[key] ?? null,
37
+ }));
32
38
 
33
39
  mock.module("../platform/client.js", () => ({
34
40
  VellumPlatformClient: {
@@ -83,6 +89,7 @@ describe("channels/available", () => {
83
89
  body: { count: 0, results: [] },
84
90
  };
85
91
  mockFetchThrows = false;
92
+ mockSecureKeys = {};
86
93
  });
87
94
 
88
95
  test("base list only when no email address registered", async () => {
@@ -126,6 +133,14 @@ describe("channels/available", () => {
126
133
  expect(result.channels.map((c) => c.id)).toContain("email");
127
134
  });
128
135
 
136
+ test("appends email when a your-own provider credential is stored", async () => {
137
+ mockSecureKeys[credentialKey("resend", "api_key")] = "stored";
138
+
139
+ const result = (await handler({})) as HandlerResult;
140
+
141
+ expect(result.channels.map((c) => c.id)).toContain("email");
142
+ });
143
+
129
144
  test("base list only when platform returns non-ok", async () => {
130
145
  mockEmailAddressesResponse = {
131
146
  ok: false,
@@ -10,9 +10,11 @@ import { beforeEach, describe, expect, mock, test } from "bun:test";
10
10
  // ---------------------------------------------------------------------------
11
11
  // Mocks — must be set up before importing the service
12
12
  // ---------------------------------------------------------------------------
13
+ import type { RegisteredInboxState } from "../email/registered-inbox.js";
13
14
 
14
15
  let mockSecureKeys: Record<string, string>;
15
16
  let mockHasTwilioCredentials: boolean;
17
+ let mockRegisteredInbox: RegisteredInboxState;
16
18
 
17
19
  mock.module("../calls/twilio-rest.js", () => ({
18
20
  hasTwilioCredentials: () => mockHasTwilioCredentials,
@@ -25,6 +27,11 @@ mock.module("../security/secure-keys.js", () => ({
25
27
  getSecureKeyAsync: async (key: string) => mockSecureKeys[key] ?? null,
26
28
  }));
27
29
 
30
+ mock.module("../email/registered-inbox.js", () => ({
31
+ resolveRegisteredInbox: async () => mockRegisteredInbox,
32
+ invalidateRegisteredInboxCache: () => {},
33
+ }));
34
+
28
35
  // ---------------------------------------------------------------------------
29
36
  // Import under test
30
37
  // ---------------------------------------------------------------------------
@@ -45,6 +52,7 @@ describe("channel readiness routes — email and WhatsApp probes", () => {
45
52
  setConfig("email", {});
46
53
  mockSecureKeys = {};
47
54
  mockHasTwilioCredentials = false;
55
+ mockRegisteredInbox = { status: "none" };
48
56
  });
49
57
 
50
58
  // -------------------------------------------------------------------------
@@ -102,12 +110,15 @@ describe("channel readiness routes — email and WhatsApp probes", () => {
102
110
  expect(ingressCheck!.passed).toBe(false);
103
111
  });
104
112
 
105
- test("ready when all prerequisites are met (including inbox)", async () => {
113
+ test("ready when the platform reports a registered inbox", async () => {
106
114
  setConfig("ingress", {
107
115
  publicBaseUrl: "https://example.com",
108
116
  enabled: true,
109
117
  });
110
- setConfig("email", { address: "user@example.com" });
118
+ mockRegisteredInbox = {
119
+ status: "registered",
120
+ address: "assistant@example.com",
121
+ };
111
122
  const service = createReadinessService();
112
123
  const [snapshot] = await service.getReadiness("email", true);
113
124
 
@@ -115,7 +126,26 @@ describe("channel readiness routes — email and WhatsApp probes", () => {
115
126
  expect(snapshot.reasons).toHaveLength(0);
116
127
  });
117
128
 
118
- test("not ready when inbox is missing (remote check)", async () => {
129
+ test("platform registration decides the check; local config cannot", async () => {
130
+ // Registration lives on the platform. A stray local `email.address`
131
+ // config value (a key nothing writes) must not make an unregistered
132
+ // inbox pass.
133
+ setConfig("ingress", {
134
+ publicBaseUrl: "https://example.com",
135
+ enabled: true,
136
+ });
137
+ setConfig("email", { address: "stale@example.com" });
138
+ mockRegisteredInbox = { status: "none" };
139
+ const service = createReadinessService();
140
+ const [snapshot] = await service.getReadiness("email", true);
141
+
142
+ expect(snapshot.ready).toBe(false);
143
+ expect(snapshot.reasons.some((r) => r.code === "inbox_configured")).toBe(
144
+ true,
145
+ );
146
+ });
147
+
148
+ test("not ready when the platform reports no inbox (remote check)", async () => {
119
149
  setConfig("ingress", {
120
150
  publicBaseUrl: "https://example.com",
121
151
  enabled: true,
@@ -129,6 +159,69 @@ describe("channel readiness routes — email and WhatsApp probes", () => {
129
159
  );
130
160
  });
131
161
 
162
+ test("missing platform credentials fail the inbox check", async () => {
163
+ setConfig("ingress", {
164
+ publicBaseUrl: "https://example.com",
165
+ enabled: true,
166
+ });
167
+ mockRegisteredInbox = { status: "no_platform" };
168
+ const service = createReadinessService();
169
+ const [snapshot] = await service.getReadiness("email", true);
170
+
171
+ expect(snapshot.ready).toBe(false);
172
+ expect(snapshot.reasons.some((r) => r.code === "inbox_configured")).toBe(
173
+ true,
174
+ );
175
+ });
176
+
177
+ test("a your-own provider credential passes the inbox check", async () => {
178
+ setConfig("ingress", {
179
+ publicBaseUrl: "https://example.com",
180
+ enabled: true,
181
+ });
182
+ mockRegisteredInbox = { status: "none" };
183
+ mockSecureKeys[credentialKey("resend", "api_key")] = "stored";
184
+ const service = createReadinessService();
185
+ const [snapshot] = await service.getReadiness("email", true);
186
+
187
+ expect(snapshot.ready).toBe(true);
188
+ expect(snapshot.reasons).toHaveLength(0);
189
+ });
190
+
191
+ test("a your-own credential decides even when the platform is unreachable", async () => {
192
+ setConfig("ingress", {
193
+ publicBaseUrl: "https://example.com",
194
+ enabled: true,
195
+ });
196
+ mockRegisteredInbox = { status: "unavailable", detail: "HTTP 503" };
197
+ mockSecureKeys[credentialKey("mailgun", "api_key")] = "stored";
198
+ const service = createReadinessService();
199
+ const [snapshot] = await service.getReadiness("email", true);
200
+
201
+ expect(snapshot.ready).toBe(true);
202
+ });
203
+
204
+ test("unreachable platform is indeterminate, not a fault", async () => {
205
+ setConfig("ingress", {
206
+ publicBaseUrl: "https://example.com",
207
+ enabled: true,
208
+ });
209
+ mockRegisteredInbox = { status: "unavailable", detail: "HTTP 503" };
210
+ const service = createReadinessService();
211
+ const [snapshot] = await service.getReadiness("email", true);
212
+
213
+ // No positive confirmation, so the channel cannot report ready, but an
214
+ // unanswerable question is not a fault and must not be listed as one.
215
+ expect(snapshot.ready).toBe(false);
216
+ expect(snapshot.reasons.some((r) => r.code === "inbox_configured")).toBe(
217
+ false,
218
+ );
219
+ const remoteCheck = snapshot.remoteChecks?.find(
220
+ (c) => c.name === "inbox_configured",
221
+ );
222
+ expect(remoteCheck?.indeterminate).toBe(true);
223
+ });
224
+
132
225
  test("local-only readiness still passes without inbox check", async () => {
133
226
  setConfig("ingress", {
134
227
  publicBaseUrl: "https://example.com",
@@ -369,18 +369,33 @@ describe("ChannelReadinessService", () => {
369
369
  });
370
370
  });
371
371
 
372
- test("email readiness ignores platform connectivity", async () => {
373
- // Email passes `allowManagedCallbacks: false`, so the managed tiers are
374
- // not offered to it and only a real ingress URL counts.
372
+ test("email readiness accepts a platform-connected assistant with no ingress", async () => {
373
+ // Inbound email arrives through the platform callback route the gateway
374
+ // registers (`registerEmailCallbackRoute`, the same pattern as Telegram),
375
+ // so a platform-connected assistant with no public ingress URL still
376
+ // receives email and must not report missing ingress.
375
377
  mockPlatformConnected = true;
376
378
 
377
379
  const readiness = createReadinessService();
378
380
  const [snapshot] = await readiness.getReadiness("email");
379
381
 
382
+ expect(snapshot.localChecks).toContainEqual({
383
+ name: "ingress",
384
+ passed: true,
385
+ message: "Managed platform callback routing is configured",
386
+ });
387
+ });
388
+
389
+ test("email readiness reports missing ingress when nothing can route inbound", async () => {
390
+ mockPlatformConnected = false;
391
+
392
+ const readiness = createReadinessService();
393
+ const [snapshot] = await readiness.getReadiness("email");
394
+
380
395
  expect(snapshot.localChecks).toContainEqual({
381
396
  name: "ingress",
382
397
  passed: false,
383
- message: "Public ingress URL is not configured or disabled",
398
+ message: "No public ingress URL or managed callback route is configured",
384
399
  });
385
400
  });
386
401
 
@@ -1564,7 +1564,7 @@ describe("seedInferenceProfiles BYOK-mode default profiles", () => {
1564
1564
  );
1565
1565
  expect(effective.balanced?.label).toBe("Balanced");
1566
1566
  expect(effective["quality-optimized"]?.label).toBe("Quality");
1567
- expect(effective["cost-optimized"]?.label).toBe("Cost");
1567
+ expect(effective["cost-optimized"]?.label).toBe("Budget");
1568
1568
  expect("status" in (effective.balanced ?? {})).toBe(false);
1569
1569
  expect("status" in (effective["quality-optimized"] ?? {})).toBe(false);
1570
1570
  expect("status" in (effective["cost-optimized"] ?? {})).toBe(false);
@@ -1834,7 +1834,7 @@ describe("seedInferenceProfiles BYOK-mode default profiles", () => {
1834
1834
  const effective = getEffectiveProfiles(raw.llm.profiles);
1835
1835
  expect(effective.balanced?.label).toBe("Balanced");
1836
1836
  expect(effective["quality-optimized"]?.label).toBe("Quality");
1837
- expect(effective["cost-optimized"]?.label).toBe("Cost");
1837
+ expect(effective["cost-optimized"]?.label).toBe("Budget");
1838
1838
  });
1839
1839
 
1840
1840
  test("a managed key missing from profileOrder lands beside its siblings", () => {
@@ -238,6 +238,7 @@ describe("Invariant 2: no generic plaintext secret read API", () => {
238
238
  "providers/inference/connection-availability.ts", // shared (provider, connection) availability status (credential presence check only; value never leaves the helper)
239
239
  "plugin-api/resolve-credential.ts", // plugin-facing resolveCredential: reveal-equivalent plaintext read, scoped to the in-context plugin's own service
240
240
  "tools/credentials/store.ts", // shared credential write path (setSecureKeyAsync only; no reads) behind credentials/set and plugin-facing storeCredential
241
+ "email/byo-email-credential.ts", // BYO email provider configuration check (credential presence only; value never leaves the helper)
241
242
  ]);
242
243
 
243
244
  const thisDir = dirname(fileURLToPath(import.meta.url));
@@ -486,7 +487,6 @@ describe("Invariant 4: credentials only used for allowed purpose", () => {
486
487
  });
487
488
  });
488
489
 
489
-
490
490
  // ---------------------------------------------------------------------------
491
491
  // Invariant 6 — oauth2ClientSecret never in plaintext metadata
492
492
  // ---------------------------------------------------------------------------
@@ -1,11 +1,27 @@
1
1
  /**
2
2
  * Tests for the email invite adapter.
3
3
  *
4
- * Verifies that the email adapter resolves the assistant's email address
5
- * from workspace config and falls back to `undefined` when no address
6
- * is configured.
4
+ * Verifies that the email adapter resolves the assistant's address from the
5
+ * shared registered-inbox reader (the platform is the only writer of managed
6
+ * inbox registrations) and falls back to `undefined` when no address is
7
+ * registered or the platform cannot be asked.
7
8
  */
8
- import { beforeEach, describe, expect, test } from "bun:test";
9
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
10
+
11
+ import type { RegisteredInboxState } from "../email/registered-inbox.js";
12
+
13
+ let mockInboxState: RegisteredInboxState;
14
+ let mockResolveThrows: boolean;
15
+
16
+ mock.module("../email/registered-inbox.js", () => ({
17
+ resolveRegisteredInbox: async () => {
18
+ if (mockResolveThrows) {
19
+ throw new Error("resolver unavailable");
20
+ }
21
+ return mockInboxState;
22
+ },
23
+ invalidateRegisteredInboxCache: () => {},
24
+ }));
9
25
 
10
26
  import { resolveAdapterHandle } from "../runtime/channel-invite-transport.js";
11
27
  import { emailInviteAdapter } from "../runtime/channel-invite-transports/email.js";
@@ -17,30 +33,55 @@ import { setConfig } from "./helpers/set-config.js";
17
33
 
18
34
  describe("emailInviteAdapter", () => {
19
35
  beforeEach(() => {
20
- // The adapter reads the raw workspace config; reset the `email` key so
21
- // each test starts without a configured address.
22
- setConfig("email", {});
36
+ mockInboxState = { status: "none" };
37
+ mockResolveThrows = false;
23
38
  });
24
39
 
25
- test("returns configured email address via resolveChannelHandleAsync", async () => {
26
- setConfig("email", { address: "user@example.com" });
40
+ test("returns the registered address via resolveChannelHandleAsync", async () => {
41
+ mockInboxState = { status: "registered", address: "user@example.com" };
27
42
 
28
43
  const handle = await resolveAdapterHandle(emailInviteAdapter);
29
44
  expect(handle).toBe("user@example.com");
30
45
  });
31
46
 
32
- test("returns undefined when no address is configured", async () => {
47
+ test("returns undefined when no inbox is registered", async () => {
33
48
  const handle = await resolveAdapterHandle(emailInviteAdapter);
34
49
  expect(handle).toBeUndefined();
35
50
  });
36
51
 
37
- test("returns undefined when email.address is empty string", async () => {
38
- setConfig("email", { address: "" });
52
+ test("returns undefined when the platform is not connected", async () => {
53
+ mockInboxState = { status: "no_platform" };
39
54
 
40
55
  const handle = await resolveAdapterHandle(emailInviteAdapter);
41
56
  expect(handle).toBeUndefined();
42
57
  });
43
58
 
59
+ test("returns undefined when the platform cannot be asked", async () => {
60
+ mockInboxState = { status: "unavailable", detail: "HTTP 503" };
61
+
62
+ const handle = await resolveAdapterHandle(emailInviteAdapter);
63
+ expect(handle).toBeUndefined();
64
+ });
65
+
66
+ test("returns undefined when the resolver throws", async () => {
67
+ mockResolveThrows = true;
68
+
69
+ const handle = await resolveAdapterHandle(emailInviteAdapter);
70
+ expect(handle).toBeUndefined();
71
+ });
72
+
73
+ test("a local email.address config value cannot supply the handle", async () => {
74
+ // Registration lives on the platform; a stray config value (a key
75
+ // nothing writes) must not surface as the assistant's address.
76
+ setConfig("email", { address: "stale@example.com" });
77
+ mockInboxState = { status: "none" };
78
+
79
+ const handle = await resolveAdapterHandle(emailInviteAdapter);
80
+ expect(handle).toBeUndefined();
81
+
82
+ setConfig("email", {});
83
+ });
84
+
44
85
  test("adapter channel is email", () => {
45
86
  expect(emailInviteAdapter.channel).toBe("email");
46
87
  });
@@ -0,0 +1,60 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { createConversation } from "../persistence/conversation-crud.js";
4
+ import { initializeDb } from "../persistence/db-init.js";
5
+ import {
6
+ getBindingByConversation,
7
+ upsertBinding,
8
+ } from "../persistence/external-conversation-store.js";
9
+
10
+ await initializeDb();
11
+
12
+ async function boundConversation(): Promise<string> {
13
+ const conversation = await createConversation({ title: "t" });
14
+ upsertBinding({
15
+ conversationId: conversation.id,
16
+ sourceChannel: "slack",
17
+ externalChatId: "C0123CHANNEL",
18
+ externalChatName: "user-feedback",
19
+ });
20
+ return conversation.id;
21
+ }
22
+
23
+ describe("upsertBinding chat-name preservation", () => {
24
+ test("a nameless upsert for the same chat keeps the stored name", async () => {
25
+ const conversationId = await boundConversation();
26
+ upsertBinding({
27
+ conversationId,
28
+ sourceChannel: "slack",
29
+ externalChatId: "C0123CHANNEL",
30
+ });
31
+ expect(getBindingByConversation(conversationId)?.externalChatName).toBe(
32
+ "user-feedback",
33
+ );
34
+ });
35
+
36
+ test("a move to a nameless chat clears the stored name", async () => {
37
+ const conversationId = await boundConversation();
38
+ upsertBinding({
39
+ conversationId,
40
+ sourceChannel: "slack",
41
+ externalChatId: "D0MOVEDHERE",
42
+ });
43
+ const binding = getBindingByConversation(conversationId);
44
+ expect(binding?.externalChatId).toBe("D0MOVEDHERE");
45
+ expect(binding?.externalChatName ?? null).toBeNull();
46
+ });
47
+
48
+ test("a move that reports a name takes the new name", async () => {
49
+ const conversationId = await boundConversation();
50
+ upsertBinding({
51
+ conversationId,
52
+ sourceChannel: "slack",
53
+ externalChatId: "C0ELSEWHERE",
54
+ externalChatName: "new-home",
55
+ });
56
+ expect(getBindingByConversation(conversationId)?.externalChatName).toBe(
57
+ "new-home",
58
+ );
59
+ });
60
+ });
@@ -147,6 +147,34 @@ describe("guardian-verify-setup skill: proactive auto-check polling", () => {
147
147
  });
148
148
  });
149
149
 
150
+ describe("guardian-verify-setup skill: Discord DM code wording", () => {
151
+ /**
152
+ * A plain DM containing the code completes verification; Discord's reply
153
+ * affordance is not required, and instructions that say "reply" made a
154
+ * live-QA user hesitate over whether a plain message would count.
155
+ */
156
+ const discordBullets = (body: string) =>
157
+ body.split("\n").filter((line) => /^\s*-\s+\*\*Discord\*\*/.test(line));
158
+
159
+ for (const [step, from, to] of [
160
+ ["Step 3", "## Step 3", "## Step 4"],
161
+ ["Step 4", "## Step 4", "## Step 5"],
162
+ ] as const) {
163
+ test(`${step}'s Discord bullets say to send the code, not reply with it`, () => {
164
+ const bullets = discordBullets(section(from, to));
165
+ expect(bullets.length).toBeGreaterThanOrEqual(1);
166
+ for (const bullet of bullets) {
167
+ expect(bullet).toContain("send that 6-digit code in this DM");
168
+ // The instruction forms are what misled: "reply with the code" /
169
+ // "reply to the DM". Prose *about* the reply feature ("reply feature
170
+ // is not required") is the clarification and stays.
171
+ expect(bullet.toLowerCase()).not.toContain("reply with");
172
+ expect(bullet.toLowerCase()).not.toContain("reply to the dm");
173
+ }
174
+ });
175
+ }
176
+ });
177
+
150
178
  describe("guardian-verify-setup skill: channel coverage", () => {
151
179
  test("every channel the CLI accepts is offered in Step 1", () => {
152
180
  const step1 = section("## Step 1", "## Step 2");
@@ -264,7 +264,7 @@ describe("PUT /v1/config/llm/profiles/:name — managed profile guard", () => {
264
264
  });
265
265
 
266
266
  test("PUT on the code-owned latency profile is rejected, status re-enable included", async () => {
267
- // Speed fronts live voice, so it takes no writes at all: the re-enable
267
+ // Fast fronts live voice, so it takes no writes at all: the re-enable
268
268
  // escape hatch the other managed profiles keep would persist a stub that
269
269
  // never governs what the name resolves to.
270
270
  seedRawConfig({ llm: { profiles: {} } });
@@ -20,7 +20,7 @@ describe("model intents", () => {
20
20
  "claude-haiku-4-5-20251001",
21
21
  );
22
22
  expect(resolveModelIntent("anthropic", "quality-optimized")).toBe(
23
- "claude-fable-5",
23
+ "claude-fable-5-1",
24
24
  );
25
25
  expect(resolveModelIntent("anthropic", "vision-optimized")).toBe(
26
26
  "claude-opus-4-6",
@@ -47,7 +47,7 @@ describe("model intents", () => {
47
47
  "anthropic/claude-haiku-4.5",
48
48
  );
49
49
  expect(resolveModelIntent("vercel-ai-gateway", "quality-optimized")).toBe(
50
- "anthropic/claude-fable-5",
50
+ "anthropic/claude-fable-5.1",
51
51
  );
52
52
  expect(resolveModelIntent("vercel-ai-gateway", "vision-optimized")).toBe(
53
53
  "anthropic/claude-opus-4.6",
@@ -450,6 +450,19 @@ describe("resolvePricingForUsage", () => {
450
450
  expect(result.estimatedCostUsd).toBeCloseTo(57.4, 10);
451
451
  });
452
452
 
453
+ test("bills Fable 5.1 cache reads at the catalog rate", () => {
454
+ const result = resolvePricingForUsage("anthropic", "claude-fable-5-1", {
455
+ directInputTokens: 0,
456
+ outputTokens: 0,
457
+ cacheCreationInputTokens: 0,
458
+ cacheReadInputTokens: 1_000_000,
459
+ anthropicCacheCreation: null,
460
+ });
461
+
462
+ expect(result.pricingStatus).toBe("priced");
463
+ expect(result.estimatedCostUsd).toBeCloseTo(0.25, 10);
464
+ });
465
+
453
466
  test("returns unpriced with null cost for unknown provider", () => {
454
467
  const usage: PricingUsage = {
455
468
  directInputTokens: 10,
@@ -214,7 +214,7 @@ describe("buildToolApprovalSeedContentBlocks", () => {
214
214
  );
215
215
  expect(surface.data.metadata).toContainEqual({
216
216
  label: "Source",
217
- value: "Slack — Direct message",
217
+ value: "Slack · Direct message",
218
218
  });
219
219
  });
220
220
 
@@ -232,7 +232,20 @@ describe("buildToolApprovalSeedContentBlocks", () => {
232
232
  );
233
233
  expect(surface.data.metadata).toContainEqual({
234
234
  label: "Source",
235
- value: "Slack — #C01ABC",
235
+ value: "Slack · #C01ABC",
236
+ });
237
+ });
238
+
239
+ test("Slack channel source prefers the captured channel name", () => {
240
+ const payload = {
241
+ ...toolApprovalPayload,
242
+ sourceChatId: "C01ABC",
243
+ sourceChatName: "user-feedback",
244
+ };
245
+ const surface = surfaceBlock(buildToolApprovalSeedContentBlocks(payload)!);
246
+ expect(surface.data.metadata).toContainEqual({
247
+ label: "Source",
248
+ value: "Slack · #user-feedback",
236
249
  });
237
250
  });
238
251
 
@@ -245,7 +258,7 @@ describe("buildToolApprovalSeedContentBlocks", () => {
245
258
  expect(surface.data.body).not.toContain("[View message]");
246
259
  expect(surface.data.metadata).toContainEqual({
247
260
  label: "Source",
248
- value: "Slack — Direct message",
261
+ value: "Slack · Direct message",
249
262
  });
250
263
  });
251
264