@vellumai/vellum-gateway 0.8.3 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
+ });
@@ -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 () => {
@@ -4,6 +4,7 @@ import { drizzle } from "drizzle-orm/bun-sqlite";
4
4
  import type { GatewayConfig } from "../config.js";
5
5
  import { SlackStore } from "../db/slack-store.js";
6
6
  import * as schema from "../db/schema.js";
7
+ import type { RuntimeInboundPayload } from "../runtime/client.js";
7
8
  import type { NormalizedSlackEvent } from "../slack/normalize.js";
8
9
 
9
10
  type FetchFn = (
@@ -27,14 +28,42 @@ function makeSlackUserResponse(): Response {
27
28
  let fetchMock: ReturnType<typeof mock<FetchFn>> = mock(async () =>
28
29
  makeSlackUserResponse(),
29
30
  );
31
+ const runtimePayloads: RuntimeInboundPayload[] = [];
30
32
 
31
33
  mock.module("../fetch.js", () => ({
32
34
  fetchImpl: (...args: Parameters<FetchFn>) => fetchMock(...args),
33
35
  }));
34
36
 
37
+ mock.module("../runtime/client.js", () => ({
38
+ CircuitBreakerOpenError: class CircuitBreakerOpenError extends Error {
39
+ readonly retryAfterSecs: number;
40
+
41
+ constructor(retryAfterSecs: number) {
42
+ super("Circuit breaker is open");
43
+ this.name = "CircuitBreakerOpenError";
44
+ this.retryAfterSecs = retryAfterSecs;
45
+ }
46
+ },
47
+ forwardToRuntime: mock(
48
+ async (_config: GatewayConfig, payload: RuntimeInboundPayload) => {
49
+ runtimePayloads.push(payload);
50
+ return {
51
+ accepted: true,
52
+ duplicate: false,
53
+ eventId: "runtime-event-1",
54
+ };
55
+ },
56
+ ),
57
+ }));
58
+
59
+ mock.module("../verification/text-verification.js", () => ({
60
+ tryTextVerificationIntercept: mock(async () => ({ intercepted: false })),
61
+ }));
62
+
35
63
  const { SlackSocketModeClient } = await import("../slack/socket-mode.js");
36
64
  const { clearChannelInfoCache, clearUserInfoCache, resolveSlackUser } =
37
65
  await import("../slack/normalize.js");
66
+ const { handleInbound } = await import("../handlers/handle-inbound.js");
38
67
  import type { SlackSocketModeConfig } from "../slack/socket-mode.js";
39
68
 
40
69
  type SocketModeHarness = {
@@ -149,6 +178,7 @@ function flushAsyncEventEmission(): Promise<void> {
149
178
  }
150
179
 
151
180
  beforeEach(() => {
181
+ runtimePayloads.length = 0;
152
182
  clearUserInfoCache();
153
183
  clearChannelInfoCache();
154
184
  fetchMock = mock(async () => makeSlackUserResponse());
@@ -706,21 +736,85 @@ describe("SlackSocketModeClient thread tracking", () => {
706
736
  }
707
737
  });
708
738
 
739
+ test("emits a plain app mention without resolving the event channel name", async () => {
740
+ const { rawDb, store } = createSlackStore();
741
+ const config = makeConfig();
742
+ const emitted: NormalizedSlackEvent[] = [];
743
+ const client = createHarness(store, (event) => emitted.push(event));
744
+ const ws = makeOpenSocket();
745
+ const conversationInfoChannels: string[] = [];
746
+
747
+ fetchMock = mock(async (input) => {
748
+ const url = new URL(String(input));
749
+ if (url.pathname.endsWith("/conversations.info")) {
750
+ const channelId = url.searchParams.get("channel");
751
+ if (channelId) {
752
+ conversationInfoChannels.push(channelId);
753
+ }
754
+ return new Response(
755
+ JSON.stringify({
756
+ ok: true,
757
+ channel: { id: channelId, name: "support-triage" },
758
+ }),
759
+ { status: 200, headers: { "content-type": "application/json" } },
760
+ );
761
+ }
762
+ return makeSlackUserResponse();
763
+ });
764
+
765
+ try {
766
+ client.handleMessage(
767
+ JSON.stringify({
768
+ envelope_id: "env-channel-name",
769
+ type: "events_api",
770
+ payload: {
771
+ event_id: "Ev-channel-name",
772
+ event: {
773
+ type: "app_mention",
774
+ user: "U-actor",
775
+ text: "<@UBOT> please summarize this",
776
+ ts: "1700000000.000950",
777
+ channel: "C-thread",
778
+ },
779
+ },
780
+ }),
781
+ ws,
782
+ );
783
+ await flushAsyncEventEmission();
784
+
785
+ expect(emitted).toHaveLength(1);
786
+ expect(emitted[0].event.source.channelName).toBeUndefined();
787
+ expect(conversationInfoChannels).toEqual([]);
788
+
789
+ await handleInbound(config, emitted[0].event, {
790
+ routingOverride: emitted[0].routing,
791
+ });
792
+
793
+ expect(runtimePayloads).toHaveLength(1);
794
+ expect(runtimePayloads[0].sourceMetadata?.channelName).toBeUndefined();
795
+ } finally {
796
+ rawDb.close();
797
+ }
798
+ });
799
+
709
800
  test("keeps embedded Slack channel labels without conversations.info lookup", async () => {
710
801
  const { rawDb, store } = createSlackStore();
711
802
  const emitted: NormalizedSlackEvent[] = [];
712
803
  const client = createHarness(store, (event) => emitted.push(event));
713
804
  const ws = makeOpenSocket();
714
- let conversationInfoCalls = 0;
805
+ const conversationInfoChannels: string[] = [];
715
806
 
716
807
  fetchMock = mock(async (input) => {
717
808
  const url = new URL(String(input));
718
809
  if (url.pathname.endsWith("/conversations.info")) {
719
- conversationInfoCalls++;
810
+ const channelId = url.searchParams.get("channel");
811
+ if (channelId) {
812
+ conversationInfoChannels.push(channelId);
813
+ }
720
814
  return new Response(
721
815
  JSON.stringify({
722
816
  ok: true,
723
- channel: { id: "CFEEDBACK", name: "private-name" },
817
+ channel: { id: channelId, name: "private-name" },
724
818
  }),
725
819
  { status: 200, headers: { "content-type": "application/json" } },
726
820
  );
@@ -752,7 +846,7 @@ describe("SlackSocketModeClient thread tracking", () => {
752
846
  expect(emitted[0].event.message.content).toBe(
753
847
  "@Example User continue in #visible-name",
754
848
  );
755
- expect(conversationInfoCalls).toBe(0);
849
+ expect(conversationInfoChannels).toEqual([]);
756
850
  } finally {
757
851
  rawDb.close();
758
852
  }
@@ -40,6 +40,9 @@ interface InboundEventBase<C extends InboundChannelId> {
40
40
  lastName?: string;
41
41
  languageCode?: string;
42
42
  isBot?: boolean;
43
+ timezone?: string;
44
+ timezoneLabel?: string;
45
+ timezoneOffsetSeconds?: number;
43
46
  };
44
47
  source: {
45
48
  updateId: string;
@@ -51,6 +54,7 @@ interface InboundEventBase<C extends InboundChannelId> {
51
54
  * `In-Reply-To`, etc.) can reuse the field later.
52
55
  */
53
56
  threadId?: string;
57
+ channelName?: string;
54
58
  };
55
59
  raw: Record<string, unknown>;
56
60
  }
@@ -67,6 +67,16 @@ export class SlackStore {
67
67
  return row !== undefined;
68
68
  }
69
69
 
70
+ detachThread(threadTs: string, channelId: string): boolean {
71
+ const raw = (this.db as unknown as { $client: Database }).$client;
72
+ const changes = raw
73
+ .prepare(
74
+ "DELETE FROM slack_active_threads WHERE thread_ts = ? AND channel_id = ?",
75
+ )
76
+ .run(threadTs, channelId).changes;
77
+ return changes > 0;
78
+ }
79
+
70
80
  /**
71
81
  * Returns all unexpired active threads with a known channel for reconnect
72
82
  * catch-up. Rows with a NULL `channel_id` (legacy rows from before the
@@ -9,6 +9,14 @@
9
9
  "description": "Automatically trigger conversation analysis on the same cadence as memory extraction (batch threshold, idle debounce, end-of-conversation). The analysis agent has full tool access and writes back to memory and skills without user approval.",
10
10
  "defaultEnabled": false
11
11
  },
12
+ {
13
+ "id": "memory-retrospective-fork",
14
+ "scope": "assistant",
15
+ "key": "memory-retrospective-fork",
16
+ "label": "Fork-based memory retrospective",
17
+ "description": "Fork the source conversation through its latest message for memory retrospectives, instead of rendering the slice into a transcript and waking an empty background conversation. Lets the retrospective hit the provider prompt cache and read compaction summary + tail messages natively.",
18
+ "defaultEnabled": false
19
+ },
12
20
  {
13
21
  "id": "user-hosted-enabled",
14
22
  "scope": "client",
@@ -43,7 +51,7 @@
43
51
  },
44
52
  {
45
53
  "id": "settings-developer-nav",
46
- "scope": "client",
54
+ "scope": "assistant",
47
55
  "key": "settings-developer-nav",
48
56
  "label": "Settings Developer Nav",
49
57
  "description": "Control Developer nav visibility in macOS settings",
@@ -89,6 +97,14 @@
89
97
  "description": "Surface credential grant and audit inspection endpoints for reviewing active grants and access logs",
90
98
  "defaultEnabled": false
91
99
  },
100
+ {
101
+ "id": "chatgpt-subscription-auth",
102
+ "scope": "assistant",
103
+ "key": "chatgpt-subscription-auth",
104
+ "label": "ChatGPT Subscription Auth",
105
+ "description": "Enable ChatGPT subscription OAuth as a provider auth type for OpenAI models, using the Codex device-code flow.",
106
+ "defaultEnabled": false
107
+ },
92
108
  {
93
109
  "id": "deploy-to-vercel",
94
110
  "scope": "assistant",
@@ -275,7 +291,7 @@
275
291
  },
276
292
  {
277
293
  "id": "account-deletion",
278
- "scope": "client",
294
+ "scope": "assistant",
279
295
  "key": "account-deletion",
280
296
  "label": "Account Deletion",
281
297
  "description": "Surfaces the user-initiated account deletion flow in client settings.",
@@ -299,7 +315,7 @@
299
315
  },
300
316
  {
301
317
  "id": "pro-plan-adjust",
302
- "scope": "assistant",
318
+ "scope": "client",
303
319
  "key": "pro-plan-adjust",
304
320
  "label": "Pro Plan Adjust",
305
321
  "description": "Show the rich Plan card (current plan, features, Manage/Upgrade CTA) at the top of the macOS Settings \u2192 Billing tab.",
@@ -328,6 +344,86 @@
328
344
  "label": "Velvet Theme",
329
345
  "description": "Show the Velvet theme option in the macOS appearance settings. Velvet is a dark-mode variant with red/pink accent colors.",
330
346
  "defaultEnabled": false
347
+ },
348
+ {
349
+ "id": "query-complexity-routing",
350
+ "scope": "assistant",
351
+ "key": "query-complexity-routing",
352
+ "label": "Query Complexity Routing",
353
+ "description": "Automatically route user messages to the most appropriate inference profile based on query complexity. Simple queries use the speed profile, complex queries escalate to the quality profile. The user is notified of each switch and can opt out by pinning a profile on the conversation.",
354
+ "defaultEnabled": false
355
+ },
356
+ {
357
+ "id": "queue-steering",
358
+ "scope": "assistant",
359
+ "key": "queue-steering",
360
+ "label": "Queue Steering",
361
+ "description": "Enable the 'Push to agent' button on queued messages, allowing users to steer the assistant to a specific queued message by aborting the current generation and promoting the message to the head of the queue.",
362
+ "defaultEnabled": false
363
+ },
364
+ {
365
+ "id": "chat-pull-to-refresh-enabled",
366
+ "scope": "client",
367
+ "key": "chat-pull-to-refresh-enabled",
368
+ "label": "Chat Pull to Refresh",
369
+ "description": "Enable pull-to-refresh gesture in the chat view.",
370
+ "defaultEnabled": false
371
+ },
372
+ {
373
+ "id": "doctor",
374
+ "scope": "client",
375
+ "key": "doctor",
376
+ "label": "Doctor",
377
+ "description": "Enable the Doctor diagnostic tab in Debug settings.",
378
+ "defaultEnabled": false
379
+ },
380
+ {
381
+ "id": "home-page",
382
+ "scope": "client",
383
+ "key": "home-page",
384
+ "label": "Home Page",
385
+ "description": "Enable the Home page as the default landing view.",
386
+ "defaultEnabled": false
387
+ },
388
+ {
389
+ "id": "platform-notifications",
390
+ "scope": "client",
391
+ "key": "platform-notifications",
392
+ "label": "Platform Notifications",
393
+ "description": "Enable the Notifications tab in settings.",
394
+ "defaultEnabled": false
395
+ },
396
+ {
397
+ "id": "rollback-enabled",
398
+ "scope": "assistant",
399
+ "key": "rollback-enabled",
400
+ "label": "Rollback Enabled",
401
+ "description": "Show older versions in the version picker, allowing rollback to previous releases.",
402
+ "defaultEnabled": false
403
+ },
404
+ {
405
+ "id": "self-hosted-assistant",
406
+ "scope": "client",
407
+ "key": "self-hosted-assistant",
408
+ "label": "Self-Hosted Assistant",
409
+ "description": "Enable self-hosted assistant configuration.",
410
+ "defaultEnabled": false
411
+ },
412
+ {
413
+ "id": "settings-sleep-policy",
414
+ "scope": "assistant",
415
+ "key": "settings-sleep-policy",
416
+ "label": "Settings Sleep Policy",
417
+ "description": "Enable sleep policy settings.",
418
+ "defaultEnabled": false
419
+ },
420
+ {
421
+ "id": "velvet",
422
+ "scope": "client",
423
+ "key": "velvet",
424
+ "label": "Velvet",
425
+ "description": "Enable the Velvet design theme.",
426
+ "defaultEnabled": false
331
427
  }
332
428
  ]
333
429
  }
@@ -15,7 +15,6 @@ import type { RuntimeInboundResponse } from "../runtime/client.js";
15
15
  import type { GatewayInboundEvent } from "../types.js";
16
16
  import { tryTextVerificationIntercept } from "../verification/text-verification.js";
17
17
 
18
-
19
18
  const log = getLogger("handle-inbound");
20
19
 
21
20
  export type InboundResult = {
@@ -119,6 +118,7 @@ export async function handleInbound(
119
118
  options?.transportMetadata?.hints,
120
119
  );
121
120
  const transportUxBrief = options?.transportMetadata?.uxBrief?.trim();
121
+ const sourceChannelName = event.source.channelName?.trim();
122
122
 
123
123
  try {
124
124
  const response = await forwardToRuntime(
@@ -144,8 +144,12 @@ export async function handleInbound(
144
144
  messageId: event.source.messageId,
145
145
  chatType: event.source.chatType,
146
146
  ...(event.source.threadId ? { threadId: event.source.threadId } : {}),
147
+ ...(sourceChannelName ? { channelName: sourceChannelName } : {}),
147
148
  languageCode: event.actor.languageCode,
148
149
  isBot: event.actor.isBot,
150
+ timezone: event.actor.timezone,
151
+ timezoneLabel: event.actor.timezoneLabel,
152
+ timezoneOffsetSeconds: event.actor.timezoneOffsetSeconds,
149
153
  ...(transportHints.length > 0 ? { hints: transportHints } : {}),
150
154
  ...(transportUxBrief ? { uxBrief: transportUxBrief } : {}),
151
155
  ...(options?.sourceMetadata ?? {}),
@@ -176,9 +180,7 @@ export async function handleInbound(
176
180
  // writes to both assistant DB and gateway DB. Fire-and-forget so
177
181
  // IPC failures here cannot leak as unhandled rejections.
178
182
  if (!response.denied) {
179
- void touchContactChannelStats(event, response.duplicate).catch(
180
- () => {},
181
- );
183
+ void touchContactChannelStats(event, response.duplicate).catch(() => {});
182
184
  }
183
185
 
184
186
  return { forwarded: true, rejected: false, runtimeResponse: response };
package/src/index.ts CHANGED
@@ -176,6 +176,7 @@ import {
176
176
  import { GatewayIpcServer } from "./ipc/server.js";
177
177
  import { contactRoutes } from "./ipc/contact-handlers.js";
178
178
  import { featureFlagRoutes } from "./ipc/feature-flag-handlers.js";
179
+ import { slackThreadRoutes } from "./ipc/slack-thread-handlers.js";
179
180
  import { thresholdRoutes } from "./ipc/threshold-handlers.js";
180
181
 
181
182
  import { riskClassificationRoutes } from "./ipc/risk-classification-handlers.js";
@@ -2205,6 +2206,7 @@ async function main() {
2205
2206
  const ipcServer = new GatewayIpcServer([
2206
2207
  ...featureFlagRoutes,
2207
2208
  ...contactRoutes,
2209
+ ...slackThreadRoutes,
2208
2210
  ...thresholdRoutes,
2209
2211
  ...riskClassificationRoutes,
2210
2212
  ...createVelayRoutes(velayTunnelClient),
@@ -0,0 +1,39 @@
1
+ /**
2
+ * IPC route definitions for Slack active-thread listener control.
3
+ *
4
+ * The gateway owns Slack Socket Mode listener state, so assistant-side
5
+ * controls call here instead of writing gateway storage directly.
6
+ */
7
+
8
+ import { z } from "zod";
9
+
10
+ import { SlackStore } from "../db/slack-store.js";
11
+ import type { IpcRoute } from "./server.js";
12
+
13
+ let store: SlackStore | null = null;
14
+
15
+ function getStore(): SlackStore {
16
+ if (!store) {
17
+ store = new SlackStore();
18
+ }
19
+ return store;
20
+ }
21
+
22
+ const DetachSlackActiveThreadParamsSchema = z.object({
23
+ channelId: z.string().trim().min(1),
24
+ threadTs: z.string().trim().min(1),
25
+ });
26
+
27
+ export const slackThreadRoutes: IpcRoute[] = [
28
+ {
29
+ method: "detach_slack_active_thread",
30
+ schema: DetachSlackActiveThreadParamsSchema,
31
+ handler: (params?: Record<string, unknown>) => {
32
+ const { channelId, threadTs } = DetachSlackActiveThreadParamsSchema.parse(
33
+ params ?? {},
34
+ );
35
+ const detached = getStore().detachThread(threadTs, channelId);
36
+ return { detached, channelId, threadTs };
37
+ },
38
+ },
39
+ ];
@@ -1,4 +1,5 @@
1
1
  import { renderSlackTextForModel } from "@vellumai/slack-text";
2
+ import { createHash } from "node:crypto";
2
3
  import type { GatewayConfig } from "../config.js";
3
4
  import { fetchImpl } from "../fetch.js";
4
5
  import { resolveAssistant, isRejection } from "../routing/resolve-assistant.js";
@@ -11,8 +12,20 @@ import type { GatewayInboundEvent } from "../types.js";
11
12
  interface SlackUserInfo {
12
13
  displayName: string;
13
14
  username: string;
15
+ timezone?: string;
16
+ timezoneLabel?: string;
17
+ timezoneOffsetSeconds?: number;
14
18
  }
15
19
 
20
+ export type SlackUserActorFields = Pick<
21
+ SlackUserInfo,
22
+ | "displayName"
23
+ | "username"
24
+ | "timezone"
25
+ | "timezoneLabel"
26
+ | "timezoneOffsetSeconds"
27
+ >;
28
+
16
29
  interface SlackChannelInfo {
17
30
  name: string;
18
31
  }
@@ -48,6 +61,16 @@ const inFlightChannelFetches = new Map<
48
61
  Promise<SlackChannelInfo | undefined>
49
62
  >();
50
63
 
64
+ function slackUserCacheKey(userId: string, botToken: string): string {
65
+ const authScope = createHash("sha256").update(botToken).digest("hex");
66
+ return `${authScope}:${userId}`;
67
+ }
68
+
69
+ function slackChannelCacheKey(channelId: string, botToken: string): string {
70
+ const authScope = createHash("sha256").update(botToken).digest("hex");
71
+ return `${authScope}:${channelId}`;
72
+ }
73
+
51
74
  function evictExpired<T>(cache: Map<string, CacheEntry<T>>): void {
52
75
  const now = Date.now();
53
76
  for (const [key, entry] of cache) {
@@ -106,11 +129,12 @@ export async function resolveSlackUser(
106
129
  userId: string,
107
130
  botToken: string,
108
131
  ): Promise<SlackUserInfo | undefined> {
109
- const cached = cacheGet(userInfoCache, userId);
132
+ const cacheKey = slackUserCacheKey(userId, botToken);
133
+ const cached = cacheGet(userInfoCache, cacheKey);
110
134
  if (cached) return cached;
111
135
 
112
136
  // If another caller is already fetching this user, reuse that promise
113
- const existing = inFlightUserFetches.get(userId);
137
+ const existing = inFlightUserFetches.get(cacheKey);
114
138
  if (existing) return existing;
115
139
 
116
140
  const fetchPromise = (async (): Promise<SlackUserInfo | undefined> => {
@@ -129,6 +153,9 @@ export async function resolveSlackUser(
129
153
  user?: {
130
154
  name?: string;
131
155
  real_name?: string;
156
+ tz?: string;
157
+ tz_label?: string;
158
+ tz_offset?: number;
132
159
  profile?: { display_name?: string; real_name?: string };
133
160
  };
134
161
  };
@@ -141,11 +168,27 @@ export async function resolveSlackUser(
141
168
  data.user.name ||
142
169
  userId;
143
170
  const username = data.user.name || userId;
144
-
145
- const info: SlackUserInfo = { displayName, username };
171
+ const timezone =
172
+ typeof data.user.tz === "string" ? data.user.tz : undefined;
173
+ const timezoneLabel =
174
+ typeof data.user.tz_label === "string" ? data.user.tz_label : undefined;
175
+ const timezoneOffsetSeconds =
176
+ typeof data.user.tz_offset === "number"
177
+ ? data.user.tz_offset
178
+ : undefined;
179
+
180
+ const info: SlackUserInfo = {
181
+ displayName,
182
+ username,
183
+ ...(timezone !== undefined ? { timezone } : {}),
184
+ ...(timezoneLabel !== undefined ? { timezoneLabel } : {}),
185
+ ...(timezoneOffsetSeconds !== undefined
186
+ ? { timezoneOffsetSeconds }
187
+ : {}),
188
+ };
146
189
  cacheSet(
147
190
  userInfoCache,
148
- userId,
191
+ cacheKey,
149
192
  info,
150
193
  USER_CACHE_TTL_MS,
151
194
  USER_CACHE_MAX_SIZE,
@@ -156,11 +199,11 @@ export async function resolveSlackUser(
156
199
  }
157
200
  })();
158
201
 
159
- inFlightUserFetches.set(userId, fetchPromise);
202
+ inFlightUserFetches.set(cacheKey, fetchPromise);
160
203
  try {
161
204
  return await fetchPromise;
162
205
  } finally {
163
- inFlightUserFetches.delete(userId);
206
+ inFlightUserFetches.delete(cacheKey);
164
207
  }
165
208
  }
166
209
 
@@ -175,10 +218,11 @@ export async function resolveSlackChannel(
175
218
  channelId: string,
176
219
  botToken: string,
177
220
  ): Promise<SlackChannelInfo | undefined> {
178
- const cached = cacheGet(channelInfoCache, channelId);
221
+ const cacheKey = slackChannelCacheKey(channelId, botToken);
222
+ const cached = cacheGet(channelInfoCache, cacheKey);
179
223
  if (cached) return cached;
180
224
 
181
- const existing = inFlightChannelFetches.get(channelId);
225
+ const existing = inFlightChannelFetches.get(cacheKey);
182
226
  if (existing) return existing;
183
227
 
184
228
  const fetchPromise = (async (): Promise<SlackChannelInfo | undefined> => {
@@ -207,7 +251,7 @@ export async function resolveSlackChannel(
207
251
  const info: SlackChannelInfo = { name };
208
252
  cacheSet(
209
253
  channelInfoCache,
210
- channelId,
254
+ cacheKey,
211
255
  info,
212
256
  CHANNEL_CACHE_TTL_MS,
213
257
  CHANNEL_CACHE_MAX_SIZE,
@@ -218,11 +262,11 @@ export async function resolveSlackChannel(
218
262
  }
219
263
  })();
220
264
 
221
- inFlightChannelFetches.set(channelId, fetchPromise);
265
+ inFlightChannelFetches.set(cacheKey, fetchPromise);
222
266
  try {
223
267
  return await fetchPromise;
224
268
  } finally {
225
- inFlightChannelFetches.delete(channelId);
269
+ inFlightChannelFetches.delete(cacheKey);
226
270
  }
227
271
  }
228
272
 
@@ -235,8 +279,9 @@ export function resolveSlackUserSync(
235
279
  userId: string,
236
280
  botToken: string,
237
281
  ): SlackUserInfo | undefined {
238
- const cached = cacheGet(userInfoCache, userId);
239
- if (!cached && !inFlightUserFetches.has(userId)) {
282
+ const cacheKey = slackUserCacheKey(userId, botToken);
283
+ const cached = cacheGet(userInfoCache, cacheKey);
284
+ if (!cached && !inFlightUserFetches.has(cacheKey)) {
240
285
  // Fire-and-forget: warm the cache for next time
241
286
  resolveSlackUser(userId, botToken).catch(() => {});
242
287
  }
@@ -395,6 +440,22 @@ function renderSlackInboundText(
395
440
  });
396
441
  }
397
442
 
443
+ export function slackUserActorFields(
444
+ userInfo: SlackUserInfo,
445
+ ): SlackUserActorFields {
446
+ return {
447
+ displayName: userInfo.displayName,
448
+ username: userInfo.username,
449
+ ...(userInfo.timezone !== undefined ? { timezone: userInfo.timezone } : {}),
450
+ ...(userInfo.timezoneLabel !== undefined
451
+ ? { timezoneLabel: userInfo.timezoneLabel }
452
+ : {}),
453
+ ...(userInfo.timezoneOffsetSeconds !== undefined
454
+ ? { timezoneOffsetSeconds: userInfo.timezoneOffsetSeconds }
455
+ : {}),
456
+ };
457
+ }
458
+
398
459
  function extractSlackAttachments(files: SlackFile[] | undefined): Array<{
399
460
  type: "image" | "document";
400
461
  fileId: string;
@@ -505,10 +566,7 @@ export function normalizeSlackDirectMessage(
505
566
  },
506
567
  actor: {
507
568
  actorExternalId: event.user,
508
- ...(userInfo && {
509
- displayName: userInfo.displayName,
510
- username: userInfo.username,
511
- }),
569
+ ...(userInfo ? slackUserActorFields(userInfo) : {}),
512
570
  },
513
571
  source: {
514
572
  updateId: eventId,
@@ -573,10 +631,7 @@ export function normalizeSlackChannelMessage(
573
631
  },
574
632
  actor: {
575
633
  actorExternalId: event.user,
576
- ...(userInfo && {
577
- displayName: userInfo.displayName,
578
- username: userInfo.username,
579
- }),
634
+ ...(userInfo ? slackUserActorFields(userInfo) : {}),
580
635
  },
581
636
  source: {
582
637
  updateId: eventId,
@@ -638,10 +693,7 @@ export function normalizeSlackAppMention(
638
693
  },
639
694
  actor: {
640
695
  actorExternalId: event.user,
641
- ...(userInfo && {
642
- displayName: userInfo.displayName,
643
- username: userInfo.username,
644
- }),
696
+ ...(userInfo ? slackUserActorFields(userInfo) : {}),
645
697
  },
646
698
  source: {
647
699
  updateId: eventId,
@@ -25,6 +25,7 @@ import {
25
25
  normalizeSlackReactionRemoved,
26
26
  resolveSlackChannel,
27
27
  resolveSlackUser,
28
+ slackUserActorFields,
28
29
  type SlackAppMentionEvent,
29
30
  type SlackDirectMessageEvent,
30
31
  type SlackChannelMessageEvent,
@@ -1018,8 +1019,7 @@ export class SlackSocketModeClient {
1018
1019
  ),
1019
1020
  ]);
1020
1021
  if (userInfo) {
1021
- actor.displayName = userInfo.displayName;
1022
- actor.username = userInfo.username;
1022
+ Object.assign(actor, slackUserActorFields(userInfo));
1023
1023
  }
1024
1024
  }
1025
1025