@vellumai/vellum-gateway 0.3.3 → 0.3.5

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/README.md CHANGED
@@ -138,7 +138,7 @@ These fields are forwarded to the runtime in the `/channels/inbound` payload alo
138
138
 
139
139
  **Normalization constraints:** Only DM-only (`private` chat type) callback queries are processed. Group and channel callbacks are dropped and acknowledged with `answerCallbackQuery` so the Telegram button spinner clears. Callback queries with no `data` field or no associated `message` are also dropped.
140
140
 
141
- **Stale callback blocking:** When the runtime receives `callbackData` that does not match any pending approval (e.g., a button from an old prompt), it returns `stale_ignored` and does not process the payload as a regular message. This is enforced regardless of whether the callback has non-empty content. The gateway acknowledges all callback queries via `answerCallbackQuery` to clear the button spinner.
141
+ **Stale callback blocking:** When the runtime receives `callbackData` that does not match any pending approval (e.g., a button from an old prompt), it returns `stale_ignored` and does not process the payload as a regular message. This is enforced regardless of whether the callback has non-empty content. The gateway sends a best-effort `answerCallbackQuery` acknowledgment for normalized callback updates (including stale, rejected, and forward-failure paths) so the button spinner clears promptly. Transient forwarding failures may still return `500` so Telegram retries update delivery.
142
142
 
143
143
  ## Approval Buttons and Inline Keyboard
144
144
 
@@ -370,7 +370,7 @@ See [`benchmarking/gateway/README.md`](../benchmarking/gateway/README.md) for lo
370
370
  | Symptom | Cause | Resolution |
371
371
  |---------|-------|------------|
372
372
  | `/guardian_verify` command gets no reply | The verification message did not reach the runtime, or the challenge expired | Ensure the gateway is running, the bot token is valid, and the Telegram webhook is registered. Challenges expire after 10 minutes -- generate a new one via the desktop UI. |
373
- | Non-guardian actions auto-denied with "no guardian configured" | No guardian binding exists for the channel and `CHANNEL_APPROVALS_ENABLED=true` on the runtime. The system is fail-closed when guardian enforcement is active. | Set up a guardian by running the verification flow from the desktop UI, and ensure `CHANNEL_APPROVALS_ENABLED=true` is set on the runtime. |
373
+ | Non-guardian actions auto-denied with "no guardian configured" | No guardian binding exists for the channel. The runtime is fail-closed for unverified channels. | Set up a guardian by running the verification flow from the desktop UI. |
374
374
  | Approval prompt not delivered to guardian | The `replyCallbackUrl` may be unreachable, or the guardian's chat ID is stale | Verify `GATEWAY_INTERNAL_BASE_URL` is set correctly (especially in containerized deployments). Re-verify the guardian if the chat ID has changed. |
375
375
  | Guardian approval expired | The 30-minute TTL elapsed without a decision. A proactive sweep (every 60s) auto-denied the approval and notified both the requester and guardian. | The non-guardian user must re-trigger the action. |
376
376
  | "Only the verified guardian can approve or deny" | A non-guardian sender attempted to respond to a guardian approval prompt | Only the guardian whose `externalUserId` matches the approval request can approve or deny. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "bun run --watch src/index.ts",
@@ -1,5 +1,7 @@
1
- import { writeFileSync, unlinkSync } from "node:fs";
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync, unlinkSync } from "node:fs";
2
2
  import { describe, test, expect } from "bun:test";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
3
5
  import { loadConfig } from "../config.js";
4
6
 
5
7
  const BASE_ENV = {
@@ -31,6 +33,7 @@ function withEnv(overrides: Record<string, string | undefined>, fn: () => void)
31
33
  "GATEWAY_MAX_ATTACHMENT_CONCURRENCY",
32
34
  "GATEWAY_TELEGRAM_DELIVER_AUTH_BYPASS",
33
35
  "VELLUM_HTTP_TOKEN_PATH",
36
+ "BASE_DATA_DIR",
34
37
  ];
35
38
 
36
39
  for (const key of allKeys) {
@@ -259,3 +262,35 @@ describe("config: runtime bearer token", () => {
259
262
  );
260
263
  });
261
264
  });
