@vellumai/vellum-gateway 0.8.2 → 0.8.4

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 (32) hide show
  1. package/ARCHITECTURE.md +1 -1
  2. package/package.json +1 -1
  3. package/src/__tests__/config-file-watcher.test.ts +57 -0
  4. package/src/__tests__/ipc-slack-thread-routes.test.ts +157 -0
  5. package/src/__tests__/route-schema-guard.test.ts +4 -0
  6. package/src/__tests__/slack-display-name.test.ts +218 -0
  7. package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +98 -4
  8. package/src/__tests__/twilio-webhooks.test.ts +47 -0
  9. package/src/auth/ipc-route-policy.ts +6 -0
  10. package/src/channels/inbound-event.ts +8 -2
  11. package/src/channels/types.ts +2 -0
  12. package/src/config-file-watcher.ts +44 -1
  13. package/src/db/slack-store.ts +10 -0
  14. package/src/feature-flag-registry.json +111 -23
  15. package/src/handlers/handle-inbound.ts +6 -4
  16. package/src/http/routes/a2a-routes.test.ts +129 -0
  17. package/src/http/routes/a2a-routes.ts +121 -0
  18. package/src/http/routes/twilio-voice-verify-callback.ts +41 -12
  19. package/src/http/routes/twilio-voice-webhook.test.ts +55 -0
  20. package/src/http/routes/twilio-voice-webhook.ts +10 -2
  21. package/src/index.ts +16 -0
  22. package/src/ipc/slack-thread-handlers.ts +39 -0
  23. package/src/risk/bash-risk-classifier.test.ts +24 -0
  24. package/src/risk/command-registry/commands/assistant.ts +33 -0
  25. package/src/risk/command-registry.test.ts +5 -0
  26. package/src/runtime/client.ts +66 -14
  27. package/src/slack/normalize.ts +78 -26
  28. package/src/slack/socket-mode.ts +2 -2
  29. package/src/twilio/validate-webhook.ts +7 -1
  30. package/src/types.ts +1 -0
  31. package/src/velay/client.test.ts +100 -0
  32. package/src/velay/client.ts +73 -0
package/ARCHITECTURE.md CHANGED
@@ -298,7 +298,7 @@ The assistant runtime reads this URL via the centralized `public-ingress-urls.ts
298
298
 
299
299
  Velay is a platform-managed tunnel for assistant-hosted HTTP and WebSocket traffic. When it is active, Velay publishes the registered public assistant URL to `ingress.publicBaseUrl` and marks it with `ingress.publicBaseUrlManagedBy: "velay"`.
300
300
 
301
- When `VELAY_BASE_URL` is present in the gateway environment, the gateway creates `VelayTunnelClient` but starts it only after Twilio setup has been started in the workspace. On boot, existing Twilio credentials or existing `twilio.accountSid` / `twilio.phoneNumber` config count as prior setup, and successful credential setup persists `twilio.setupStarted: true` for future boots. Before credential-backed startup side effects run, the gateway clears any stale Velay-managed `ingress.publicBaseUrl`; if setup has not started, it does this without opening a tunnel. The client registers with Velay over `GET /v1/register` using the assistant API key, then receives a `registered` frame containing a public assistant URL such as `https://velay.vellum.ai/<assistant-id>`. The gateway writes that URL to `ingress.publicBaseUrl`. When the tunnel disconnects, it clears that value only if the Velay ownership marker is still present and the URL still matches what the tunnel published, leaving manual URLs intact.
301
+ When `VELAY_BASE_URL` is present in the gateway environment, the gateway creates `VelayTunnelClient` but starts it only after Twilio setup has been started in the workspace. The Twilio setup skill writes `twilio.setupStarted: true` at the beginning of setup so the tunnel can open while credentials and phone-number selection are still in progress. On boot, existing Twilio credentials or existing `twilio.accountSid` / `twilio.phoneNumber` config also count as prior setup. Before credential-backed startup side effects run, the gateway clears any stale Velay-managed `ingress.publicBaseUrl`; if setup has not started, it does this without opening a tunnel. The client registers with Velay over `GET /v1/register` using the assistant API key, then receives a `registered` frame containing a public assistant URL such as `https://velay.vellum.ai/<assistant-id>`. If Vellum platform credentials change while the tunnel is already running or waiting on backoff, the gateway asks the client to reconnect with fresh credentials instead of waiting for process restart. The gateway writes the registered URL to `ingress.publicBaseUrl`. When the tunnel disconnects, it clears that value only if the Velay ownership marker is still present and the URL still matches what the tunnel published, leaving manual URLs intact.
302
302
 
