@vellumai/assistant 0.11.7-dev.202609011522.ce7ad01 → 0.11.7-dev.202609011637.5f3cbff

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 (27) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/access-request-seed-content-blocks.test.ts +2 -2
  3. package/src/__tests__/channel-delivery-store.test.ts +57 -0
  4. package/src/__tests__/channel-retry-sweep.test.ts +168 -0
  5. package/src/__tests__/external-binding-chat-name.test.ts +60 -0
  6. package/src/__tests__/provider-meta-persistence.test.ts +67 -0
  7. package/src/__tests__/tool-approval-seed-content-blocks.test.ts +16 -3
  8. package/src/daemon/conversation-messaging.ts +42 -0
  9. package/src/daemon/handlers/shared.ts +9 -0
  10. package/src/daemon/process-message.ts +61 -16
  11. package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +70 -1
  12. package/src/live-voice/live-voice-session.ts +3 -0
  13. package/src/live-voice/protocol.ts +20 -1
  14. package/src/messaging/providers/slack/approval-source.test.ts +70 -0
  15. package/src/messaging/providers/slack/approval-source.ts +18 -5
  16. package/src/notifications/__tests__/guardian-feed-projection.test.ts +9 -1
  17. package/src/notifications/approval-card-data.ts +13 -5
  18. package/src/notifications/guardian-feed-projection.ts +9 -11
  19. package/src/notifications/guardian-question-mode.ts +33 -1
  20. package/src/persistence/delivery-crud.ts +51 -1
  21. package/src/persistence/external-conversation-store.ts +5 -1
  22. package/src/persistence/migrations/374-channel-inbound-message-id-index.ts +26 -0
  23. package/src/persistence/steps.ts +2 -0
  24. package/src/runtime/approval-source-link.ts +6 -0
  25. package/src/runtime/channel-retry-sweep.ts +90 -0
  26. package/src/runtime/routes/inbound-message-handler.ts +26 -0
  27. package/src/runtime/routes/inbound-stages/background-dispatch.ts +20 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.7-dev.202609011522.ce7ad01",
3
+ "version": "0.11.7-dev.202609011637.5f3cbff",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -90,7 +90,7 @@ describe("buildAccessRequestSeedContentBlocks", () => {
90
90
  });
91
91
  expect(data.metadata).toContainEqual({
92
92
  label: "Source",
93
- value: "Slack — #C01ABC",
93
+ value: "Slack · #C01ABC",
94
94
  });
95
95
  });
96
96
 
@@ -101,7 +101,7 @@ describe("buildAccessRequestSeedContentBlocks", () => {
101
101
  });
102
102
  expect(surfaceOf(blocks).data.metadata).toContainEqual({
103
103
  label: "Source",
104
- value: "Slack — Direct message",
104
+ value: "Slack · Direct message",
105
105
  });
106
106
  });
107
107
 
@@ -180,6 +180,63 @@ describe("channel-delivery-store", () => {
180
180
  ).toBeNull();
181
181
  });
182
182
 