265
+
266
+ describe("config: twilio assistant phone number mapping", () => {
267
+ test("loads assistantPhoneNumbers from workspace config on startup", () => {
268
+ const testBaseDir = mkdtempSync(join(tmpdir(), "gateway-config-test-"));
269
+ try {
270
+ const workspaceDir = join(testBaseDir, ".vellum", "workspace");
271
+ mkdirSync(workspaceDir, { recursive: true });
272
+ writeFileSync(
273
+ join(workspaceDir, "config.json"),
274
+ JSON.stringify({
275
+ sms: {
276
+ phoneNumber: "+15550001111",
277
+ assistantPhoneNumbers: {
278
+ "asst-alpha": "+15550002222",
279
+ "asst-beta": "+15550003333",
280
+ },
281
+ },
282
+ }),
283
+ );
284
+
285
+ withEnv({ BASE_DATA_DIR: testBaseDir }, () => {
286
+ const config = loadConfig();
287
+ expect(config.assistantPhoneNumbers).toEqual({
288
+ "asst-alpha": "+15550002222",
289
+ "asst-beta": "+15550003333",
290
+ });
291
+ });
292
+ } finally {
293
+ rmSync(testBaseDir, { recursive: true, force: true });
294
+ }
295
+ });
296
+ });
@@ -1,4 +1,4 @@
1
- import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from "bun:test";
1
+ import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test";
2
2
  import { mkdirSync, writeFileSync, rmSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
@@ -35,10 +35,7 @@ mock.module("node:child_process", () => {
35
35
 
36
36
  import {
37
37
  readTelegramCredentials,
38
- readCredential,
39
38
  readKeychainCredential,
40
- getMetadataPath,
41
- getRootDir,
42
39
  } from "../credential-reader.js";
43
40
 
44
41
  // ---------------------------------------------------------------------------
@@ -62,21 +62,48 @@ describe("DedupCache", () => {
62
62
  expect(cache.size).toBe(2);
63
63
  });
64
64
 
65
- test("reserve returns true for unseen update_id and creates processing entry", () => {
66
- expect(cache.reserve(50)).toBe(true);
65
+ test("reserve returns 'reserved' for unseen update_id and creates processing entry", () => {
66
+ expect(cache.reserve(50)).toBe("reserved");
67
67
  expect(cache.size).toBe(1);
68
68
  // get() returns undefined for processing entries (not yet finalized)
69
69
  expect(cache.get(50)).toBeUndefined();
70
70
  });
71
71
 
72
- test("reserve returns false for already-reserved update_id", () => {
72
+ test("reserve returns 'duplicate' for already-reserved update_id", () => {
73
73
  cache.reserve(60);
74
- expect(cache.reserve(60)).toBe(false);
74
+ expect(cache.reserve(60)).toBe("duplicate");
75
75
  });
76
76
 
77
- test("reserve returns false for already-cached update_id", () => {
77
+ test("reserve returns 'duplicate' for already-cached update_id still in cache", () => {
78
+ // set() advances the high-water mark, but the cache entry is checked
79
+ // first — since it's still within TTL, reserve() returns "duplicate"
78
80
  cache.set(70, '{"done":true}', 200);
79
- expect(cache.reserve(70)).toBe(false);
81
+ expect(cache.reserve(70)).toBe("duplicate");
82
+ });
83
+
84
+ test("reserve returns 'already_processed' for finalized update_id after TTL expires", () => {
85
+ const shortCache = new DedupCache(1, 100);
86
+ shortCache.set(70, '{"done":true}', 200);
87
+
88
+ // Wait for the TTL entry to expire
89
+ const start = Date.now();
90
+ while (Date.now() - start < 5) {
91
+ // busy-wait
92
+ }
93
+
94
+ // Cache entry expired, but high-water mark still blocks replay
95
+ expect(shortCache.reserve(70)).toBe("already_processed");
96
+ });
97
+
98
+ test("reserve returns 'duplicate' for in-cache entry above high-water mark", () => {
99
+ // reserve() without set() doesn't advance the high-water mark,
100
+ // so a second reserve() for the same ID returns "duplicate"
101
+ cache.reserve(71);
102
+ cache.set(71, '{"done":true}', 200);
103
+ // Now reserve a higher ID (which won't be finalized)
104
+ cache.reserve(72);
105
+ // 72 is above high-water mark (71) but already in cache
106
+ expect(cache.reserve(72)).toBe("duplicate");
80
107
  });
81
108
 
82
109
  test("get returns undefined while entry is still processing", () => {
@@ -99,12 +126,12 @@ describe("DedupCache", () => {
99
126
 
100
127
  test("unreserve allows re-reservation after processing failure", () => {
101
128
  cache.reserve(85);
102
- expect(cache.reserve(85)).toBe(false);
129
+ expect(cache.reserve(85)).toBe("duplicate");
103
130
  // Simulate processing failure — unreserve so Telegram can retry
104
131
  cache.unreserve(85);
105
132
  expect(cache.size).toBe(0);
106
133
  // Now re-reserve should succeed
107
- expect(cache.reserve(85)).toBe(true);
134
+ expect(cache.reserve(85)).toBe("reserved");
108
135
  });
109
136
 
110
137
  test("unreserve does not remove finalized entries", () => {
@@ -123,7 +150,65 @@ describe("DedupCache", () => {
123
150
  // busy-wait for expiry
124
151
  }
125
152
 
126
- expect(shortCache.reserve(90)).toBe(true);
153
+ expect(shortCache.reserve(90)).toBe("reserved");
154
+ });
155
+
156
+ test("high-water mark rejects update_ids at or below the max finalized", () => {
157
+ cache.reserve(100);
158
+ cache.set(100, '{"ok":true}', 200);
159
+
160
+ // update_id below the high-water mark is permanently rejected
161
+ expect(cache.reserve(99)).toBe("already_processed");
162
+ // same update_id is still in cache — returns "duplicate" (cache checked first)
163
+ expect(cache.reserve(100)).toBe("duplicate");
164
+ // update_id above the high-water mark is accepted
165
+ expect(cache.reserve(101)).toBe("reserved");
166
+ });
167
+
168
+ test("high-water mark persists after cache TTL expires", () => {
169
+ const shortCache = new DedupCache(1, 100);
170
+ shortCache.reserve(50);
171
+ shortCache.set(50, '{"ok":true}', 200);
172
+
173
+ // Wait for the TTL entry to expire
174
+ const start = Date.now();
175
+ while (Date.now() - start < 5) {
176
+ // busy-wait
177
+ }
178
+
179
+ // TTL entry is gone, but high-water mark still blocks replay
180
+ expect(shortCache.get(50)).toBeUndefined();
181
+ expect(shortCache.reserve(50)).toBe("already_processed");
182
+ expect(shortCache.reserve(49)).toBe("already_processed");
183
+ // Higher ID still works
184
+ expect(shortCache.reserve(51)).toBe("reserved");
185
+ });
186
+
187
+ test("high-water mark advances to the max across non-sequential sets", () => {
188
+ cache.set(200, "a", 200);
189
+ cache.set(100, "b", 200);
190
+ cache.set(150, "c", 200);
191
+
192
+ // High-water mark should be 200 (the max), not 150 (the last set)
193
+ expect(cache.reserve(199)).toBe("already_processed");
194
+ // 200 is still in the cache — returns "duplicate" (cache checked first)
195
+ expect(cache.reserve(200)).toBe("duplicate");
196
+ expect(cache.reserve(201)).toBe("reserved");
197
+ });
198
+
199
+ test("in-flight entry returns 'duplicate' even when high-water mark has advanced past it", () => {
200
+ // Simulate concurrent processing: reserve 101, then 102 finalizes first
201
+ cache.reserve(101);
202
+ cache.reserve(102);
203
+ cache.set(102, '{"ok":true}', 200); // advances high-water mark to 102
204
+
205
+ // 101 is still in-flight (reserved but not finalized). A retry for 101
206
+ // should return "duplicate" (not "already_processed") so the caller
207
+ // returns 503 and Telegram retries, rather than returning 200 which
208
+ // would suppress retries if the original 101 handler fails.
209
+ expect(cache.reserve(101)).toBe("duplicate");
210
+ // get() still returns undefined because 101 is in processing state
211
+ expect(cache.get(101)).toBeUndefined();
127
212
  });
128
213
  });
129
214
 
package/src/config.ts CHANGED
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { getLogger, type LogFileConfig } from "./logger.js";
5
+ import { getRootDir, readKeychainCredential, readCredential, readTwilioCredentials } from "./credential-reader.js";
5
6
 
6
7
  const log = getLogger("config");
7
8
 
@@ -247,9 +248,40 @@ export function loadConfig(): GatewayConfig {
247
248
  );
248
249
  }
249
250
 
250
- const twilioAuthToken = process.env.TWILIO_AUTH_TOKEN || undefined;
251
- const twilioAccountSid = process.env.TWILIO_ACCOUNT_SID || undefined;
252
- const twilioPhoneNumber = process.env.TWILIO_PHONE_NUMBER || undefined;
251
+ // Twilio credentials: env var > credential store (keychain / encrypted file)
252
+ const twilioCreds = readTwilioCredentials();
253
+ const twilioAuthToken = process.env.TWILIO_AUTH_TOKEN || twilioCreds?.authToken || undefined;
254
+ const twilioAccountSid = process.env.TWILIO_ACCOUNT_SID || twilioCreds?.accountSid || undefined;
255
+
256
+ // Phone number: env var > config file sms.phoneNumber > credential store
257
+ let twilioPhoneNumber: string | undefined = process.env.TWILIO_PHONE_NUMBER || undefined;
258
+ let assistantPhoneNumbers: Record<string, string> | undefined;
259
+ try {
260
+ const cfgPath = join(getRootDir(), "workspace", "config.json");
261
+ const raw = readFileSync(cfgPath, "utf-8");
262
+ const data = JSON.parse(raw);
263
+ if (!twilioPhoneNumber && data?.sms?.phoneNumber && typeof data.sms.phoneNumber === "string") {
264
+ twilioPhoneNumber = data.sms.phoneNumber;
265
+ }
266
+ const rawMapping = data?.sms?.assistantPhoneNumbers;
267
+ if (rawMapping && typeof rawMapping === "object" && !Array.isArray(rawMapping)) {
268
+ const normalized: Record<string, string> = {};
269
+ for (const [assistantId, phoneNumber] of Object.entries(rawMapping as Record<string, unknown>)) {
270
+ if (typeof phoneNumber === "string" && phoneNumber.trim().length > 0) {
271
+ normalized[assistantId] = phoneNumber;
272
+ }
273
+ }
274
+ assistantPhoneNumbers = normalized;
275
+ }
276
+ } catch {
277
+ // config file may not exist yet
278
+ }
279
+ if (!twilioPhoneNumber) {
280
+ twilioPhoneNumber =
281
+ readKeychainCredential("credential:twilio:phone_number")
282
+ || readCredential("credential:twilio:phone_number")
283
+ || undefined;
284
+ }
253
285
 
254
286
  const smsDeliverAuthBypassRaw = process.env.GATEWAY_SMS_DELIVER_AUTH_BYPASS;
255
287
  if (
@@ -292,6 +324,7 @@ export function loadConfig(): GatewayConfig {
292
324
  hasTwilioAuthToken: !!twilioAuthToken,
293
325
  hasTwilioAccountSid: !!twilioAccountSid,
294
326
  hasTwilioPhoneNumber: !!twilioPhoneNumber,
327
+ assistantPhoneNumberCount: assistantPhoneNumbers ? Object.keys(assistantPhoneNumbers).length : 0,
295
328
  smsDeliverAuthBypass,
296
329
  ingressPublicBaseUrl,
297
330
  },
@@ -327,6 +360,7 @@ export function loadConfig(): GatewayConfig {
327
360
  twilioAuthToken,
328
361
  twilioAccountSid,
329
362
  twilioPhoneNumber,
363
+ assistantPhoneNumbers,
330
364
  smsDeliverAuthBypass,
331
365
  ingressPublicBaseUrl,
332
366
  unmappedPolicy,
@@ -2,6 +2,8 @@ import { getLogger } from "./logger.js";
2
2
 
3
3
  const log = getLogger("dedup-cache");
4
4
 
5
+ export type ReserveResult = "reserved" | "duplicate" | "already_processed";
6
+
5
7
  interface CacheEntry {
6
8
  body: string;
7
9
  status: number;
@@ -19,6 +21,14 @@ export class DedupCache {
19
21
  private cache = new Map<number, CacheEntry>();
20
22
  private readonly ttlMs: number;
21
23
  private readonly maxSize: number;
24
+ /**
25
+ * Monotonic high-water mark: the highest update_id that has been fully
26
+ * processed (finalized via set()). Any update_id at or below this value
27
+ * is rejected permanently, even after the TTL cache evicts the entry.
28
+ * This closes the replay window that existed when entries expired from
29
+ * the TTL cache.
30
+ */
31
+ private highWaterMark = -Infinity;
22
32
 
23
33
  constructor(ttlMs = 5 * 60_000, maxSize = 10_000) {
24
34
  this.ttlMs = ttlMs;
@@ -45,21 +55,33 @@ export class DedupCache {
45
55
  * Checks whether this update_id is already reserved or cached.
46
56
  * If not, immediately reserves it with a "processing" sentinel so that
47
57
  * concurrent retries are blocked before the handler finishes.
48
- * Returns true if a new reservation was created (caller should proceed),
49
- * false if the update_id was already present (caller should short-circuit).
50
58
  *
51
- * While the entry is in the "processing" state, {@link get} returns
52
- * `undefined` so callers can distinguish an in-flight request from a
53
- * finalized cache hit and respond accordingly (e.g. 503 Retry-After).
59
+ * Returns:
60
+ * - `'reserved'` new reservation created, caller should proceed
61
+ * - `'duplicate'` update_id is already in the cache (in-flight or finalized)
62
+ * - `'already_processed'` — update_id is at or below the high-water mark
63
+ * (fully processed, TTL entry may have expired)
54
64
  */
55
- reserve(updateId: number): boolean {
65
+ reserve(updateId: number): ReserveResult {
66
+ // Check existing cache entries FIRST — an active (non-expired) entry
67
+ // takes priority over the high-water mark. This prevents a race where
68
+ // a higher update_id finalizes first (advancing the high-water mark)
69
+ // while a lower update_id is still in-flight: without this ordering,
70
+ // the in-flight entry would be reported as "already_processed" instead
71
+ // of "duplicate", causing the caller to return 200 and suppress retries.
56
72
  const existing = this.cache.get(updateId);
57
73
  if (existing && Date.now() <= existing.expiresAt) {
58
- return false;
74
+ return "duplicate";
59
75
  }
60
76
  // Clean up expired entry if present
61
77
  if (existing) this.cache.delete(updateId);
62
78
 
79
+ // Reject any update_id at or below the high-water mark — these have
80
+ // already been fully processed and are replay attempts.
81
+ if (updateId <= this.highWaterMark) {
82
+ return "already_processed";
83
+ }
84
+
63
85
  // Evict if at capacity
64
86
  if (this.cache.size >= this.maxSize) {
65
87
  this.evictExpired();
@@ -77,7 +99,7 @@ export class DedupCache {
77
99
  expiresAt: Date.now() + this.ttlMs,
78
100
  processing: true,
79
101
  });
80
- return true;
102
+ return "reserved";
81
103
  }
82
104
 
83
105
  /** Remove a reserved entry so Telegram can retry. */
@@ -88,8 +110,14 @@ export class DedupCache {
88
110
  }
89
111
  }
90
112
 
91
- /** Store a response for the given update_id. */
113
+ /** Store a response for the given update_id and advance the high-water mark. */
92
114
  set(updateId: number, body: string, status: number): void {
115
+ // Advance monotonic high-water mark so this update_id (and all lower
116
+ // ones) are permanently rejected even after the TTL cache evicts them.
117
+ if (updateId > this.highWaterMark) {
118
+ this.highWaterMark = updateId;
119
+ }
120
+
93
121
  // Evict expired entries if we're at capacity
94
122
  if (this.cache.size >= this.maxSize) {
95
123
  this.evictExpired();
@@ -118,14 +118,21 @@ export function createRuntimeProxyHandler(config: GatewayConfig) {
118
118
  controller.abort(new DOMException("The operation was aborted due to timeout", "TimeoutError"));
119
119
  }, config.runtimeTimeoutMs);
120
120
 
121
+ // Buffer the request body instead of streaming req.body to avoid
122
+ // Content-Length mismatches when Bun re-sends a ReadableStream, which
123
+ // can cause the upstream to reject the request with a bare 400.
124
+ const hasBody = req.method !== "GET" && req.method !== "HEAD";
125
+ const bodyBuffer = hasBody ? await req.arrayBuffer() : null;
126
+ if (bodyBuffer !== null) {
127
+ reqHeaders.set("content-length", String(bodyBuffer.byteLength));
128
+ }
129
+
121
130
  let response: Response;
122
131
  try {
123
132
  response = await fetch(upstream, {
124
133
  method: req.method,
125
134
  headers: reqHeaders,
126
- body: req.body,
127
- // @ts-expect-error Bun supports duplex on Request
128
- duplex: "half",
135
+ body: bodyBuffer,
129
136
  signal: controller.signal,
130
137
  });
131
138
  clearTimeout(timeoutId);
@@ -64,9 +64,9 @@ afterEach(() => {
64
64
  globalThis.fetch = originalFetch;
65
65
  });
66
66
 
67
- function mockTwilioApi() {
67
+ function mockTwilioApi(overrides?: Record<string, unknown>) {
68
68
  globalThis.fetch = mock(async () => {
69
- return new Response(JSON.stringify({ sid: "SM-sent" }), {
69
+ return new Response(JSON.stringify({ sid: "SM-sent", status: "queued", error_code: null, error_message: null, ...overrides }), {
70
70
  status: 201,
71
71
  headers: { "content-type": "application/json" },
72
72
  });
@@ -271,4 +271,156 @@ describe("/deliver/sms", () => {
271
271
  expect(sentParams.get("To")).toBe("+15559876543");
272
272
  expect(sentParams.get("Body")).toBe("Test SMS body");
273
273
  });
274
+
275
+ it("uses assistant-specific From number when assistantId mapping exists", async () => {
276
+ const fetchCalls: Array<{ url: string; init: RequestInit }> = [];
277
+ globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
278
+ const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url;
279
+ fetchCalls.push({ url: urlStr, init: init ?? {} });
280
+ return new Response(JSON.stringify({ sid: "SM-sent" }), {
281
+ status: 201,
282
+ headers: { "content-type": "application/json" },
283
+ });
284
+ }) as unknown as typeof fetch;
285
+
286
+ const handler = createSmsDeliverHandler(
287
+ makeConfig({
288
+ runtimeProxyBearerToken: undefined,
289
+ smsDeliverAuthBypass: true,
290
+ assistantPhoneNumbers: { "ast-alpha": "+15550001111" },
291
+ }),
292
+ );
293
+ const req = makeRequest({
294
+ to: "+15559876543",
295
+ text: "assistant scoped",
296
+ assistantId: "ast-alpha",
297
+ });
298
+
299
+ const res = await handler(req);
300
+ expect(res.status).toBe(200);
301
+ expect(fetchCalls).toHaveLength(1);
302
+ const sentBody = fetchCalls[0].init.body as string;
303
+ const sentParams = new URLSearchParams(sentBody);
304
+ expect(sentParams.get("From")).toBe("+15550001111");
305
+ });
306
+
307
+ it("falls back to global From number when assistant mapping is missing", async () => {
308
+ const fetchCalls: Array<{ url: string; init: RequestInit }> = [];
309
+ globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
310
+ const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url;
311
+ fetchCalls.push({ url: urlStr, init: init ?? {} });
312
+ return new Response(JSON.stringify({ sid: "SM-sent" }), {
313
+ status: 201,
314
+ headers: { "content-type": "application/json" },
315
+ });
316
+ }) as unknown as typeof fetch;
317
+
318
+ const handler = createSmsDeliverHandler(
319
+ makeConfig({
320
+ runtimeProxyBearerToken: undefined,
321
+ smsDeliverAuthBypass: true,
322
+ assistantPhoneNumbers: { "ast-beta": "+15550002222" },
323
+ }),
324
+ );
325
+ const req = makeRequest({
326
+ to: "+15559876543",
327
+ text: "fallback",
328
+ assistantId: "ast-alpha",
329
+ });
330
+
331
+ const res = await handler(req);
332
+ expect(res.status).toBe(200);
333
+ expect(fetchCalls).toHaveLength(1);
334
+ const sentBody = fetchCalls[0].init.body as string;
335
+ const sentParams = new URLSearchParams(sentBody);
336
+ expect(sentParams.get("From")).toBe("+15551234567");
337
+ });
338
+
339
+ it("attachment-only request (no text) uses fallback text", async () => {
340
+ const fetchCalls: Array<{ url: string; init: RequestInit }> = [];
341
+ globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
342
+ const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url;
343
+ fetchCalls.push({ url: urlStr, init: init ?? {} });
344
+ return new Response(JSON.stringify({ sid: "SM-sent" }), {
345
+ status: 201,
346
+ headers: { "content-type": "application/json" },
347
+ });
348
+ }) as unknown as typeof fetch;
349
+
350
+ const handler = createSmsDeliverHandler(
351
+ makeConfig({ runtimeProxyBearerToken: undefined, smsDeliverAuthBypass: true }),
352
+ );
353
+ const req = makeRequest({
354
+ to: "+15559876543",
355
+ attachments: [{ url: "https://example.com/image.png" }],
356
+ });
357
+ const res = await handler(req);
358
+ expect(res.status).toBe(200);
359
+ const body = await res.json();
360
+ expect(body.ok).toBe(true);
361
+
362
+ // Verify the Twilio Messages API was called
363
+ expect(fetchCalls).toHaveLength(1);
364
+ expect(fetchCalls[0].url).toContain("AC-test-sid/Messages.json");
365
+
366
+ // Verify the Body parameter contains the fallback text
367
+ const sentBody = fetchCalls[0].init.body as string;
368
+ const sentParams = new URLSearchParams(sentBody);
369
+ expect(sentParams.get("Body")).toBe(
370
+ "I have a media attachment to share, but SMS currently supports text only.",
371
+ );
372
+ });
373
+
374
+ it("returns enriched Twilio acceptance details in response", async () => {
375
+ mockTwilioApi({ sid: "SM-enrich-test", status: "queued", error_code: null, error_message: null });
376
+ const handler = createSmsDeliverHandler(
377
+ makeConfig({ runtimeProxyBearerToken: undefined, smsDeliverAuthBypass: true }),
378
+ );
379
+ const req = makeRequest({ to: "+15559876543", text: "enriched" });
380
+ const res = await handler(req);
381
+ expect(res.status).toBe(200);
382
+ const body = await res.json();
383
+ expect(body.ok).toBe(true);
384
+ expect(body.messageSid).toBe("SM-enrich-test");
385
+ expect(body.status).toBe("queued");
386
+ expect(body.errorCode).toBeNull();
387
+ expect(body.errorMessage).toBeNull();
388
+ });
389
+
390
+ it("returns Twilio error details in response when error_code is present", async () => {
391
+ mockTwilioApi({ sid: "SM-err-test", status: "failed", error_code: 30003, error_message: "Unreachable" });
392
+ const handler = createSmsDeliverHandler(
393
+ makeConfig({ runtimeProxyBearerToken: undefined, smsDeliverAuthBypass: true }),
394
+ );
395
+ const req = makeRequest({ to: "+15559876543", text: "fail test" });
396
+ const res = await handler(req);
397
+ expect(res.status).toBe(200);
398
+ const body = await res.json();
399
+ expect(body.ok).toBe(true);
400
+ expect(body.messageSid).toBe("SM-err-test");
401
+ expect(body.status).toBe("failed");
402
+ expect(body.errorCode).toBe("30003");
403
+ expect(body.errorMessage).toBe("Unreachable");
404
+ });
405
+
406
+ it("returns 503 when no From number is available", async () => {
407
+ const handler = createSmsDeliverHandler(
408
+ makeConfig({
409
+ runtimeProxyBearerToken: undefined,
410
+ smsDeliverAuthBypass: true,
411
+ twilioPhoneNumber: undefined,
412
+ assistantPhoneNumbers: undefined,
413
+ }),
414
+ );
415
+ const req = makeRequest({
416
+ to: "+15559876543",
417
+ text: "no from",
418
+ assistantId: "ast-alpha",
419
+ });
420
+
421
+ const res = await handler(req);
422
+ expect(res.status).toBe(503);
423
+ const body = await res.json();
424
+ expect(body.error).toBe("SMS integration not configured");
425
+ });
274
426
  });