303
303
  Velay forwards both HTTP request frames and WebSocket frames into the local gateway loopback listener:
304
304
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -42,6 +42,31 @@ function makeEvent(
42
42
  };
43
43
  }
44
44
 
45
+ function makeManualIntervalTimer() {
46
+ let intervalFn: (() => void) | undefined;
47
+ let cleared = false;
48
+ type IntervalHandle = ReturnType<typeof setInterval>;
49
+ const timer = 1 as unknown as IntervalHandle;
50
+ return {
51
+ timerApi: {
52
+ setInterval: (fn: () => void, _delayMs: number) => {
53
+ intervalFn = fn;
54
+ return timer;
55
+ },
56
+ clearInterval: (_timer: IntervalHandle) => {
57
+ cleared = true;
58
+ },
59
+ },
60
+ runInterval: () => {
61
+ if (!intervalFn) {
62
+ throw new Error("interval was not scheduled");
63
+ }
64
+ intervalFn();
65
+ },
66
+ isCleared: () => cleared,
67
+ };
68
+ }
69
+
45
70
  afterEach(() => {
46
71
  try {
47
72
  if (existsSync(configPath)) unlinkSync(configPath);
@@ -51,6 +76,38 @@ afterEach(() => {
51
76
  });
52
77
 
53
78
  describe("ConfigFileWatcher", () => {
79
+ test("polls config changes when file watcher events are missed", () => {
80
+ const events: ConfigChangeEvent[] = [];
81
+ const timer = makeManualIntervalTimer();
82
+ const watcher = new ConfigFileWatcher(
83
+ (event) => {
84
+ events.push(event);
85
+ },
86
+ { pollIntervalMs: 10, timerApi: timer.timerApi },
87
+ );
88
+
89
+ try {
90
+ watcher.start();
91
+ writeConfig({
92
+ twilio: {
93
+ setupStarted: true,
94
+ },
95
+ });
96
+
97
+ timer.runInterval();
98
+
99
+ expect(events).toHaveLength(1);
100
+ expect(events[0].changedKeys).toEqual(new Set(["twilio"]));
101
+ expect(events[0].changedFields.get("twilio")).toEqual(
102
+ new Set(["setupStarted"]),
103
+ );
104
+ } finally {
105
+ watcher.stop();
106
+ }
107
+
108
+ expect(timer.isCleared()).toBe(true);
109
+ });
110
+
54
111
  test("reports shallow ingress fields changed by Velay-managed URL writes", () => {
55
112
  writeConfig({
56
113
  ingress: {
@@ -0,0 +1,157 @@
1
+ import {
2
+ afterAll,
3
+ afterEach,
4
+ beforeAll,
5
+ beforeEach,
6
+ describe,
7
+ expect,
8
+ test,
9
+ } from "bun:test";
10
+ import { randomBytes } from "node:crypto";
11
+ import { createConnection, type Socket } from "node:net";
12
+
13
+ import {
14
+ getGatewayDb,
15
+ initGatewayDb,
16
+ resetGatewayDb,
17
+ } from "../db/connection.js";
18
+ import { SlackStore } from "../db/slack-store.js";
19
+ import { slackActiveThreads } from "../db/schema.js";
20
+ import { GatewayIpcServer } from "../ipc/server.js";
21
+ import { slackThreadRoutes } from "../ipc/slack-thread-handlers.js";
22
+
23
+ const CHANNEL_ID = "CFAKE00001";
24
+ const OTHER_CHANNEL_ID = "COTHER0001";
25
+ const THREAD_TS = "1700000000.000000";
26
+ const OTHER_THREAD_TS = "1700000001.000000";
27
+
28
+ beforeAll(async () => {
29
+ await initGatewayDb();
30
+ });
31
+
32
+ beforeEach(() => {
33
+ getGatewayDb().delete(slackActiveThreads).run();
34
+ });
35
+
36
+ afterAll(() => {
37
+ resetGatewayDb();
38
+ });
39
+
40
+ function connectClient(path: string): Promise<Socket> {
41
+ return new Promise((resolve, reject) => {
42
+ const client = createConnection(path, () => resolve(client));
43
+ client.on("error", reject);
44
+ });
45
+ }
46
+
47
+ function sendRequest(
48
+ client: Socket,
49
+ method: string,
50
+ params?: Record<string, unknown>,
51
+ ): Promise<{ id: string; result?: unknown; error?: string }> {
52
+ return new Promise((resolve, reject) => {
53
+ const id = randomBytes(4).toString("hex");
54
+ let buffer = "";
55
+
56
+ const onData = (chunk: Buffer) => {
57
+ buffer += chunk.toString();
58
+ const newlineIdx = buffer.indexOf("\n");
59
+ if (newlineIdx !== -1) {
60
+ const line = buffer.slice(0, newlineIdx).trim();
61
+ buffer = buffer.slice(newlineIdx + 1);
62
+ client.off("data", onData);
63
+ try {
64
+ resolve(JSON.parse(line));
65
+ } catch (err) {
66
+ reject(err);
67
+ }
68
+ }
69
+ };
70
+
71
+ client.on("data", onData);
72
+ client.write(JSON.stringify({ id, method, params }) + "\n");
73
+ });
74
+ }
75
+
76
+ function activeThreadRows(): Array<{ threadTs: string; channelId: string }> {
77
+ return new SlackStore(getGatewayDb()).listActiveThreadsWithChannel();
78
+ }
79
+
80
+ function trackThread(): void {
81
+ new SlackStore(getGatewayDb()).trackThread(THREAD_TS, CHANNEL_ID, 60_000);
82
+ }
83
+
84
+ describe("IPC Slack thread routes", () => {
85
+ let server: InstanceType<typeof GatewayIpcServer>;
86
+ let client: Socket;
87
+
88
+ afterEach(() => {
89
+ client?.destroy();
90
+ server?.stop();
91
+ });
92
+
93
+ async function startServerAndConnect(): Promise<void> {
94
+ server = new GatewayIpcServer([...slackThreadRoutes]);
95
+ server.start();
96
+ await new Promise((resolve) => setTimeout(resolve, 50));
97
+ client = await connectClient(server.getSocketPath());
98
+ }
99
+
100
+ test("detach_slack_active_thread removes a matching active thread", async () => {
101
+ trackThread();
102
+
103
+ await startServerAndConnect();
104
+ const res = await sendRequest(client, "detach_slack_active_thread", {
105
+ channelId: CHANNEL_ID,
106
+ threadTs: THREAD_TS,
107
+ });
108
+
109
+ expect(res.error).toBeUndefined();
110
+ expect(res.result).toEqual({
111
+ detached: true,
112
+ channelId: CHANNEL_ID,
113
+ threadTs: THREAD_TS,
114
+ });
115
+ expect(activeThreadRows()).toEqual([]);
116
+ });
117
+
118
+ test("detach_slack_active_thread is idempotent for an unknown thread", async () => {
119
+ trackThread();
120
+
121
+ await startServerAndConnect();
122
+ const res = await sendRequest(client, "detach_slack_active_thread", {
123
+ channelId: CHANNEL_ID,
124
+ threadTs: OTHER_THREAD_TS,
125
+ });
126
+
127
+ expect(res.error).toBeUndefined();
128
+ expect(res.result).toEqual({
129
+ detached: false,
130
+ channelId: CHANNEL_ID,
131
+ threadTs: OTHER_THREAD_TS,
132
+ });
133
+ expect(activeThreadRows()).toEqual([
134
+ { threadTs: THREAD_TS, channelId: CHANNEL_ID },
135
+ ]);
136
+ });
137
+
138
+ test("detach_slack_active_thread does not remove channel mismatches", async () => {
139
+ trackThread();
140
+
141
+ await startServerAndConnect();
142
+ const res = await sendRequest(client, "detach_slack_active_thread", {
143
+ channelId: OTHER_CHANNEL_ID,
144
+ threadTs: THREAD_TS,
145
+ });
146
+
147
+ expect(res.error).toBeUndefined();
148
+ expect(res.result).toEqual({
149
+ detached: false,
150
+ channelId: OTHER_CHANNEL_ID,
151
+ threadTs: THREAD_TS,
152
+ });
153
+ expect(activeThreadRows()).toEqual([
154
+ { threadTs: THREAD_TS, channelId: CHANNEL_ID },
155
+ ]);
156
+ });
157
+ });
@@ -8,6 +8,7 @@ import {
8
8
  TWILIO_STATUS_WEBHOOK_PATH,
9
9
  TWILIO_VOICE_WEBHOOK_PATH,
10
10
  } from "@vellumai/service-contracts/twilio-ingress";
11
+ import { A2A_AGENT_CARD_PATH } from "../http/routes/a2a-routes.js";
11
12
  import { buildSchema } from "../schema.js";
12
13
 
13
14
  /** A route extracted from source: path + optional HTTP method. */
@@ -17,6 +18,7 @@ interface ExtractedRoute {
17
18
  }
18
19
 
19
20
  const ROUTE_PATH_CONSTANTS: Record<string, string> = {
21
+ A2A_AGENT_CARD_PATH,
20
22
  TWILIO_CONNECT_ACTION_WEBHOOK_PATH,
21
23
  TWILIO_MEDIA_STREAM_WEBHOOK_PATH,
22
24
  TWILIO_RELAY_WEBHOOK_PATH,
@@ -169,6 +171,8 @@ const EXCLUDED_FROM_SCHEMA = new Set([
169
171
  "catch-all",
170
172
  // Loopback-only pairing endpoint — not part of the public gateway API
171
173
  "/v1/pair",
174
+ // A2A agent card discovery — read-only, unauthenticated per spec
175
+ "/.well-known/agent-card.json",
172
176
  ]);
173
177
 
174
178
  // ── Schema paths that don't map to a discrete route definition ──
@@ -1,5 +1,9 @@
1
1
  import { describe, test, expect, mock, beforeEach } from "bun:test";
2
2
  import type { GatewayConfig } from "../config.js";
3
+ import type {
4
+ RuntimeInboundPayload,
5
+ RuntimeInboundResponse,
6
+ } from "../runtime/client.js";
3
7
 
4
8
  type FetchFn = (
5
9
  input: string | URL | Request,
@@ -8,20 +12,46 @@ type FetchFn = (
8
12
  let fetchMock: ReturnType<typeof mock<FetchFn>> = mock(
9
13
  async () => new Response(),
10
14
  );
15
+ let runtimePayloads: RuntimeInboundPayload[] = [];
16
+ const forwardToRuntimeMock = mock(
17
+ async (
18
+ _config: GatewayConfig,
19
+ payload: RuntimeInboundPayload,
20
+ ): Promise<RuntimeInboundResponse> => {
21
+ runtimePayloads.push(payload);
22
+ return { accepted: true, duplicate: false, eventId: "runtime-event-1" };
23
+ },
24
+ );
11
25
 
12
26
  mock.module("../fetch.js", () => ({
13
27
  fetchImpl: (...args: Parameters<FetchFn>) => fetchMock(...args),
14
28
  }));
15
29
 
30
+ mock.module("../runtime/client.js", () => ({
31
+ CircuitBreakerOpenError: class CircuitBreakerOpenError extends Error {
32
+ readonly retryAfterSecs: number;
33
+
34
+ constructor(retryAfterSecs: number) {
35
+ super("Circuit breaker is open");
36
+ this.retryAfterSecs = retryAfterSecs;
37
+ }
38
+ },
39
+ forwardToRuntime: (...args: Parameters<typeof forwardToRuntimeMock>) =>
40
+ forwardToRuntimeMock(...args),
41
+ }));
42
+
16
43
  const {
17
44
  normalizeSlackAppMention,
18
45
  resolveSlackChannel,
19
46
  resolveSlackUser,
47
+ resolveSlackUserSync,
20
48
  clearChannelInfoCache,
49
+ clearInFlightFetches,
21
50
  clearUserInfoCache,
22
51
  getChannelInfoCacheSize,
23
52
  getUserInfoCacheSize,
24
53
  } = await import("../slack/normalize.js");
54
+ const { handleInbound } = await import("../handlers/handle-inbound.js");
25
55
  import type { SlackAppMentionEvent } from "../slack/normalize.js";
26
56
 
27
57
  function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
@@ -67,6 +97,9 @@ function makeEvent(
67
97
  beforeEach(() => {
68
98
  clearUserInfoCache();
69
99
  clearChannelInfoCache();
100
+ clearInFlightFetches();
101
+ runtimePayloads = [];
102
+ forwardToRuntimeMock.mockClear();
70
103
  });
71
104
 
72
105
  describe("resolveSlackUser", () => {
@@ -78,6 +111,9 @@ describe("resolveSlackUser", () => {
78
111
  user: {
79
112
  name: "jdoe",
80
113
  real_name: "Jane Doe",
114
+ tz: "America/New_York",
115
+ tz_label: "Eastern Daylight Time",
116
+ tz_offset: -14400,
81
117
  profile: { display_name: "Jane D", real_name: "Jane Doe" },
82
118
  },
83
119
  }),
@@ -89,6 +125,9 @@ describe("resolveSlackUser", () => {
89
125
  expect(info).not.toBeUndefined();
90
126
  expect(info!.displayName).toBe("Jane D");
91
127
  expect(info!.username).toBe("jdoe");
128
+ expect(info!.timezone).toBe("America/New_York");
129
+ expect(info!.timezoneLabel).toBe("Eastern Daylight Time");
130
+ expect(info!.timezoneOffsetSeconds).toBe(-14400);
92
131
  });
93
132
 
94
133
  test("falls back to real_name when display_name is empty", async () => {
@@ -151,6 +190,100 @@ describe("resolveSlackUser", () => {
151
190
  expect(callCount).toBe(1);
152
191
  expect(getUserInfoCacheSize()).toBe(1);
153
192
  });
193
+
194
+ test("scopes cached user info by bot token", async () => {
195
+ let callCount = 0;
196
+ fetchMock = mock(async (_input, init) => {
197
+ callCount++;
198
+ const auth = new Headers(init?.headers).get("authorization");
199
+ const user =
200
+ auth === "Bearer xoxb-team-a"
201
+ ? {
202
+ name: "alice",
203
+ tz: "America/New_York",
204
+ tz_label: "Eastern Daylight Time",
205
+ tz_offset: -14400,
206
+ profile: { display_name: "Alice" },
207
+ }
208
+ : {
209
+ name: "bob",
210
+ tz: "America/Los_Angeles",
211
+ tz_label: "Pacific Daylight Time",
212
+ tz_offset: -25200,
213
+ profile: { display_name: "Bob" },
214
+ };
215
+ return new Response(JSON.stringify({ ok: true, user }), {
216
+ status: 200,
217
+ headers: { "content-type": "application/json" },
218
+ });
219
+ });
220
+
221
+ const [teamAInfo, teamBInfo] = await Promise.all([
222
+ resolveSlackUser("U_SHARED", "xoxb-team-a"),
223
+ resolveSlackUser("U_SHARED", "xoxb-team-b"),
224
+ ]);
225
+
226
+ expect(teamAInfo!.displayName).toBe("Alice");
227
+ expect(teamAInfo!.timezone).toBe("America/New_York");
228
+ expect(teamBInfo!.displayName).toBe("Bob");
229
+ expect(teamBInfo!.timezone).toBe("America/Los_Angeles");
230
+ expect(callCount).toBe(2);
231
+ expect(getUserInfoCacheSize()).toBe(2);
232
+
233
+ await resolveSlackUser("U_SHARED", "xoxb-team-a");
234
+ await resolveSlackUser("U_SHARED", "xoxb-team-b");
235
+ expect(callCount).toBe(2);
236
+ });
237
+
238
+ test("sync cache lookup uses the bot token scope", async () => {
239
+ let callCount = 0;
240
+ fetchMock = mock(async (_input, init) => {
241
+ callCount++;
242
+ const auth = new Headers(init?.headers).get("authorization");
243
+ const user =
244
+ auth === "Bearer xoxb-team-a"
245
+ ? {
246
+ name: "alice",
247
+ tz: "America/Denver",
248
+ tz_label: "Mountain Daylight Time",
249
+ tz_offset: -21600,
250
+ profile: { display_name: "Alice" },
251
+ }
252
+ : {
253
+ name: "bob",
254
+ tz: "Europe/London",
255
+ tz_label: "British Summer Time",
256
+ tz_offset: 3600,
257
+ profile: { display_name: "Bob" },
258
+ };
259
+ return new Response(JSON.stringify({ ok: true, user }), {
260
+ status: 200,
261
+ headers: { "content-type": "application/json" },
262
+ });
263
+ });
264
+
265
+ await resolveSlackUser("U_SHARED_SYNC", "xoxb-team-a");
266
+
267
+ const teamACached = resolveSlackUserSync("U_SHARED_SYNC", "xoxb-team-a");
268
+ expect(teamACached!.displayName).toBe("Alice");
269
+ expect(teamACached!.timezone).toBe("America/Denver");
270
+
271
+ const teamBMiss = resolveSlackUserSync("U_SHARED_SYNC", "xoxb-team-b");
272
+ expect(teamBMiss).toBeUndefined();
273
+
274
+ const teamBResolved = await resolveSlackUser(
275
+ "U_SHARED_SYNC",
276
+ "xoxb-team-b",
277
+ );
278
+ expect(teamBResolved!.displayName).toBe("Bob");
279
+ expect(teamBResolved!.timezone).toBe("Europe/London");
280
+
281
+ const teamBCached = resolveSlackUserSync("U_SHARED_SYNC", "xoxb-team-b");
282
+ expect(teamBCached!.displayName).toBe("Bob");
283
+ expect(teamBCached!.timezone).toBe("Europe/London");
284
+ expect(callCount).toBe(2);
285
+ expect(getUserInfoCacheSize()).toBe(2);
286
+ });
154
287
  });
155
288
 
156
289
  describe("resolveSlackChannel", () => {
@@ -217,6 +350,36 @@ describe("resolveSlackChannel", () => {
217
350
  expect(callCount).toBe(1);
218
351
  expect(getChannelInfoCacheSize()).toBe(1);
219
352
  });
353
+
354
+ test("scopes cached channel names by bot token", async () => {
355
+ let callCount = 0;
356
+ fetchMock = mock(async (_input, init) => {
357
+ callCount++;
358
+ const auth = new Headers(init?.headers).get("authorization");
359
+ const channel =
360
+ auth === "Bearer xoxb-team-a"
361
+ ? { id: "C_SHARED", name: "team-a-channel" }
362
+ : { id: "C_SHARED", name: "team-b-channel" };
363
+ return new Response(JSON.stringify({ ok: true, channel }), {
364
+ status: 200,
365
+ headers: { "content-type": "application/json" },
366
+ });
367
+ });
368
+
369
+ const [teamAInfo, teamBInfo] = await Promise.all([
370
+ resolveSlackChannel("C_SHARED", "xoxb-team-a"),
371
+ resolveSlackChannel("C_SHARED", "xoxb-team-b"),
372
+ ]);
373
+
374
+ expect(teamAInfo!.name).toBe("team-a-channel");
375
+ expect(teamBInfo!.name).toBe("team-b-channel");
376
+ expect(callCount).toBe(2);
377
+ expect(getChannelInfoCacheSize()).toBe(2);
378
+
379
+ await resolveSlackChannel("C_SHARED", "xoxb-team-a");
380
+ await resolveSlackChannel("C_SHARED", "xoxb-team-b");
381
+ expect(callCount).toBe(2);
382
+ });
220
383
  });
221
384
 
222
385
  describe("normalizeSlackAppMention with display name", () => {
@@ -273,6 +436,9 @@ describe("normalizeSlackAppMention with display name", () => {
273
436
  user: {
274
437
  name: "testuser",
275
438
  real_name: "Test User",
439
+ tz: "America/Denver",
440
+ tz_label: "Mountain Daylight Time",
441
+ tz_offset: -21600,
276
442
  profile: { display_name: "Test U" },
277
443
  },
278
444
  }),
@@ -296,6 +462,58 @@ describe("normalizeSlackAppMention with display name", () => {
296
462
  expect(result).not.toBeNull();
297
463
  expect(result!.event.actor.displayName).toBe("Test U");
298
464
  expect(result!.event.actor.username).toBe("testuser");
465
+ expect(result!.event.actor.timezone).toBe("America/Denver");
466
+ expect(result!.event.actor.timezoneLabel).toBe("Mountain Daylight Time");
467
+ expect(result!.event.actor.timezoneOffsetSeconds).toBe(-21600);
468
+ });
469
+
470
+ test("forwards cached Slack timezone fields in runtime source metadata", async () => {
471
+ fetchMock = mock(async () => {
472
+ return new Response(
473
+ JSON.stringify({
474
+ ok: true,
475
+ user: {
476
+ name: "testuser",
477
+ real_name: "Test User",
478
+ tz: "America/Los_Angeles",
479
+ tz_label: "Pacific Daylight Time",
480
+ tz_offset: -25200,
481
+ profile: { display_name: "Test U" },
482
+ },
483
+ }),
484
+ { status: 200, headers: { "content-type": "application/json" } },
485
+ );
486
+ });
487
+
488
+ const config = makeConfig();
489
+ const event = makeEvent({ user: "U_WITH_TZ" });
490
+ await resolveSlackUser("U_WITH_TZ", "xoxb-test");
491
+
492
+ const result = normalizeSlackAppMention(
493
+ event,
494
+ "evt-tz-forward",
495
+ config,
496
+ undefined,
497
+ "xoxb-test",
498
+ );
499
+ expect(result).not.toBeNull();
500
+
501
+ await handleInbound(config, result!.event, {
502
+ routingOverride: result!.routing,
503
+ });
504
+
505
+ expect(forwardToRuntimeMock).toHaveBeenCalledTimes(1);
506
+ const forwardedPayload = runtimePayloads[0];
507
+ expect(forwardedPayload).toBeDefined();
508
+ expect(forwardedPayload!.sourceMetadata!.timezone).toBe(
509
+ "America/Los_Angeles",
510
+ );
511
+ expect(forwardedPayload!.sourceMetadata!.timezoneLabel).toBe(
512
+ "Pacific Daylight Time",
513
+ );
514
+ expect(forwardedPayload!.sourceMetadata!.timezoneOffsetSeconds).toBe(
515
+ -25200,
516
+ );
299
517
  });
300
518
 
301
519
  test("renders cache-warmed mention labels in model-facing content", async () => {