@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
@@ -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
  }
@@ -826,6 +826,53 @@ describe("Twilio webhook signature with canonical ingress base URL", () => {
826
826
  });
827
827
  });
828
828
 
829
+ test("uses platform callback assistant ID for Twilio websocket URL", async () => {
830
+ const assistantId = "019e2d0d-f355-744c-a12c-d7e7dcefcf1e";
831
+ fetchMock = mock(
832
+ async () =>
833
+ new Response(
834
+ '<?xml version="1.0" encoding="UTF-8"?><Response><Connect>' +
835
+ '<ConversationRelay url="wss://__VELLUM_PUBLIC_BASE_URL__/webhooks/twilio/relay?token=__VELLUM_RELAY_TOKEN__"/>' +
836
+ "</Connect></Response>",
837
+ {
838
+ status: 200,
839
+ headers: { "Content-Type": "text/xml" },
840
+ },
841
+ ),
842
+ );
843
+
844
+ const handler = createTwilioVoiceWebhookHandler(
845
+ makeConfig({ velayBaseUrl: "https://velay-staging.vellum.ai" }),
846
+ makeCaches(),
847
+ );
848
+
849
+ const platformCallbackUrl =
850
+ `https://staging-platform.vellum.ai/v1/gateway/callbacks/${assistantId}` +
851
+ "/webhooks/twilio/voice?callSessionId=platform-wss-test";
852
+ const localUrl =
853
+ "http://localhost:7830/webhooks/twilio/voice?callSessionId=platform-wss-test";
854
+ const params = { CallSid: "CA-platform-wss" };
855
+ const signature = computeSignature(platformCallbackUrl, params, AUTH_TOKEN);
856
+ const req = new Request(localUrl, {
857
+ method: "POST",
858
+ headers: {
859
+ "Content-Type": "application/x-www-form-urlencoded",
860
+ "X-Twilio-Signature": signature,
861
+ "X-Vellum-Ingress-URL": platformCallbackUrl,
862
+ },
863
+ body: new URLSearchParams(params).toString(),
864
+ });
865
+
866
+ const res = await handler(req);
867
+ expect(res.status).toBe(200);
868
+ const body = await res.text();
869
+ expect(body).toContain(
870
+ `url="wss://velay-staging.vellum.ai/${assistantId}/webhooks/twilio/relay?`,
871
+ );
872
+ expect(body).not.toContain("__VELLUM_PUBLIC_BASE_URL__");
873
+ expect(body).not.toContain("staging-platform.vellum.ai");
874
+ });
875
+
829
876
  test("platform proxy URL takes priority over configured ingress", async () => {
830
877
  fetchMock = mock(
831
878
  async () =>
@@ -223,6 +223,12 @@ const POLICY_TABLE: PolicyEntry[] = [
223
223
  ["updateHeartbeatConfig", ["settings.write"]],
224
224
 
225
225
  // Integrations / ingress
226
+ [
227
+ "integrations_a2a_invite_complete_post",
228
+ ["internal.write"],
229
+ ["svc_gateway"],
230
+ ],
231
+ ["integrations_a2a_invite_redeem_post", ["internal.write"], ["svc_gateway"]],
226
232
  ["integrations_ingress_config_get", ["settings.read"]],
227
233
  ["integrations_ingress_config_put", ["settings.write"]],
228
234
  ["integrations_oauth_start_post", ["settings.write"]],
@@ -10,7 +10,7 @@ import type { ChannelId } from "./types.js";
10
10
 
11
11
  export type InboundChannelId = Extract<
12
12
  ChannelId,
13
- "telegram" | "whatsapp" | "slack" | "email"
13
+ "telegram" | "whatsapp" | "slack" | "email" | "a2a"
14
14
  >;
15
15
 
16
16
  interface InboundEventBase<C extends InboundChannelId> {
@@ -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
  }
@@ -59,9 +63,11 @@ export type TelegramInboundEvent = InboundEventBase<"telegram">;
59
63
  export type WhatsAppInboundEvent = InboundEventBase<"whatsapp">;
60
64
  export type SlackInboundEvent = InboundEventBase<"slack">;
61
65
  export type EmailInboundEvent = InboundEventBase<"email">;
66
+ export type A2aInboundEvent = InboundEventBase<"a2a">;
62
67
 
63
68
  export type GatewayInboundEvent =
64
69
  | TelegramInboundEvent
65
70
  | WhatsAppInboundEvent
66
71
  | SlackInboundEvent
67
- | EmailInboundEvent;
72
+ | EmailInboundEvent
73
+ | A2aInboundEvent;
@@ -5,6 +5,7 @@ export const CHANNEL_IDS = [
5
5
  "whatsapp",
6
6
  "slack",
7
7
  "email",
8
+ "a2a",
8
9
  ] as const;
9
10
 
10
11
  export type ChannelId = (typeof CHANNEL_IDS)[number];
@@ -30,6 +31,7 @@ export const INTERFACE_IDS = [
30
31
  "whatsapp",
31
32
  "slack",
32
33
  "email",
34
+ "a2a",
33
35
  ] as const;
34
36
 
35
37
  export type InterfaceId = (typeof INTERFACE_IDS)[number];
@@ -15,6 +15,14 @@ import { getLogger } from "./logger.js";
15
15
  const log = getLogger("config-file-watcher");
16
16
 
17
17
  const DEBOUNCE_MS = 500;
18
+ const FALLBACK_POLL_MS = 1_000;
19
+
20
+ type IntervalHandle = ReturnType<typeof setInterval>;
21
+
22
+ type TimerApi = {
23
+ setInterval: (fn: () => void, delayMs: number) => IntervalHandle;
24
+ clearInterval: (timer: IntervalHandle) => void;
25
+ };
18
26
 
19
27
  export type ConfigChangeEvent = {
20
28
  /** Full parsed config.json data. */
@@ -32,16 +40,27 @@ export type ConfigChangeCallback = (event: ConfigChangeEvent) => void;
32
40
 
33
41
  export class ConfigFileWatcher {
34
42
  private watcher: FSWatcher | null = null;
43
+ private pollTimer: IntervalHandle | null = null;
35
44
  private watchingDirectory = false;
36
45
  private debounceTimer: ReturnType<typeof setTimeout> | null = null;
37
46
  private lastSerialized: Map<string, string> = new Map();
38
47
  private lastValues: Map<string, unknown> = new Map();
39
48
  private callback: ConfigChangeCallback;
40
49
  private configPath: string;
50
+ private pollIntervalMs: number;
51
+ private timerApi: TimerApi;
41
52
 
42
- constructor(callback: ConfigChangeCallback) {
53
+ constructor(
54
+ callback: ConfigChangeCallback,
55
+ opts?: { pollIntervalMs?: number; timerApi?: TimerApi },
56
+ ) {
43
57
  this.callback = callback;
44
58
  this.configPath = getConfigPath();
59
+ this.pollIntervalMs = opts?.pollIntervalMs ?? FALLBACK_POLL_MS;
60
+ this.timerApi = opts?.timerApi ?? {
61
+ setInterval,
62
+ clearInterval,
63
+ };
45
64
  }
46
65
 
47
66
  start(): void {
@@ -77,6 +96,8 @@ export class ConfigFileWatcher {
77
96
  "Failed to start config file watcher",
78
97
  );
79
98
  }
99
+
100
+ this.startFallbackPolling();
80
101
  }
81
102
 
82
103
  stop(): void {
@@ -84,12 +105,34 @@ export class ConfigFileWatcher {
84
105
  clearTimeout(this.debounceTimer);
85
106
  this.debounceTimer = null;
86
107
  }
108
+ if (this.pollTimer) {
109
+ this.timerApi.clearInterval(this.pollTimer);
110
+ this.pollTimer = null;
111
+ }
87
112
  if (this.watcher) {
88
113
  this.watcher.close();
89
114
  this.watcher = null;
90
115
  }
91
116
  }
92
117
 
118
+ private startFallbackPolling(): void {
119
+ if (this.pollIntervalMs <= 0 || this.pollTimer) return;
120
+
121
+ this.pollTimer = this.timerApi.setInterval(() => {
122
+ this.pollOnce();
123
+
124
+ if (this.watchingDirectory && existsSync(this.configPath)) {
125
+ this.upgradeWatcher();
126
+ }
127
+ }, this.pollIntervalMs);
128
+ (this.pollTimer as { unref?: () => void }).unref?.();
129
+
130
+ log.info(
131
+ { intervalMs: this.pollIntervalMs },
132
+ "Polling config file for missed change events",
133
+ );
134
+ }
135
+
93
136
  private scheduleCheck(): void {
94
137
  if (this.debounceTimer) {
95
138
  clearTimeout(this.debounceTimer);
@@ -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
@@ -10,11 +10,11 @@
10
10
  "defaultEnabled": false
11
11
  },
12
12
  {
13
- "id": "memory-retrospective",
13
+ "id": "memory-retrospective-fork",
14
14
  "scope": "assistant",
15
- "key": "memory-retrospective",
16
- "label": "Memory retrospective pass",
17
- "description": "Run a focused, memory-only retrospective during active conversations and at conversation lifecycle. The retrospective agent re-reads the messages added since the last successful run, dedupes against memory/archive/, and calls `remember` for what wasn't captured in the moment. Enabling this also relaxes the in-conversation pressure to call `remember` (lighter PKB reminder + relaxed tool description) so the assistant can stay present and trust the retrospective as the backstop.",
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
18
  "defaultEnabled": false
19
19
  },
20
20
  {
@@ -33,6 +33,14 @@
33
33
  "description": "When enabled, the Local hosting option uses Docker under the hood for sandboxed execution, hiding the separate Docker card",
34
34
  "defaultEnabled": false
35
35
  },
36
+ {
37
+ "id": "a2a-channel",
38
+ "scope": "assistant",
39
+ "key": "a2a-channel",
40
+ "label": "A2A Channel",
41
+ "description": "Enable the A2A (Agent-to-Agent) channel for inter-assistant communication via the open A2A protocol",
42
+ "defaultEnabled": false
43
+ },
36
44
  {
37
45
  "id": "email-channel",
38
46
  "scope": "assistant",
@@ -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",
@@ -145,6 +161,14 @@
145
161
  "description": "Show a speaker button on assistant messages to generate and play the message as audio via Fish Audio TTS",
146
162
  "defaultEnabled": false
147
163
  },
164
+ {
165
+ "id": "openai-compatible-endpoints",
166
+ "scope": "assistant",
167
+ "key": "openai-compatible-endpoints",
168
+ "label": "OpenAI-Compatible Endpoints",
169
+ "description": "Enable user-configured OpenAI-compatible inference endpoints with custom base URLs and model identifiers",
170
+ "defaultEnabled": false
171
+ },
148
172
  {
149
173
  "id": "backward-releases",
150
174
  "scope": "assistant",
@@ -267,7 +291,7 @@
267
291
  },
268
292
  {
269
293
  "id": "account-deletion",
270
- "scope": "client",
294
+ "scope": "assistant",
271
295
  "key": "account-deletion",
272
296
  "label": "Account Deletion",
273
297
  "description": "Surfaces the user-initiated account deletion flow in client settings.",
@@ -281,14 +305,6 @@
281
305
  "description": "Enable the app-control skill (per-app screenshot + raw input bypassing AX tree)",
282
306
  "defaultEnabled": false
283
307
  },
284
- {
285
- "id": "species-migration",
286
- "scope": "assistant",
287
- "key": "species-migration",
288
- "label": "Species Migration",
289
- "description": "Enable the Species Migration skill for migrating from OpenClaw, Hermes, Manus, and other assistant species into Vellum.",
290
- "defaultEnabled": false
291
- },
292
308
  {
293
309
  "id": "analyze-conversation",
294
310
  "scope": "assistant",
@@ -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.",
@@ -322,19 +338,91 @@
322
338
  "defaultEnabled": false
323
339
  },
324
340
  {
325
- "id": "provider-deepseek",
341
+ "id": "velvet-theme",
342
+ "scope": "client",
343
+ "key": "velvet-theme",
344
+ "label": "Velvet Theme",
345
+ "description": "Show the Velvet theme option in the macOS appearance settings. Velvet is a dark-mode variant with red/pink accent colors.",
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",
326
358
  "scope": "assistant",
327
- "key": "provider-deepseek",
328
- "label": "DeepSeek Provider",
329
- "description": "Enable the DeepSeek direct API provider and its models (V4 Pro, V4 Flash) in the provider picker and model selection UI",
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.",
330
370
  "defaultEnabled": false
331
371
  },
332
372
  {
333
- "id": "provider-minimax",
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",
334
414
  "scope": "assistant",
335
- "key": "provider-minimax",
336
- "label": "MiniMax Provider",
337
- "description": "Enable the MiniMax direct API provider and its models (M2.7, M2.5, M2.1, M2, and highspeed variants) in the provider picker and model selection UI",
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.",
338
426
  "defaultEnabled": false
339
427
  }
340
428
  ]
@@ -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 };