@vellumai/vellum-gateway 0.5.15 → 0.5.16

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.5.15",
3
+ "version": "0.5.16",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -76,7 +76,7 @@ async function startGateway(): Promise<void> {
76
76
  stdio: ["ignore", "pipe", "pipe"],
77
77
  });
78
78
 
79
- const deadline = Date.now() + 5_000;
79
+ const deadline = Date.now() + 10_000;
80
80
  while (Date.now() < deadline) {
81
81
  try {
82
82
  const res = await fetch(`http://localhost:${gatewayPort}/healthz`);
@@ -86,7 +86,7 @@ async function startGateway(): Promise<void> {
86
86
  }
87
87
  await new Promise((resolve) => setTimeout(resolve, 100));
88
88
  }
89
- throw new Error("Gateway failed to start within 5 seconds");
89
+ throw new Error("Gateway failed to start within 10 seconds");
90
90
  }
91
91
 
92
92
  function startFakeCes(opts: {
@@ -139,7 +139,7 @@ afterEach(() => {
139
139
  cesPort = 0;
140
140
 
141
141
  if (gatewayProc) {
142
- gatewayProc.kill();
142
+ gatewayProc.kill("SIGKILL");
143
143
  gatewayProc = null;
144
144
  }
145
145
 
@@ -176,7 +176,7 @@ describe("gateway managed credential bootstrap retry", () => {
176
176
  }
177
177
 
178
178
  expect(status).toBe(401);
179
- }, 15_000);
179
+ }, 20_000);
180
180
 
181
181
  test("keeps retrying until configured credential reads succeed after CES list is already available", async () => {
182
182
  mkdirSync(testDir, { recursive: true });
@@ -220,5 +220,5 @@ describe("gateway managed credential bootstrap retry", () => {
220
220
  }
221
221
 
222
222
  expect(status).toBe(401);
223
- }, 15_000);
223
+ }, 20_000);
224
224
  });
@@ -326,6 +326,32 @@ describe("normalizeSlackMessageEdit", () => {
326
326
  expect(result!.threadTs).toBe("1700000000.000100");
327
327
  });
328
328
 