183
+ test("linked inbound rows are excluded from the provider-id scan; crash-window rows resolve", () => {
184
+ const chatId = "chat-scan-budget";
185
+ const minted = recordInbound("telegram", chatId, "evt-linked", {
186
+ sourceMessageId: "tg-linked-1",
187
+ });
188
+ const envelope = (messageId: string) =>
189
+ JSON.stringify({
190
+ providerMeta: JSON.stringify({
191
+ source: "telegram",
192
+ conversationExternalId: chatId,
193
+ messageId,
194
+ eventKind: "message",
195
+ }),
196
+ });
197
+
198
+ // A normally-ingested inbound row: linked to its event, so the
199
+ // inbound-event index owns its resolution and the fallback scan must
200
+ // not spend budget on it.
201
+ insertMessage("linked-user-row", minted.conversationId, {});
202
+ getDb()
203
+ .update(messages)
204
+ .set({ metadata: envelope("tg-linked-1") })
205
+ .where(eq(messages.id, "linked-user-row"))
206
+ .run();
207
+ linkMessage(minted.eventId, "linked-user-row");
208
+ expect(
209
+ findConversationByProviderMessageId("telegram", chatId, "tg-linked-1"),
210
+ ).toBeNull();
211
+
212
+ // A crash-window row: persisted with its envelope but its event link
213
+ // never landed. The fallback is the only path that can resolve it.
214
+ insertMessage("orphan-user-row", minted.conversationId, {});
215
+ getDb()
216
+ .update(messages)
217
+ .set({ metadata: envelope("tg-orphan-1") })
218
+ .where(eq(messages.id, "orphan-user-row"))
219
+ .run();
220
+ expect(
221
+ findConversationByProviderMessageId("telegram", chatId, "tg-orphan-1"),
222
+ ).toBe(minted.conversationId);
223
+
224
+ // A legacy linked row whose event carries no source_message_id:
225
+ // findMessageBySourceId matches only that column, so the fallback must
226
+ // still admit the row or reactions to it become unresolvable.
227
+ const legacy = recordInbound("telegram", chatId, "evt-legacy");
228
+ insertMessage("legacy-user-row", legacy.conversationId, {});
229
+ getDb()
230
+ .update(messages)
231
+ .set({ metadata: envelope("tg-legacy-1") })
232
+ .where(eq(messages.id, "legacy-user-row"))
233
+ .run();
234
+ linkMessage(legacy.eventId, "legacy-user-row");
235
+ expect(
236
+ findConversationByProviderMessageId("telegram", chatId, "tg-legacy-1"),
237
+ ).toBe(legacy.conversationId);
238
+ });
239
+
183
240
  test("same chat on same channel reuses the same conversation", () => {
184
241
  const r1 = recordInbound("telegram", "chat-1", "msg-1");
185
242
  const r2 = recordInbound("telegram", "chat-1", "msg-2");
@@ -236,6 +236,55 @@ function seedFailedSlackEvent(opts: {
236
236
  return inbound.eventId;
237
237
  }
238
238
 
239
+ function seedFailedTelegramEvent(opts: {
240
+ content: string;
241
+ externalChatId: string;
242
+ messageId: string;
243
+ threadId?: string;
244
+ /** Sender display name as `storePayload` records it at ingress. */
245
+ senderName?: string;
246
+ /** Stands in for the `channelInbound` the live path captures onto the payload. */
247
+ channelInbound?: Record<string, unknown>;
248
+ }): string {
249
+ const inbound = deliveryCrud.recordInbound(
250
+ "telegram",
251
+ opts.externalChatId,
252
+ opts.messageId,
253
+ );
254
+ deliveryCrud.storePayload(inbound.eventId, {
255
+ content: opts.content,
256
+ sourceChannel: "telegram",
257
+ interface: "telegram",
258
+ externalChatId: opts.externalChatId,
259
+ externalMessageId: opts.messageId,
260
+ sourceMetadata: {
261
+ messageId: opts.messageId,
262
+ ...(opts.threadId ? { threadId: opts.threadId } : {}),
263
+ },
264
+ ...(opts.senderName ? { senderName: opts.senderName } : {}),
265
+ ...(opts.channelInbound ? { channelInbound: opts.channelInbound } : {}),
266
+ trustCtx: {
267
+ trustClass: "guardian",
268
+ sourceChannel: "telegram",
269
+ requesterExternalUserId: "user-1",
270
+ requesterChatId: opts.externalChatId,
271
+ guardianPrincipalId: "principal-1",
272
+ },
273
+ });
274
+
275
+ getDb()
276
+ .update(channelInboundEvents)
277
+ .set({
278
+ processingStatus: "failed",
279
+ processingAttempts: 1,
280
+ retryAfter: Date.now() - 1,
281
+ })
282
+ .where(eq(channelInboundEvents.id, inbound.eventId))
283
+ .run();
284
+
285
+ return inbound.eventId;
286
+ }
287
+
239
288
  describe("channel-retry-sweep", () => {
240
289
  beforeEach(() => {
241
290
  resetTables();
@@ -442,6 +491,125 @@ describe("channel-retry-sweep", () => {
442
491
  expect(capturedOptions?.slackInbound?.channelTs).toBe("1700000000.000200");
443
492
  });
444
493
 
494
+ test("replays a captured channelInbound envelope verbatim", async () => {
495
+ const captured = {
496
+ source: "telegram",
497
+ conversationExternalId: "chat-500",
498
+ messageId: "tg-msg-1",
499
+ threadId: "topic-3",
500
+ actorExternalId: "user-77",
501
+ displayName: "Alice",
502
+ eventKind: "message",
503
+ };
504
+ seedFailedTelegramEvent({
505
+ content: "retry me",
506
+ externalChatId: "chat-500",
507
+ messageId: "tg-msg-1",
508
+ channelInbound: captured,
509
+ });
510
+
511
+ let capturedOptions:
512
+ | { channelInbound?: Record<string, unknown> }
513
+ | undefined;
514
+ await sweepFailedEvents(async (conversationId, content, options) => {
515
+ capturedOptions = options as {
516
+ channelInbound?: Record<string, unknown>;
517
+ };
518
+ const messageId = "message-tg-captured";
519
+ getDb()
520
+ .insert(messages)
521
+ .values({
522
+ id: messageId,
523
+ conversationId,
524
+ role: "user",
525
+ content: JSON.stringify([{ type: "text", text: content }]),
526
+ createdAt: Date.now(),
527
+ })
528
+ .run();
529
+ return { messageId };
530
+ });
531
+
532
+ // The captured envelope is the EXACT object the live turn used, so the
533
+ // replayed row persists an identical providerMeta.
534
+ expect(capturedOptions?.channelInbound).toEqual(captured);
535
+ });
536
+
537
+ test("reconstructs channelInbound from the stored payload when no capture exists", async () => {
538
+ // The crash window: `storePayload` ran at ingress but the daemon died
539
+ // before `storeInboundChannelMetadata`. Recovery must rebuild the same
540
+ // envelope the live turn would have persisted, sender fields included.
541
+ seedFailedTelegramEvent({
542
+ content: "retry me",
543
+ externalChatId: "chat-501",
544
+ messageId: "tg-msg-2",
545
+ threadId: "topic-9",
546
+ senderName: "Alice",
547
+ });
548
+
549
+ let capturedOptions:
550
+ | { channelInbound?: Record<string, unknown> }
551
+ | undefined;
552
+ await sweepFailedEvents(async (conversationId, content, options) => {
553
+ capturedOptions = options as {
554
+ channelInbound?: Record<string, unknown>;
555
+ };
556
+ const messageId = "message-tg-fallback";
557
+ getDb()
558
+ .insert(messages)
559
+ .values({
560
+ id: messageId,
561
+ conversationId,
562
+ role: "user",
563
+ content: JSON.stringify([{ type: "text", text: content }]),
564
+ createdAt: Date.now(),
565
+ })
566
+ .run();
567
+ return { messageId };
568
+ });
569
+
570
+ expect(capturedOptions?.channelInbound).toEqual({
571
+ source: "telegram",
572
+ conversationExternalId: "chat-501",
573
+ messageId: "tg-msg-2",
574
+ threadId: "topic-9",
575
+ displayName: "Alice",
576
+ actorExternalId: "user-1",
577
+ eventKind: "message",
578
+ });
579
+ });
580
+
581
+ test("a Slack replay carries no channelInbound (Slack keeps slackMeta)", async () => {
582
+ seedFailedSlackEvent({
583
+ trustClass: "guardian",
584
+ content: "hello",
585
+ externalChatId: "C0000001",
586
+ channelTs: "1700000001.000100",
587
+ });
588
+
589
+ let capturedOptions:
590
+ | { channelInbound?: Record<string, unknown> }
591
+ | undefined;
592
+ await sweepFailedEvents(async (conversationId, content, options) => {
593
+ capturedOptions = options as {
594
+ channelInbound?: Record<string, unknown>;
595
+ };
596
+ const messageId = "message-slack-no-neutral";
597
+ getDb()
598
+ .insert(messages)
599
+ .values({
600
+ id: messageId,
601
+ conversationId,
602
+ role: "user",
603
+ content: JSON.stringify([{ type: "text", text: content }]),
604
+ createdAt: Date.now(),
605
+ })
606
+ .run();
607
+ return { messageId };
608
+ });
609
+
610
+ expect(capturedOptions?.channelInbound).toBeUndefined();
611
+ });
612
+
445
613
  test("replays the sender's Slack app context from the captured slackInbound", async () => {
446
614
  seedFailedSlackEvent({
447
615
  trustClass: "guardian",
@@ -0,0 +1,60 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { createConversation } from "../persistence/conversation-crud.js";
4
+ import { initializeDb } from "../persistence/db-init.js";
5
+ import {
6
+ getBindingByConversation,
7
+ upsertBinding,
8
+ } from "../persistence/external-conversation-store.js";
9
+
10
+ await initializeDb();
11
+
12
+ async function boundConversation(): Promise<string> {
13
+ const conversation = await createConversation({ title: "t" });
14
+ upsertBinding({
15
+ conversationId: conversation.id,
16
+ sourceChannel: "slack",
17
+ externalChatId: "C0123CHANNEL",
18
+ externalChatName: "user-feedback",
19
+ });
20
+ return conversation.id;
21
+ }
22
+
23
+ describe("upsertBinding chat-name preservation", () => {
24
+ test("a nameless upsert for the same chat keeps the stored name", async () => {
25
+ const conversationId = await boundConversation();
26
+ upsertBinding({
27
+ conversationId,
28
+ sourceChannel: "slack",
29
+ externalChatId: "C0123CHANNEL",
30
+ });
31
+ expect(getBindingByConversation(conversationId)?.externalChatName).toBe(
32
+ "user-feedback",
33
+ );
34
+ });
35
+
36
+ test("a move to a nameless chat clears the stored name", async () => {
37
+ const conversationId = await boundConversation();
38
+ upsertBinding({
39
+ conversationId,
40
+ sourceChannel: "slack",
41
+ externalChatId: "D0MOVEDHERE",
42
+ });
43
+ const binding = getBindingByConversation(conversationId);
44
+ expect(binding?.externalChatId).toBe("D0MOVEDHERE");
45
+ expect(binding?.externalChatName ?? null).toBeNull();
46
+ });
47
+
48
+ test("a move that reports a name takes the new name", async () => {
49
+ const conversationId = await boundConversation();
50
+ upsertBinding({
51
+ conversationId,
52
+ sourceChannel: "slack",
53
+ externalChatId: "C0ELSEWHERE",
54
+ externalChatName: "new-home",
55
+ });
56
+ expect(getBindingByConversation(conversationId)?.externalChatName).toBe(
57
+ "new-home",
58
+ );
59
+ });
60
+ });
@@ -0,0 +1,67 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { buildProviderMetaForPersistence } from "../daemon/conversation-messaging.js";
4
+ import type { ProviderMessageMetadata } from "../messaging/provider-message-metadata.js";
5
+ import { readProviderMetadata } from "../messaging/read-provider-metadata.js";
6
+
7
+ const telegramInbound: ProviderMessageMetadata = {
8
+ source: "telegram",
9
+ conversationExternalId: "chat-42",
10
+ messageId: "msg-100",
11
+ threadId: "topic-7",
12
+ actorExternalId: "user-9",
13
+ displayName: "Alice",
14
+ eventKind: "message",
15
+ };
16
+
17
+ describe("buildProviderMetaForPersistence", () => {
18
+ test("a matching non-Slack turn persists an envelope the neutral reader round-trips", () => {
19
+ const serialized = buildProviderMetaForPersistence({
20
+ channelInbound: telegramInbound,
21
+ turnChannel: "telegram",
22
+ });
23
+ expect(serialized).not.toBeNull();
24
+
25
+ // The stored row's metadata JSON carries the envelope under the
26
+ // `providerMeta` key; the canonical reader must resolve it.
27
+ const rowMetadata = JSON.stringify({
28
+ userMessageChannel: "telegram",
29
+ providerMeta: serialized,
30
+ });
31
+ const read = readProviderMetadata(rowMetadata);
32
+ expect(read).toEqual(telegramInbound);
33
+ });
34
+
35
+ test("a Slack turn never gains a providerMeta key", () => {
36
+ expect(
37
+ buildProviderMetaForPersistence({
38
+ channelInbound: { ...telegramInbound, source: "slack" },
39
+ turnChannel: "slack",
40
+ }),
41
+ ).toBeNull();
42
+ });
43
+
44
+ test("a turn channel that does not match the envelope's source is refused", () => {
45
+ expect(
46
+ buildProviderMetaForPersistence({
47
+ channelInbound: telegramInbound,
48
+ turnChannel: "discord",
49
+ }),
50
+ ).toBeNull();
51
+ expect(
52
+ buildProviderMetaForPersistence({
53
+ channelInbound: telegramInbound,
54
+ turnChannel: undefined,
55
+ }),
56
+ ).toBeNull();
57
+ });
58
+
59
+ test("an absent envelope persists nothing", () => {
60
+ expect(
61
+ buildProviderMetaForPersistence({
62
+ channelInbound: undefined,
63
+ turnChannel: "telegram",
64
+ }),
65
+ ).toBeNull();
66
+ });
67
+ });
@@ -214,7 +214,7 @@ describe("buildToolApprovalSeedContentBlocks", () => {
214
214
  );
215
215
  expect(surface.data.metadata).toContainEqual({
216
216
  label: "Source",
217
- value: "Slack — Direct message",
217
+ value: "Slack · Direct message",
218
218
  });
219
219
  });
220
220
 
@@ -232,7 +232,20 @@ describe("buildToolApprovalSeedContentBlocks", () => {
232
232
  );
233
233
  expect(surface.data.metadata).toContainEqual({
234
234
  label: "Source",
235
- value: "Slack — #C01ABC",
235
+ value: "Slack · #C01ABC",
236
+ });
237
+ });
238
+
239
+ test("Slack channel source prefers the captured channel name", () => {
240
+ const payload = {
241
+ ...toolApprovalPayload,
242
+ sourceChatId: "C01ABC",
243
+ sourceChatName: "user-feedback",
244
+ };
245
+ const surface = surfaceBlock(buildToolApprovalSeedContentBlocks(payload)!);
246
+ expect(surface.data.metadata).toContainEqual({
247
+ label: "Source",
248
+ value: "Slack · #user-feedback",
236
249
  });
237
250
  });
238
251
 
@@ -245,7 +258,7 @@ describe("buildToolApprovalSeedContentBlocks", () => {
245
258
  expect(surface.data.body).not.toContain("[View message]");
246
259
  expect(surface.data.metadata).toContainEqual({
247
260
  label: "Source",
248
- value: "Slack — Direct message",
261
+ value: "Slack · Direct message",
249
262
  });
250
263
  });
251
264
 
@@ -27,6 +27,10 @@ import {
27
27
  parseInterfaceId,
28
28
  } from "../channels/types.js";
29
29
  import { parseImageDimensions } from "../context/image-dimensions.js";
30
+ import {
31
+ type ProviderMessageMetadata,
32
+ providerMessageMetadataSchema,
33
+ } from "../messaging/provider-message-metadata.js";
30
34
  import {
31
35
  buildSlackTimezoneMetadata,
32
36
  type SlackMessageMetadata,
@@ -748,6 +752,37 @@ export function buildSlackMetaForPersistence(params: {
748
752
  return writeSlackMetadata(slackMeta);
749
753
  }
750
754
 
755
+ /**
756
+ * Build the neutral channel envelope persisted under the `providerMeta` key
757
+ * on a user message's `metadata` JSON, the non-Slack counterpart of
758
+ * {@link buildSlackMetaForPersistence}. Returns `null` (do not include the
759
+ * key) when the turn channel does not match the envelope's own `source`, so
760
+ * a stale plumbing field can never tag a row with another channel's
761
+ * identity. Slack turns also return `null`: Slack still writes `slackMeta`,
762
+ * which `readProviderMetadata` maps onto the neutral shape on read, and a
763
+ * `providerMeta` key on a Slack row would shadow that richer envelope.
764
+ *
765
+ * TRANSITIONAL: the Slack exclusion exists only while Slack writes its own
766
+ * envelope; do not extend it.
767
+ */
768
+ export function buildProviderMetaForPersistence(params: {
769
+ channelInbound: ProviderMessageMetadata | undefined;
770
+ turnChannel: string | undefined;
771
+ }): string | null {
772
+ const inbound = params.channelInbound;
773
+ if (!inbound) {
774
+ return null;
775
+ }
776
+ if (params.turnChannel !== inbound.source || inbound.source === "slack") {
777
+ return null;
778
+ }
779
+ const parsed = providerMessageMetadataSchema.safeParse(inbound);
780
+ if (!parsed.success) {
781
+ return null;
782
+ }
783
+ return JSON.stringify(parsed.data);
784
+ }
785
+
751
786
  // ── EnqueueMessageOptions ────────────────────────────────────────────
752
787
 
753
788
  /** Options for `enqueueMessage`. Only `content` is required; everything
@@ -1119,11 +1154,13 @@ export async function persistQueuedMessageBody(
1119
1154
  // never reported.
1120
1155
  const {
1121
1156
  slackInbound: rawSlackInbound,
1157
+ channelInbound: rawChannelInbound,
1122
1158
  scripted: rawScriptedFromMetadata,
1123
1159
  clientOsFromRequest: _rawClientOsFromRequest,
1124
1160
  ...metadataWithoutSlackInbound
1125
1161
  } = (metadata ?? {}) as Record<string, unknown> & {
1126
1162
  slackInbound?: SlackInboundMessageMetadata;
1163
+ channelInbound?: ProviderMessageMetadata;
1127
1164
  scripted?: unknown;
1128
1165
  clientOsFromRequest?: unknown;
1129
1166
  };
@@ -1131,6 +1168,10 @@ export async function persistQueuedMessageBody(
1131
1168
  slackInbound: rawSlackInbound,
1132
1169
  turnChannel: turnCtx?.userMessageChannel,
1133
1170
  });
1171
+ const providerMeta = buildProviderMetaForPersistence({
1172
+ channelInbound: rawChannelInbound,
1173
+ turnChannel: turnCtx?.userMessageChannel,
1174
+ });
1134
1175
 
1135
1176
  // See the `scripted` note on the merged metadata below. Only a real
1136
1177
  // boolean in the bag counts: a stray truthy string must not be read as a
@@ -1210,6 +1251,7 @@ export async function persistQueuedMessageBody(
1210
1251
  ...(clientOsFromRequest ? { clientOsFromRequest: true } : {}),
1211
1252
  ...(imageSourcePaths ? { imageSourcePaths } : {}),
1212
1253
  ...(slackMeta ? { slackMeta } : {}),
1254
+ ...(providerMeta ? { providerMeta } : {}),
1213
1255
  // Scripted-turn marker, forwarded by `turn-events-store` onto
1214
1256
  // `TurnTelemetryEvent.scripted`. Written LAST so it cannot be
1215
1257
  // half-overwritten by the raw metadata spread above.
@@ -15,6 +15,7 @@ import { ConfirmationDecisionSchema } from "../../api/responses/conversation-mes
15
15
  import { getConfig } from "../../config/loader.js";
16
16
  import type { LLMCallSite, Speed } from "../../config/schemas/llm.js";
17
17
  import { ipcCall as gatewayIpcCall } from "../../ipc/gateway-client.js";
18
+ import type { ProviderMessageMetadata } from "../../messaging/provider-message-metadata.js";
18
19
  import type { SecretPromptResult } from "../../permissions/secret-prompt-types.js";
19
20
  import type { ConversationCreateType } from "../../persistence/conversation-types.js";
20
21
  import { resolveMediaSourceData } from "../../providers/media-resolve.js";
@@ -223,6 +224,14 @@ export interface ConversationCreateOptions {
223
224
  * chronological renderer to consume.
224
225
  */
225
226
  slackInbound?: SlackInboundMessageMetadata;
227
+ /**
228
+ * Neutral per-row channel envelope captured at the channel ingress
229
+ * boundary, the non-Slack counterpart of `slackInbound`. When present (and
230
+ * the turn channel matches its `source`), persistence writes it as the
231
+ * message's `providerMeta` metadata key so the stored row can say which
232
+ * external message it is, in which thread, from whom.
233
+ */
234
+ channelInbound?: ProviderMessageMetadata;
226
235
  /**
227
236
  * Conversation type for newly created conversations. When omitted,
228
237
  * defaults to `"standard"` (visible in the sidebar). Set to
@@ -38,6 +38,7 @@ import {
38
38
  import { getLogger } from "../util/logger.js";
39
39
  import type { Conversation } from "./conversation.js";
40
40
  import {
41
+ buildProviderMetaForPersistence,
41
42
  buildSlackMetaForPersistence,
42
43
  CONVERSATION_BUSY_MESSAGE,
43
44
  serializePersistedUserMessageContent,
@@ -148,6 +149,43 @@ export function deriveIngressIdempotencyKey(options?: {
148
149
  return undefined;
149
150
  }
150
151
 
152
+ /**
153
+ * The transient carrier bag `persistUserMessage` strips back out of the
154
+ * metadata: the channel ingress envelopes threaded through options.
155
+ * Undefined when the turn carries neither, so non-channel turns persist no
156
+ * extra keys.
157
+ */
158
+ function buildIngressCarrierMetadata(
159
+ options?: ProcessMessageOptions,
160
+ ): Record<string, unknown> | undefined {
161
+ if (!options?.slackInbound && !options?.channelInbound) {
162
+ return undefined;
163
+ }
164
+ return {
165
+ ...(options.slackInbound ? { slackInbound: options.slackInbound } : {}),
166
+ ...(options.channelInbound
167
+ ? { channelInbound: options.channelInbound }
168
+ : {}),
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Layer the turn's persisted channel envelopes onto a metadata object:
174
+ * `slackMeta` for Slack turns, `providerMeta` for every other channel.
175
+ * At most one is non-null per turn.
176
+ */
177
+ function withChannelEnvelopes(
178
+ base: Record<string, unknown>,
179
+ slackMeta: string | null,
180
+ providerMeta: string | null,
181
+ ): Record<string, unknown> {
182
+ return {
183
+ ...base,
184
+ ...(slackMeta ? { slackMeta } : {}),
185
+ ...(providerMeta ? { providerMeta } : {}),
186
+ };
187
+ }
188
+
151
189
  function buildEventEmitter(
152
190
  observer?: (msg: AssistantEvent) => void,
153
191
  ): (msg: AssistantEvent) => void {
@@ -432,9 +470,14 @@ export async function processMessage(
432
470
  });
433
471
  const slashResult = await resolveSlash(content, slashContext);
434
472
 
473
+ const turnChannel = conversation.getTurnChannelContext()?.userMessageChannel;
435
474
  const slackMeta = buildSlackMetaForPersistence({
436
475
  slackInbound: options?.slackInbound,
437
- turnChannel: conversation.getTurnChannelContext()?.userMessageChannel,
476
+ turnChannel,
477
+ });
478
+ const providerMeta = buildProviderMetaForPersistence({
479
+ channelInbound: options?.channelInbound,
480
+ turnChannel,
438
481
  });
439
482
 
440
483
  if (slashResult.kind === "unknown") {
@@ -469,9 +512,11 @@ export async function processMessage(
469
512
  : {}),
470
513
  ...(Object.keys(imageSourcePaths).length > 0 ? { imageSourcePaths } : {}),
471
514
  };
472
- const userMetaWithSlack = slackMeta
473
- ? { ...serverChannelMeta, slackMeta }
474
- : serverChannelMeta;
515
+ const userMetaWithSlack = withChannelEnvelopes(
516
+ serverChannelMeta,
517
+ slackMeta,
518
+ providerMeta,
519
+ );
475
520
  const cleanMsg = await createUserMessage(content, attachments);
476
521
  const llmMsg = enrichMessageWithSourcePaths(cleanMsg, attachments);
477
522
  const persisted = await addMessage(
@@ -566,9 +611,11 @@ export async function processMessage(
566
611
  }
567
612
  : {}),
568
613
  };
569
- const compactUserMeta = slackMeta
570
- ? { ...compactChannelMeta, slackMeta }
571
- : compactChannelMeta;
614
+ const compactUserMeta = withChannelEnvelopes(
615
+ compactChannelMeta,
616
+ slackMeta,
617
+ providerMeta,
618
+ );
572
619
  const cleanMsg = await createUserMessage(content, attachments);
573
620
  const persisted = await addMessage(
574
621
  conversationId,
@@ -624,9 +671,11 @@ export async function processMessage(
624
671
  }
625
672
  : {}),
626
673
  };
627
- const cleanUserMeta = slackMeta
628
- ? { ...cleanChannelMeta, slackMeta }
629
- : cleanChannelMeta;
674
+ const cleanUserMeta = withChannelEnvelopes(
675
+ cleanChannelMeta,
676
+ slackMeta,
677
+ providerMeta,
678
+ );
630
679
  const cleanMsg = await createUserMessage(content, attachments);
631
680
  const persisted = await addMessage(
632
681
  conversationId,
@@ -660,9 +709,7 @@ export async function processMessage(
660
709
  const resolvedContent = slashResult.content;
661
710
 
662
711
  const requestId = uuidv7();
663
- const persistMetadata = options?.slackInbound
664
- ? { slackInbound: options.slackInbound }
665
- : undefined;
712
+ const persistMetadata = buildIngressCarrierMetadata(options);
666
713
  const ingressKey = deriveIngressIdempotencyKey(options);
667
714
  const { id: messageId, deduplicated } = await conversation.persistUserMessage(
668
715
  {
@@ -743,9 +790,7 @@ export async function processMessageInBackground(
743
790
  const emitEvent = buildEventEmitter(options?.onEvent);
744
791
 
745
792
  const requestId = uuidv7();
746
- const persistMetadata = options?.slackInbound
747
- ? { slackInbound: options.slackInbound }
748
- : undefined;
793
+ const persistMetadata = buildIngressCarrierMetadata(options);
749
794
  const ingressKey = deriveIngressIdempotencyKey(options);
750
795
  const { id: messageId, deduplicated } = await conversation.persistUserMessage(
751
796
  {