@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.
- package/docs/guardian-request-flow.md +3 -3
- package/openapi.yaml +0 -24
- package/package.json +1 -1
- package/src/__tests__/access-request-seed-content-blocks.test.ts +2 -2
- package/src/__tests__/anthropic-provider.test.ts +2 -0
- package/src/__tests__/channel-availability-routes.test.ts +15 -0
- package/src/__tests__/channel-readiness-routes.test.ts +96 -3
- package/src/__tests__/channel-readiness-service.test.ts +19 -4
- package/src/__tests__/config-loader-backfill.test.ts +2 -2
- package/src/__tests__/credential-security-invariants.test.ts +1 -1
- package/src/__tests__/email-invite-adapter.test.ts +53 -12
- package/src/__tests__/external-binding-chat-name.test.ts +60 -0
- package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +28 -0
- package/src/__tests__/managed-profile-guard.test.ts +1 -1
- package/src/__tests__/model-intents.test.ts +2 -2
- package/src/__tests__/pricing.test.ts +13 -0
- package/src/__tests__/tool-approval-seed-content-blocks.test.ts +16 -3
- package/src/api/responses/home.ts +0 -6
- package/src/config/__tests__/default-profile-catalog.test.ts +1 -1
- package/src/config/call-site-defaults.ts +1 -1
- package/src/config/default-profile-catalog.ts +12 -12
- package/src/config/default-profile-names.ts +2 -2
- package/src/email/byo-email-credential.test.ts +39 -0
- package/src/email/byo-email-credential.ts +76 -0
- package/src/email/registered-inbox.test.ts +272 -0
- package/src/email/registered-inbox.ts +169 -0
- package/src/messaging/providers/slack/approval-source.test.ts +70 -0
- package/src/messaging/providers/slack/approval-source.ts +18 -5
- package/src/notifications/__tests__/guardian-feed-projection.test.ts +13 -14
- package/src/notifications/approval-card-data.ts +13 -5
- package/src/notifications/emit-signal.ts +5 -8
- package/src/notifications/guardian-feed-projection.ts +9 -74
- package/src/notifications/guardian-question-mode.ts +33 -1
- package/src/notifications/home-feed-side-effect.ts +0 -4
- package/src/persistence/external-conversation-store.ts +5 -1
- package/src/providers/anthropic/client.ts +1 -1
- package/src/providers/model-catalog.ts +54 -0
- package/src/providers/model-intents.ts +3 -3
- package/src/runtime/approval-source-link.ts +6 -0
- package/src/runtime/channel-invite-transports/email.ts +10 -9
- package/src/runtime/channel-readiness-service.ts +83 -23
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +1 -1
- package/src/runtime/routes/channel-availability-routes.ts +18 -31
- package/src/runtime/routes/credential-routes.ts +3 -0
- package/src/runtime/routes/email-routes.ts +35 -47
- package/src/runtime/verification-outbound-actions.ts +8 -12
- package/src/tools/credentials/store.ts +3 -0
- 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,
|
|
68
|
-
reference
|
|
69
|
-
|
|
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
|
@@ -90,7 +90,7 @@ describe("buildAccessRequestSeedContentBlocks", () => {
|
|
|
90
90
|
});
|
|
91
91
|
expect(data.metadata).toContainEqual({
|
|
92
92
|
label: "Source",
|
|
93
|
-
value: "Slack
|
|
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
|
|
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
|
|
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
|
-
|
|
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("
|
|
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
|
|
373
|
-
//
|
|
374
|
-
//
|
|
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: "
|
|
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("
|
|
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("
|
|
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
|
|
5
|
-
*
|
|
6
|
-
* is
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
setConfig("email", {});
|
|
36
|
+
mockInboxState = { status: "none" };
|
|
37
|
+
mockResolveThrows = false;
|
|
23
38
|
});
|
|
24
39
|
|
|
25
|
-
test("returns
|
|
26
|
-
|
|
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
|
|
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
|
|
38
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
261
|
+
value: "Slack · Direct message",
|
|
249
262
|
});
|
|
250
263
|
});
|
|
251
264
|
|