329
+ test("DM edit without thread_ts omits threadTs", () => {
330
+ const config = makeConfig();
331
+ const event = makeMessageChangedEvent({ channel_type: "im" });
332
+ const result = normalizeSlackMessageEdit(event, "evt-dm-edit-1", config);
333
+
334
+ expect(result).not.toBeNull();
335
+ expect(result!.threadTs).toBeUndefined();
336
+ });
337
+
338
+ test("DM edit with thread_ts preserves threadTs", () => {
339
+ const config = makeConfig();
340
+ const event = makeMessageChangedEvent({
341
+ channel_type: "im",
342
+ message: {
343
+ user: "U_USER123",
344
+ text: "edited hello world",
345
+ ts: "1700000000.000100",
346
+ thread_ts: "1700000000.000050",
347
+ },
348
+ });
349
+ const result = normalizeSlackMessageEdit(event, "evt-dm-edit-2", config);
350
+
351
+ expect(result).not.toBeNull();
352
+ expect(result!.threadTs).toBe("1700000000.000050");
353
+ });
354
+
329
355
  test("DM edits use default assistant when channel is not in routing table", () => {
330
356
  const config = makeConfig({
331
357
  unmappedPolicy: "reject",
@@ -14,6 +14,7 @@ import type { Scope, ScopeProfile } from "./types.js";
14
14
 
15
15
  const PROFILE_SCOPES: Record<ScopeProfile, ReadonlySet<Scope>> = {
16
16
  actor_client_v1: new Set<Scope>([
17
+ "admin.write",
17
18
  "chat.read",
18
19
  "chat.write",
19
20
  "approval.read",
package/src/auth/types.ts CHANGED
@@ -33,6 +33,7 @@ export type Scope =
33
33
  | "calls.write"
34
34
  | "ingress.write"
35
35
  | "internal.write"
36
+ | "admin.write"
36
37
  | "feature_flags.read"
37
38
  | "feature_flags.write"
38
39
  | "local.all";
package/src/config.ts CHANGED
@@ -39,7 +39,8 @@ type RoutingEntry = {
39
39
 
40
40
  /**
41
41
  * Read the workspace config file at startup to populate gateway operational
42
- * settings. The CLI writes these values before starting the gateway.
42
+ * settings. In Docker, the daemon writes these values. In local mode, the
43
+ * CLI passes them via env vars (which take precedence in loadConfig()).
43
44
  */
44
45
  function readWorkspaceConfig(): Record<string, unknown> {
45
46
  try {
@@ -105,24 +106,46 @@ export function loadConfig(): GatewayConfig {
105
106
 
106
107
  const gatewayInternalBaseUrl = `http://127.0.0.1:${port}`;
107
108
 
108
- // Read operational settings from workspace config. The CLI writes these
109
- // before spawning the gateway (see cli/src/lib/local.ts writeGatewayConfig).
109
+ // Read operational settings from workspace config (Docker) or env vars (CLI).
110
110
  const wsConfig = readWorkspaceConfig();
111
111
  const gw = (wsConfig.gateway ?? {}) as Record<string, unknown>;
112
112
 
113
+ // Env vars take precedence over workspace config values. This allows the
114
+ // CLI to pass gateway settings directly via the process environment instead
115
+ // of writing to the workspace config file.
113
116
  const runtimeProxyEnabled =
117
+ process.env.RUNTIME_PROXY_ENABLED === "true" ||
114
118
  gw.runtimeProxyEnabled === true ||
115
- gw.runtimeProxyEnabled === "true" ||
116
- process.env.RUNTIME_PROXY_ENABLED === "true";
119
+ gw.runtimeProxyEnabled === "true";
117
120
  const runtimeProxyRequireAuth =
118
- gw.runtimeProxyRequireAuth !== false &&
119
- gw.runtimeProxyRequireAuth !== "false";
120
- const unmappedPolicy = gw.unmappedPolicy === "default" ? "default" : "reject";
121
+ process.env.RUNTIME_PROXY_REQUIRE_AUTH !== undefined
122
+ ? process.env.RUNTIME_PROXY_REQUIRE_AUTH !== "false"
123
+ : gw.runtimeProxyRequireAuth !== false &&
124
+ gw.runtimeProxyRequireAuth !== "false";
125
+ const unmappedPolicyEnv = process.env.UNMAPPED_POLICY?.trim();
126
+ const unmappedPolicy: "reject" | "default" =
127
+ unmappedPolicyEnv === "default" || unmappedPolicyEnv === "reject"
128
+ ? unmappedPolicyEnv
129
+ : gw.unmappedPolicy === "default"
130
+ ? "default"
131
+ : "reject";
121
132
  const defaultAssistantId =
122
- typeof gw.defaultAssistantId === "string" && gw.defaultAssistantId
133
+ process.env.DEFAULT_ASSISTANT_ID?.trim() ||
134
+ (typeof gw.defaultAssistantId === "string" && gw.defaultAssistantId
123
135
  ? gw.defaultAssistantId
124
- : undefined;
125
- const routingEntries = parseRoutingEntries(gw.routingEntries);
136
+ : undefined);
137
+ let routingEntries: RoutingEntry[] = [];
138
+ if (process.env.ROUTING_ENTRIES) {
139
+ try {
140
+ routingEntries = parseRoutingEntries(
141
+ JSON.parse(process.env.ROUTING_ENTRIES),
142
+ );
143
+ } catch {
144
+ log.warn("Invalid JSON in ROUTING_ENTRIES env var — ignoring");
145
+ }
146
+ } else {
147
+ routingEntries = parseRoutingEntries(gw.routingEntries);
148
+ }
126
149
 
127
150
  const logFile: LogFileConfig = {
128
151
  dir: undefined,
@@ -25,6 +25,14 @@
25
25
  "description": "Enable the Vellum Cloud hosting option on the Hosting screen",
26
26
  "defaultEnabled": false
27
27
  },
28
+ {
29
+ "id": "local-docker-enabled",
30
+ "scope": "macos",
31
+ "key": "local-docker-enabled",
32
+ "label": "Local Docker Mode",
33
+ "description": "When enabled, the Local hosting option uses Docker under the hood for sandboxed execution, hiding the separate Docker card",
34
+ "defaultEnabled": false
35
+ },
28
36
  {
29
37
  "id": "contacts",
30
38
  "scope": "assistant",
@@ -360,6 +368,14 @@
360
368
  "label": "Fast Mode",
361
369
  "description": "Enable Anthropic fast mode for Opus 4.6, delivering up to 2.5x higher output tokens per second at premium pricing",
362
370
  "defaultEnabled": false
371
+ },
372
+ {
373
+ "id": "outlook-oauth-integration",
374
+ "scope": "assistant",
375
+ "key": "outlook-oauth-integration",
376
+ "label": "Outlook / Microsoft Integration",
377
+ "description": "Enable the Outlook / Microsoft OAuth provider for connecting to Microsoft Graph APIs (email, calendar)",
378
+ "defaultEnabled": false
363
379
  }
364
380
  ]
365
381
  }
package/src/index.ts CHANGED
@@ -89,6 +89,7 @@ import {
89
89
  } from "./slack/socket-mode.js";
90
90
  import { downloadSlackFile } from "./slack/download.js";
91
91
  import { fetchThreadContext } from "./slack/thread-context.js";
92
+ import { fetchDmContext } from "./slack/dm-context.js";
92
93
  import { handleInbound } from "./handlers/handle-inbound.js";
93
94
  import { checkAuthRateLimit } from "./http/middleware/rate-limit.js";
94
95
  import {
@@ -780,7 +781,8 @@ async function main() {
780
781
  {
781
782
  path: "/v1/admin/upgrade-broadcast",
782
783
  method: "POST",
783
- auth: "edge",
784
+ auth: "edge-scoped",
785
+ scope: "admin.write",
784
786
  handler: (req) => upgradeBroadcastProxy(req),
785
787
  },
786
788
 
@@ -802,7 +804,8 @@ async function main() {
802
804
  {
803
805
  path: "/v1/admin/workspace-commit",
804
806
  method: "POST",
805
- auth: "edge",
807
+ auth: "edge-scoped",
808
+ scope: "admin.write",
806
809
  handler: (req) => workspaceCommitProxy(req),
807
810
  },
808
811
 
@@ -810,7 +813,8 @@ async function main() {
810
813
  {
811
814
  path: "/v1/admin/rollback-migrations",
812
815
  method: "POST",
813
- auth: "edge",
816
+ auth: "edge-scoped",
817
+ scope: "admin.write",
814
818
  handler: (req) => migrationRollbackProxy(req),
815
819
  },
816
820
 
@@ -1208,7 +1212,13 @@ async function main() {
1208
1212
  { appToken, botToken, gatewayConfig: config },
1209
1213
  (normalized) => {
1210
1214
  const { threadTs, channel } = normalized;
1211
- const replyCallbackUrl = `${config.gatewayInternalBaseUrl}/deliver/slack?threadTs=${encodeURIComponent(threadTs)}&channel=${encodeURIComponent(channel)}`;
1215
+ const params = new URLSearchParams({ channel });
1216
+ if (threadTs) params.set("threadTs", threadTs);
1217
+ // For non-threaded DMs, pass the original message ts so the runtime
1218
+ // can target it for emoji-based thinking indicators.
1219
+ const origMessageTs = normalized.event.source.messageId;
1220
+ if (!threadTs && origMessageTs) params.set("messageTs", origMessageTs);
1221
+ const replyCallbackUrl = `${config.gatewayInternalBaseUrl}/deliver/slack?${params}`;
1212
1222
 
1213
1223
  // Check if this is a regular thread reply (not an edit or callback action).
1214
1224
  // Edits and callbacks don't benefit from thread context and would just add
@@ -1245,10 +1255,7 @@ async function main() {
1245
1255
 
1246
1256
  // Filter oversized attachments
1247
1257
  const eligible = eventAttachments.filter((att) => {
1248
- if (
1249
- att.fileSize !== undefined &&
1250
- att.fileSize > maxBytes
1251
- ) {
1258
+ if (att.fileSize !== undefined && att.fileSize > maxBytes) {
1252
1259
  log.warn(
1253
1260
  {
1254
1261
  fileId: att.fileId,
@@ -1345,7 +1352,7 @@ async function main() {
1345
1352
  }
1346
1353
  };
1347
1354
 
1348
- if (isThreadReply && botToken) {
1355
+ if (isThreadReply && botToken && threadTs) {
1349
1356
  fetchThreadContext(channel, threadTs, messageTs, botToken)
1350
1357
  .then((context) => context ?? undefined)
1351
1358
  .catch(() => undefined)
@@ -1356,6 +1363,23 @@ async function main() {
1356
1363
  "Unhandled error in Slack forward (thread reply)",
1357
1364
  );
1358
1365
  });
1366
+ } else if (
1367
+ channel.startsWith("D") &&
1368
+ botToken &&
1369
+ messageTs &&
1370
+ !isEdit &&
1371
+ !isCallback
1372
+ ) {
1373
+ fetchDmContext(channel, messageTs, botToken)
1374
+ .then((context) => context ?? undefined)
1375
+ .catch(() => undefined)
1376
+ .then((context) => forward(context))
1377
+ .catch((err) => {
1378
+ log.error(
1379
+ { err, channel },
1380
+ "Unhandled error in Slack forward (DM context)",
1381
+ );
1382
+ });
1359
1383
  } else {
1360
1384
  forward().catch((err) => {
1361
1385
  log.error(
@@ -551,7 +551,12 @@ export async function uploadAttachment(
551
551
  ): Promise<UploadAttachmentResponse> {
552
552
  const skipCb = opts?.skipCircuitBreaker === true;
553
553
 
554
- if (!skipCb) cbBeforeRequest();
554
+ // Always check the breaker for fail-fast (OPEN/HALF_OPEN rejection).
555
+ // skipCb only suppresses success/failure accounting so attachment errors
556
+ // don't trip the breaker — but half-open probes must always record their
557
+ // outcome to avoid getting stuck in HALF_OPEN permanently.
558
+ const isProbe = cbBeforeRequest();
559
+ const recordOutcome = !skipCb || isProbe;
555
560
 
556
561
  const url = `${config.assistantRuntimeBaseUrl}/v1/attachments`;
557
562
 
@@ -566,7 +571,7 @@ export async function uploadAttachment(
566
571
  signal: AbortSignal.timeout(config.runtimeTimeoutMs),
567
572
  });
568
573
  } catch (err) {
569
- if (!skipCb) cbOnFailure();
574
+ if (recordOutcome) cbOnFailure();
570
575
  throw err;
571
576
  }
572
577
 
@@ -576,16 +581,16 @@ export async function uploadAttachment(
576
581
  // extension, missing fields). Distinguish from transient 5xx/network errors
577
582
  // so callers can decide whether to skip or propagate.
578
583
  if (response.status >= 400 && response.status < 500) {
579
- if (!skipCb) cbOnSuccess();
584
+ if (recordOutcome) cbOnSuccess();
580
585
  throw new AttachmentValidationError(
581
586
  `Attachment rejected (${response.status}): ${body}`,
582
587
  );
583
588
  }
584
- if (!skipCb) cbOnFailure();
589
+ if (recordOutcome) cbOnFailure();
585
590
  throw new Error(`Attachment upload failed (${response.status}): ${body}`);
586
591
  }
587
592
 
588
- if (!skipCb) cbOnSuccess();
593
+ if (recordOutcome) cbOnSuccess();
589
594
  return (await response.json()) as UploadAttachmentResponse;
590
595
  }
591
596
 
package/src/schema.ts CHANGED
@@ -3126,6 +3126,11 @@ export function buildSchema(): Record<string, unknown> {
3126
3126
  type: "array",
3127
3127
  items: { type: "string" },
3128
3128
  },
3129
+ callback_transport: {
3130
+ type: "string",
3131
+ enum: ["loopback", "gateway"],
3132
+ description: "OAuth callback transport. Defaults to loopback.",
3133
+ },
3129
3134
  },
3130
3135
  },
3131
3136
  OAuthConnectDeferredResponse: {
@@ -0,0 +1,170 @@
1
+ import { describe, it, expect, beforeEach, mock } from "bun:test";
2
+
3
+ type FetchFn = (
4
+ input: string | URL | Request,
5
+ init?: RequestInit,
6
+ ) => Promise<Response>;
7
+ let fetchMock: ReturnType<typeof mock<FetchFn>> = mock(
8
+ async () => new Response(),
9
+ );
10
+
11
+ mock.module("../fetch.js", () => ({
12
+ fetchImpl: (...args: Parameters<FetchFn>) => fetchMock(...args),
13
+ }));
14
+
15
+ const { fetchDmContext } = await import("./dm-context.js");
16
+ const { clearUserInfoCache, clearInFlightFetches } =
17
+ await import("./normalize.js");
18
+
19
+ function mockSlackApi(
20
+ historyMessages: Array<{
21
+ user?: string;
22
+ text?: string;
23
+ ts?: string;
24
+ bot_id?: string;
25
+ username?: string;
26
+ }>,
27
+ userNames: Record<string, string> = {},
28
+ ) {
29
+ fetchMock = mock(async (url: string | URL | Request) => {
30
+ const urlStr = typeof url === "string" ? url : url.toString();
31
+ if (urlStr.includes("conversations.history")) {
32
+ return Response.json({ ok: true, messages: historyMessages });
33
+ }
34
+ if (urlStr.includes("users.info")) {
35
+ const userId = new URL(urlStr).searchParams.get("user");
36
+ const name = userNames[userId!] ?? userId;
37
+ return Response.json({
38
+ ok: true,
39
+ user: { name: userId, profile: { display_name: name } },
40
+ });
41
+ }
42
+ return Response.json({ ok: false });
43
+ });
44
+ }
45
+
46
+ describe("fetchDmContext", () => {
47
+ beforeEach(() => {
48
+ clearUserInfoCache();
49
+ clearInFlightFetches();
50
+ });
51
+
52
+ it("returns formatted context with prior messages", async () => {
53
+ // Slack returns newest-first; include a bot message
54
+ mockSlackApi(
55
+ [
56
+ {
57
+ user: "UBOT",
58
+ text: "Here's what I found",
59
+ ts: "1000.2",
60
+ bot_id: "B123",
61
+ },
62
+ { user: "U001", text: "Can you look this up?", ts: "1000.1" },
63
+ { user: "U001", text: "Hey there", ts: "1000.0" },
64
+ ],
65
+ { U001: "Alice", UBOT: "Assistant" },
66
+ );
67
+
68
+ const result = await fetchDmContext(
69
+ "D123",
70
+ "1000.3", // current message ts, excluded from context
71
+ "xoxb-test-token",
72
+ );
73
+
74
+ expect(result).toContain(
75
+ "Recent messages in this DM conversation (3 prior messages)",
76
+ );
77
+ // Should be in chronological order (oldest first)
78
+ const aliceHeyIdx = result!.indexOf("[Alice]: Hey there");
79
+ const aliceCanIdx = result!.indexOf("[Alice]: Can you look this up?");
80
+ const botIdx = result!.indexOf("[Assistant]: Here's what I found");
81
+ expect(aliceHeyIdx).toBeLessThan(aliceCanIdx);
82
+ expect(aliceCanIdx).toBeLessThan(botIdx);
83
+ });
84
+
85
+ it("excludes the current message", async () => {
86
+ mockSlackApi(
87
+ [
88
+ { user: "U001", text: "This is the current message", ts: "1000.1" },
89
+ { user: "U002", text: "Earlier message", ts: "1000.0" },
90
+ ],
91
+ { U001: "Alice", U002: "Bob" },
92
+ );
93
+
94
+ const result = await fetchDmContext(
95
+ "D123",
96
+ "1000.1", // matches first message — should be excluded
97
+ "xoxb-test-token",
98
+ );
99
+
100
+ expect(result).toContain("1 prior messages");
101
+ expect(result).toContain("[Bob]: Earlier message");
102
+ expect(result).not.toContain("This is the current message");
103
+ });
104
+
105
+ it("returns null when no prior messages", async () => {
106
+ // Only the current message in history
107
+ mockSlackApi([
108
+ { user: "U001", text: "Hello!", ts: "1000.0" },
109
+ ]);
110
+
111
+ const result = await fetchDmContext(
112
+ "D123",
113
+ "1000.0", // matches the only message
114
+ "xoxb-test-token",
115
+ );
116
+
117
+ expect(result).toBeNull();
118
+ });
119
+
120
+ it("returns null on API error", async () => {
121
+ fetchMock = mock(async () =>
122
+ Response.json({ ok: false, error: "channel_not_found" }),
123
+ );
124
+
125
+ const result = await fetchDmContext(
126
+ "D123",
127
+ "1000.0",
128
+ "xoxb-test-token",
129
+ );
130
+
131
+ expect(result).toBeNull();
132
+ });
133
+
134
+ it("returns null on HTTP error", async () => {
135
+ fetchMock = mock(async () => new Response("Server Error", { status: 500 }));
136
+
137
+ const result = await fetchDmContext(
138
+ "D123",
139
+ "1000.0",
140
+ "xoxb-test-token",
141
+ );
142
+
143
+ expect(result).toBeNull();
144
+ });
145
+
146
+ it("reverses API order to chronological", async () => {
147
+ // Slack API returns newest-first
148
+ mockSlackApi(
149
+ [
150
+ { user: "U001", text: "Third message", ts: "1000.2" },
151
+ { user: "U001", text: "Second message", ts: "1000.1" },
152
+ { user: "U001", text: "First message", ts: "1000.0" },
153
+ ],
154
+ { U001: "Alice" },
155
+ );
156
+
157
+ const result = await fetchDmContext(
158
+ "D123",
159
+ "9999.0", // current message not in list
160
+ "xoxb-test-token",
161
+ );
162
+
163
+ // Verify chronological order in output
164
+ const firstIdx = result!.indexOf("First message");
165
+ const secondIdx = result!.indexOf("Second message");
166
+ const thirdIdx = result!.indexOf("Third message");
167
+ expect(firstIdx).toBeLessThan(secondIdx);
168
+ expect(secondIdx).toBeLessThan(thirdIdx);
169
+ });
170
+ });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Fetches recent DM history for inbound Slack direct messages so the
3
+ * assistant has context about prior messages in the conversation.
4
+ *
5
+ * Uses `conversations.history` to retrieve recent messages and formats
6
+ * them as a human-readable context string suitable for transport hints.
7
+ */
8
+
9
+ import { fetchImpl } from "../fetch.js";
10
+ import { getLogger } from "../logger.js";
11
+ import { resolveSlackUser } from "./normalize.js";
12
+
13
+ const log = getLogger("slack-dm-context");
14
+
15
+ /** Maximum number of prior messages to include in context. */
16
+ const MAX_CONTEXT_MESSAGES = 10;
17
+
18
+ /**
19
+ * Over-fetch so the current message (which is filtered out) doesn't
20
+ * reduce the effective context window below MAX_CONTEXT_MESSAGES.
21
+ */
22
+ const FETCH_LIMIT = 15;
23
+
24
+ /** Timeout for the conversations.history API call. */
25
+ const FETCH_TIMEOUT_MS = 5_000;
26
+
27
+ interface SlackDmMessage {
28
+ user?: string;
29
+ text?: string;
30
+ ts?: string;
31
+ bot_id?: string;
32
+ username?: string;
33
+ }
34
+
35
+ /**
36
+ * Fetch recent DM history for a Slack direct-message channel.
37
+ *
38
+ * Returns a formatted string containing recent prior messages, or null
39
+ * if the fetch fails or there are no prior messages.
40
+ *
41
+ * @param channel - Slack DM channel ID (starts with "D")
42
+ * @param currentMessageTs - The current message's ts (excluded from context)
43
+ * @param botToken - Bot OAuth token for API calls
44
+ */
45
+ export async function fetchDmContext(
46
+ channel: string,
47
+ currentMessageTs: string,
48
+ botToken: string,
49
+ ): Promise<string | null> {
50
+ try {
51
+ const controller = new AbortController();
52
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
53
+
54
+ const params = new URLSearchParams({
55
+ channel,
56
+ limit: String(FETCH_LIMIT),
57
+ });
58
+
59
+ const resp = await fetchImpl(
60
+ `https://slack.com/api/conversations.history?${params.toString()}`,
61
+ {
62
+ method: "GET",
63
+ headers: { Authorization: `Bearer ${botToken}` },
64
+ signal: controller.signal,
65
+ },
66
+ );
67
+
68
+ clearTimeout(timeout);
69
+
70
+ if (!resp.ok) {
71
+ log.debug(
72
+ { status: resp.status, channel },
73
+ "conversations.history HTTP error",
74
+ );
75
+ return null;
76
+ }
77
+
78
+ const data = (await resp.json()) as {
79
+ ok?: boolean;
80
+ messages?: SlackDmMessage[];
81
+ error?: string;
82
+ };
83
+
84
+ if (!data.ok || !data.messages?.length) {
85
+ log.debug(
86
+ { error: data.error, channel },
87
+ "conversations.history returned no messages",
88
+ );
89
+ return null;
90
+ }
91
+
92
+ // Exclude the current message from context
93
+ const priorMessages = data.messages.filter(
94
+ (msg) => msg.ts !== currentMessageTs,
95
+ );
96
+
97
+ if (priorMessages.length === 0) return null;
98
+
99
+ // Slack returns newest-first; reverse to chronological order
100
+ priorMessages.reverse();
101
+
102
+ // Cap at MAX_CONTEXT_MESSAGES (take the most recent N)
103
+ const contextMessages =
104
+ priorMessages.length <= MAX_CONTEXT_MESSAGES
105
+ ? priorMessages
106
+ : priorMessages.slice(-MAX_CONTEXT_MESSAGES);
107
+
108
+ // Resolve user display names (best-effort, uses cache)
109
+ const formattedMessages = await Promise.all(
110
+ contextMessages.map(async (msg) => {
111
+ let authorLabel: string;
112
+ if (msg.user) {
113
+ const userInfo = await resolveSlackUser(msg.user, botToken).catch(
114
+ () => undefined,
115
+ );
116
+ authorLabel = userInfo?.displayName ?? msg.user;
117
+ } else {
118
+ authorLabel = msg.username ?? "Unknown";
119
+ }
120
+
121
+ const text = msg.text?.trim() || "(no text)";
122
+ return `[${authorLabel}]: ${text}`;
123
+ }),
124
+ );
125
+
126
+ return `Recent messages in this DM conversation (${contextMessages.length} prior messages):\n\n${formattedMessages.join("\n\n")}`;
127
+ } catch (err) {
128
+ // AbortError from timeout or any other fetch failure — non-fatal
129
+ log.debug(
130
+ { err, channel },
131
+ "Failed to fetch DM context (non-fatal)",
132
+ );
133
+ return null;
134
+ }
135
+ }
@@ -260,6 +260,26 @@ describe("normalizeSlackReactionAdded", () => {
260
260
  });
261
261
  });
262
262
 
263
+ describe("DM threading", () => {
264
+ it("non-threaded DM has no threadTs", () => {
265
+ const config = makeConfig();
266
+ const event = makeDmEvent();
267
+ const result = normalizeSlackDirectMessage(event, "evt-dm-1", config);
268
+
269
+ expect(result).not.toBeNull();
270
+ expect(result!.threadTs).toBeUndefined();
271
+ });
272
+
273
+ it("threaded DM preserves threadTs", () => {
274
+ const config = makeConfig();
275
+ const event = makeDmEvent({ thread_ts: "1700000000.000050" });
276
+ const result = normalizeSlackDirectMessage(event, "evt-dm-2", config);
277
+
278
+ expect(result).not.toBeNull();
279
+ expect(result!.threadTs).toBe("1700000000.000050");
280
+ });
281
+ });
282
+
263
283
  // --- Attachment extraction tests ---
264
284
 
265
285
  function makeSlackFile(overrides?: Partial<SlackFile>): SlackFile {
@@ -291,7 +291,7 @@ export type NormalizedSlackEvent = {
291
291
  event: GatewayInboundEvent;
292
292
  routing: RouteResult;
293
293
  /** Thread timestamp for reply threading. */
294
- threadTs: string;
294
+ threadTs?: string;
295
295
  /** Slack channel ID. */
296
296
  channel: string;
297
297
  /** Original Slack file objects keyed by file ID, for download in the I/O layer. */
@@ -379,7 +379,7 @@ export function normalizeSlackDirectMessage(
379
379
  raw: event as unknown as Record<string, unknown>,
380
380
  },
381
381
  routing,
382
- threadTs: event.thread_ts ?? event.ts,
382
+ ...(event.thread_ts ? { threadTs: event.thread_ts } : {}),
383
383
  channel: event.channel,
384
384
  ...(slackFiles ? { slackFiles } : {}),
385
385
  };
@@ -754,8 +754,9 @@ export function normalizeSlackMessageEdit(
754
754
  raw: event as unknown as Record<string, unknown>,
755
755
  },
756
756
  routing,
757
- // Fall back to the original message ts, not the wrapper event ts
758
- threadTs: edited.thread_ts ?? edited.ts,
757
+ // For DMs without a thread, omit threadTs so the reply goes directly in conversation.
758
+ // For channels (or DMs already in a thread), fall back to edited.ts.
759
+ ...(isDm && !edited.thread_ts ? {} : { threadTs: edited.thread_ts ?? edited.ts }),
759
760
  channel: event.channel,
760
761
  };
761
762
  }