@vellumai/assistant 0.11.3-dev.202608110433.eba8e13 → 0.11.3-dev.202608111333.7065e2a

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/assistant",
3
- "version": "0.11.3-dev.202608110433.eba8e13",
3
+ "version": "0.11.3-dev.202608111333.7065e2a",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -504,23 +504,23 @@ describe("channel-delivery-store", () => {
504
504
  expect(r1.conversationId).not.toBe(r2.conversationId);
505
505
  });
506
506
 
507
- test("same chat/channel but different assistantId uses different conversations", () => {
508
- const r1 = recordInbound("telegram", "chat-1", "msg-1", {
509
- assistantId: "asst-A",
510
- });
511
- const r2 = recordInbound("telegram", "chat-1", "msg-2", {
512
- assistantId: "asst-B",
513
- });
507
+ test("the same chat keeps the same conversation", () => {
508
+ const r1 = recordInbound("telegram", "chat-1", "msg-1");
509
+ const r2 = recordInbound("telegram", "chat-1", "msg-2");
514
510
 
515
- expect(r1.conversationId).not.toBe(r2.conversationId);
511
+ expect(r1.conversationId).toBe(r2.conversationId);
516
512
  });
517
513
 
518
- test("no assistantId defaults to self-scoped key", () => {
519
- const r1 = recordInbound("telegram", "chat-1", "msg-1");
520
- const r2 = recordInbound("telegram", "chat-1", "msg-2", {
521
- assistantId: "self",
522
- });
523
- expect(r1.conversationId).toBe(r2.conversationId);
514
+ test("binds under the self-scoped key", () => {
515
+ // The prefix is a stored format, not an internal detail. It reads like a
516
+ // leftover of the assistant id that used to be interpolated into it, so
517
+ // pin it: rows already written under `asst:self:` stop resolving if a
518
+ // later tidy-up drops it.
519
+ const { conversationId } = recordInbound("telegram", "chat-1", "msg-1");
520
+
521
+ expect(
522
+ getConversationByKey("asst:self:telegram:chat-1")?.conversationId,
523
+ ).toBe(conversationId);
524
524
  });
525
525
 
526
526
  test("external bindings allow multiple Slack thread anchors per channel", () => {
@@ -145,7 +145,6 @@ describe("Slack edit propagation", () => {
145
145
  conversationExternalId: seeded.conversationExternalId,
146
146
  externalMessageId: nextEditEventId(),
147
147
  sourceMessageId: seeded.channelTs,
148
- canonicalAssistantId: "self",
149
148
  assistantId: "self",
150
149
  content: "new text",
151
150
  });
@@ -187,7 +186,6 @@ describe("Slack edit propagation", () => {
187
186
  externalMessageId: nextEditEventId(),
188
187
  sourceMessageId: seeded.channelTs,
189
188
  sourceThreadId: threadTs,
190
- canonicalAssistantId: "self",
191
189
  assistantId: "self",
192
190
  content: "new text",
193
191
  });
@@ -216,7 +214,6 @@ describe("Slack edit propagation", () => {
216
214
  conversationExternalId: seeded.conversationExternalId,
217
215
  externalMessageId: nextEditEventId(),
218
216
  sourceMessageId: seeded.channelTs,
219
- canonicalAssistantId: "self",
220
217
  assistantId: "self",
221
218
  content: "first edit",
222
219
  });
@@ -239,7 +236,6 @@ describe("Slack edit propagation", () => {
239
236
  conversationExternalId: seeded.conversationExternalId,
240
237
  externalMessageId: nextEditEventId(),
241
238
  sourceMessageId: seeded.channelTs,
242
- canonicalAssistantId: "self",
243
239
  assistantId: "self",
244
240
  content: "second edit",
245
241
  });
@@ -272,7 +268,6 @@ describe("Slack edit propagation", () => {
272
268
  conversationExternalId: seeded.conversationExternalId,
273
269
  externalMessageId: nextEditEventId(),
274
270
  sourceMessageId: seeded.channelTs,
275
- canonicalAssistantId: "self",
276
271
  assistantId: "self",
277
272
  // Same text as stored -- simulates a Slack unfurl `message_changed`
278
273
  // where only attachments changed.
@@ -313,7 +308,6 @@ describe("Slack edit propagation", () => {
313
308
  conversationExternalId: seeded.conversationExternalId,
314
309
  externalMessageId: nextEditEventId(),
315
310
  sourceMessageId: seeded.channelTs,
316
- canonicalAssistantId: "self",
317
311
  assistantId: "self",
318
312
  content: "edited searchable text",
319
313
  });
@@ -341,7 +335,6 @@ describe("Slack edit propagation", () => {
341
335
  // sourceMessageId points at a ts that was never stored.
342
336
  externalMessageId: nextEditEventId(),
343
337
  sourceMessageId: "9999.0000",
344
- canonicalAssistantId: "self",
345
338
  assistantId: "self",
346
339
  content: "new text",
347
340
  });
@@ -5,12 +5,15 @@ import {
5
5
  ensureConversationExists,
6
6
  getConversation,
7
7
  } from "../persistence/conversation-crud.js";
8
+ import { getConversationByKey } from "../persistence/conversation-key-store.js";
8
9
  import {
9
10
  isEchoSuppressedUserMessage,
10
11
  isReplyPushIneligibleUserMessage,
11
12
  } from "../persistence/conversation-types.js";
12
13
  import { getDb } from "../persistence/db-connection.js";
13
14
  import { initializeDb } from "../persistence/db-init.js";
15
+ import { buildScopedConversationKey } from "../persistence/delivery-crud.js";
16
+ import { getBindingByConversation } from "../persistence/external-conversation-store.js";
14
17
 
15
18
  await initializeDb();
16
19
 
@@ -37,6 +40,7 @@ mock.module("../runtime/sync/resource-sync-events.js", () => ({
37
40
  let lastProcessMessageConversationId: string | undefined;
38
41
  let lastProcessMessageOptions: Record<string, unknown> | undefined;
39
42
  let lastEnqueueOptions: Record<string, unknown> | undefined;
43
+ let lastTurnChannelContext: unknown;
40
44
  let conversationIsProcessing = false;
41
45
  mock.module("../daemon/conversation-store.js", () => ({
42
46
  getOrCreateConversation: async (
@@ -57,6 +61,9 @@ mock.module("../daemon/conversation-store.js", () => ({
57
61
  }
58
62
  return {
59
63
  abortController: undefined,
64
+ setTurnChannelContext: (ctx: unknown) => {
65
+ lastTurnChannelContext = ctx;
66
+ },
60
67
  isProcessing: () => conversationIsProcessing,
61
68
  async processMessage(processOptions: Record<string, unknown>) {
62
69
  // The row must already exist by the time the turn persists its user
@@ -95,6 +102,7 @@ describe("runConversationTurn persistence", () => {
95
102
  lastProcessMessageConversationId = undefined;
96
103
  lastProcessMessageOptions = undefined;
97
104
  lastEnqueueOptions = undefined;
105
+ lastTurnChannelContext = undefined;
98
106
  conversationIsProcessing = false;
99
107
  });
100
108
 
@@ -193,3 +201,217 @@ describe("runConversationTurn provenance", () => {
193
201
  expect(isReplyPushIneligibleUserMessage(metadata)).toBe(true);
194
202
  });
195
203
  });
204
+
205
+ /**
206
+ * Addressing a turn by chat rather than by conversation id.
207
+ *
208
+ * The point of these is not that the turn runs (that is the same code path
209
+ * either way) but that the conversation it runs in is the one the rest of
210
+ * the assistant already addresses by those coordinates. A caller keeping its
211
+ * own chat-to-conversation map would pass every test above and still be
212
+ * invisible to conversation reset, to the deny lanes, and to the channel
213
+ * metadata the conversation list renders.
214
+ */
215
+ describe("runConversationTurn channel binding", () => {
216
+ const CHANNEL = {
217
+ sourceChannel: "plugin" as const,
218
+ externalChatId: "imessage:+12025550142",
219
+ displayName: "Ada",
220
+ };
221
+
222
+ beforeEach(() => {
223
+ const db = getDb();
224
+ db.run("DELETE FROM messages");
225
+ db.run("DELETE FROM external_conversation_bindings");
226
+ db.run("DELETE FROM conversation_keys");
227
+ db.run("DELETE FROM conversations");
228
+ listChangedCalls.length = 0;
229
+ lastProcessMessageOptions = undefined;
230
+ lastEnqueueOptions = undefined;
231
+ lastTurnChannelContext = undefined;
232
+ conversationIsProcessing = false;
233
+ });
234
+
235
+ test("resolves the chat to the conversation inbound would have used", async () => {
236
+ const result = await runConversationTurn({
237
+ channel: CHANNEL,
238
+ content: [{ type: "text", text: "hello" }],
239
+ });
240
+
241
+ // The public name for this conversation, which is what reset and the deny
242
+ // lanes address it by.
243
+ expect(
244
+ getConversationByKey(
245
+ buildScopedConversationKey(
246
+ CHANNEL.sourceChannel,
247
+ CHANNEL.externalChatId,
248
+ ),
249
+ )?.conversationId,
250
+ ).toBe(result.conversationId);
251
+ });
252
+
253
+ test("keeps the same chat in the same conversation across turns", async () => {
254
+ const first = await runConversationTurn({
255
+ channel: CHANNEL,
256
+ content: [{ type: "text", text: "hello" }],
257
+ });
258
+ const second = await runConversationTurn({
259
+ channel: CHANNEL,
260
+ content: [{ type: "text", text: "still me" }],
261
+ });
262
+
263
+ expect(second.conversationId).toBe(first.conversationId);
264
+ // Only the first turn minted a conversation.
265
+ expect(listChangedCalls).toEqual([
266
+ { kind: "created", conversationId: first.conversationId },
267
+ ]);
268
+ });
269
+
270
+ test("gives a different chat its own conversation", async () => {
271
+ const ada = await runConversationTurn({
272
+ channel: CHANNEL,
273
+ content: [{ type: "text", text: "hello" }],
274
+ });
275
+ const grace = await runConversationTurn({
276
+ channel: { ...CHANNEL, externalChatId: "imessage:+12025550188" },
277
+ content: [{ type: "text", text: "hello" }],
278
+ });
279
+
280
+ expect(grace.conversationId).not.toBe(ada.conversationId);
281
+ });
282
+
283
+ test("records the channel metadata the conversation list reads", async () => {
284
+ const result = await runConversationTurn({
285
+ channel: { ...CHANNEL, externalUserId: "+12025550142" },
286
+ content: [{ type: "text", text: "hello" }],
287
+ });
288
+
289
+ const binding = getBindingByConversation(result.conversationId);
290
+ expect(binding).toMatchObject({
291
+ sourceChannel: "plugin",
292
+ externalChatId: CHANNEL.externalChatId,
293
+ externalUserId: "+12025550142",
294
+ displayName: "Ada",
295
+ });
296
+ });
297
+
298
+ test("follows the vendor when a display name changes", async () => {
299
+ await runConversationTurn({
300
+ channel: CHANNEL,
301
+ content: [{ type: "text", text: "hello" }],
302
+ });
303
+ const result = await runConversationTurn({
304
+ channel: { ...CHANNEL, displayName: "Ada L." },
305
+ content: [{ type: "text", text: "renamed" }],
306
+ });
307
+
308
+ expect(getBindingByConversation(result.conversationId)?.displayName).toBe(
309
+ "Ada L.",
310
+ );
311
+ });
312
+
313
+ test("runs the turn on the channel it was addressed by", async () => {
314
+ // Runtime assembly reads the per-turn channel first and only then the
315
+ // conversation's origin, so a turn into a conversation whose origin was
316
+ // never recorded would otherwise run as `vellum`. That is the wrong
317
+ // channel for the message row and for the permission cascade the tools
318
+ // are approved against.
319
+ await runConversationTurn({
320
+ channel: CHANNEL,
321
+ content: [{ type: "text", text: "hello" }],
322
+ });
323
+
324
+ expect(lastTurnChannelContext).toEqual({
325
+ userMessageChannel: "plugin",
326
+ assistantMessageChannel: "plugin",
327
+ });
328
+ expect(lastProcessMessageOptions?.metadata).toMatchObject({
329
+ userMessageChannel: "plugin",
330
+ assistantMessageChannel: "plugin",
331
+ });
332
+ });
333
+
334
+ test("a queued turn carries its own channel rather than inheriting one", async () => {
335
+ // The drain happens after this call returns and reads the channel off the
336
+ // queued message, falling back to whichever turn was in flight. On a
337
+ // conversation shared with another channel that fallback is someone
338
+ // else's channel.
339
+ conversationIsProcessing = true;
340
+
341
+ const result = await runConversationTurn({
342
+ channel: CHANNEL,
343
+ content: [{ type: "text", text: "hello" }],
344
+ });
345
+
346
+ expect(result.queued).toBe(true);
347
+ expect(lastEnqueueOptions?.metadata).toMatchObject({
348
+ userMessageChannel: "plugin",
349
+ assistantMessageChannel: "plugin",
350
+ });
351
+ });
352
+
353
+ test("leaves the channel unset when the turn names none", async () => {
354
+ // Clearing rather than leaving whatever the last turn set: a stale
355
+ // context would report this turn on a channel it has nothing to do with.
356
+ await runConversationTurn({
357
+ content: [{ type: "text", text: "hello" }],
358
+ });
359
+
360
+ expect(lastTurnChannelContext).toBeNull();
361
+ expect(lastProcessMessageOptions?.metadata).toEqual({ automated: true });
362
+ });
363
+
364
+ test("keeps a known sender when a later turn omits it", async () => {
365
+ // A plugin that knows only the chat coordinates this time is silent about
366
+ // the sender, not asserting it has none. Erasing the stored name on that
367
+ // silence drops it from the conversation and session APIs.
368
+ await runConversationTurn({
369
+ channel: { ...CHANNEL, externalUserId: "+12025550142" },
370
+ content: [{ type: "text", text: "hello" }],
371
+ });
372
+
373
+ const result = await runConversationTurn({
374
+ channel: {
375
+ sourceChannel: CHANNEL.sourceChannel,
376
+ externalChatId: CHANNEL.externalChatId,
377
+ },
378
+ content: [{ type: "text", text: "still me" }],
379
+ });
380
+
381
+ expect(getBindingByConversation(result.conversationId)).toMatchObject({
382
+ externalUserId: "+12025550142",
383
+ displayName: "Ada",
384
+ });
385
+ });
386
+
387
+ test("clears a sender the caller explicitly says is gone", async () => {
388
+ await runConversationTurn({
389
+ channel: CHANNEL,
390
+ content: [{ type: "text", text: "hello" }],
391
+ });
392
+
393
+ const result = await runConversationTurn({
394
+ channel: { ...CHANNEL, displayName: null },
395
+ content: [{ type: "text", text: "anonymous now" }],
396
+ });
397
+
398
+ expect(
399
+ getBindingByConversation(result.conversationId)?.displayName,
400
+ ).toBeNull();
401
+ });
402
+
403
+ test("an explicit conversation id wins over the address", async () => {
404
+ // Two different conversations were named. Re-resolving would overrule the
405
+ // caller, so the explicit one is honoured and nothing is bound behind it.
406
+ const existing = createConversation({ title: "chosen" });
407
+
408
+ const result = await runConversationTurn({
409
+ conversationId: existing.id,
410
+ channel: CHANNEL,
411
+ content: [{ type: "text", text: "hello" }],
412
+ });
413
+
414
+ expect(result.conversationId).toBe(existing.id);
415
+ expect(getBindingByConversation(existing.id)).toBeNull();
416
+ });
417
+ });
@@ -59,7 +59,6 @@ async function applyEdit(eventId: string, content: string): Promise<void> {
59
59
  conversationExternalId: CHANNEL,
60
60
  externalMessageId: eventId,
61
61
  sourceMessageId: ORIGINAL_TS,
62
- canonicalAssistantId: "self",
63
62
  assistantId: "self",
64
63
  content,
65
64
  });
@@ -8,8 +8,8 @@
8
8
  import { and, desc, eq, isNotNull, ne, or, sql } from "drizzle-orm";
9
9
  import { v4 as uuid } from "uuid";
10
10
 
11
+ import type { ChannelId } from "../channels/types.js";
11
12
  import { readSlackMetadataFromMessageMetadata } from "../messaging/providers/slack/message-metadata.js";
12
- import { DAEMON_INTERNAL_ASSISTANT_ID } from "../runtime/assistant-scope.js";
13
13
  import type { SlackInboundMessageMetadata } from "../runtime/http-types.js";
14
14
  import { parseJsonSafe } from "../util/json.js";
15
15
  import { isPlainObject } from "../util/object.js";
@@ -19,6 +19,7 @@ import {
19
19
  getOrCreateConversation,
20
20
  setConversationKeyIfAbsent,
21
21
  } from "./conversation-key-store.js";
22
+ import type { NonScheduledConversationType } from "./conversation-types.js";
22
23
  import { getDb } from "./db-connection.js";
23
24
  import { channelInboundEvents, conversations } from "./schema.js";
24
25
 
@@ -31,7 +32,6 @@ export interface InboundResult {
31
32
 
32
33
  export interface RecordInboundOptions {
33
34
  sourceMessageId?: string;
34
- assistantId?: string;
35
35
  sourceThreadId?: string;
36
36
  }
37
37
 
@@ -45,30 +45,25 @@ const SLACK_LEGACY_THREAD_EVIDENCE_MAX_SCAN = 500;
45
45
  */
46
46
  const THREAD_SCOPED_CHANNELS = new Set(["slack", "telegram"]);
47
47
 
48
- function buildScopedConversationKeyForAssistant(
49
- assistantId: string,
48
+ /**
49
+ * Scope prefix on every conversation key.
50
+ *
51
+ * Part of the stored key format, so it is fixed: rows are written under
52
+ * `asst:self:` and stop resolving if it changes. The daemon is single-tenant
53
+ * and scopes all its storage to `self` (`DAEMON_INTERNAL_ASSISTANT_ID`).
54
+ */
55
+ const CONVERSATION_KEY_SCOPE = "asst:self";
56
+
57
+ export function buildScopedConversationKey(
50
58
  sourceChannel: string,
51
59
  externalChatId: string,
52
60
  sourceThreadId?: string | null,
53
61
  ): string {
54
62
  const threadId = sourceThreadId?.trim();
55
63
  if (THREAD_SCOPED_CHANNELS.has(sourceChannel) && threadId) {
56
- return `asst:${assistantId}:${sourceChannel}:${externalChatId}:thread:${threadId}`;
64
+ return `${CONVERSATION_KEY_SCOPE}:${sourceChannel}:${externalChatId}:thread:${threadId}`;
57
65
  }
58
- return `asst:${assistantId}:${sourceChannel}:${externalChatId}`;
59
- }
60
-
61
- export function buildScopedConversationKey(
62
- sourceChannel: string,
63
- externalChatId: string,
64
- sourceThreadId?: string | null,
65
- ): string {
66
- return buildScopedConversationKeyForAssistant(
67
- DAEMON_INTERNAL_ASSISTANT_ID,
68
- sourceChannel,
69
- externalChatId,
70
- sourceThreadId,
71
- );
66
+ return `${CONVERSATION_KEY_SCOPE}:${sourceChannel}:${externalChatId}`;
72
67
  }
73
68
 
74
69
  function legacySlackConversationHasThreadEvidence(
@@ -135,14 +130,24 @@ function legacySlackConversationHasThreadEvidence(
135
130
  return false;
136
131
  }
137
132
 
138
- function resolveInboundConversation(
139
- assistantId: string,
133
+ /**
134
+ * Applied only where this call is what creates the conversation. An address
135
+ * that already resolves is an existing conversation, and neither its type nor
136
+ * where it came from is the current message's to restate.
137
+ */
138
+ export interface ResolveInboundConversationOptions {
139
+ conversationType?: NonScheduledConversationType;
140
+ /** Channel this conversation originates on, for first-message attribution. */
141
+ origin?: ChannelId;
142
+ }
143
+
144
+ export function resolveInboundConversation(
140
145
  sourceChannel: string,
141
146
  externalChatId: string,
142
147
  sourceThreadId?: string | null,
148
+ opts?: ResolveInboundConversationOptions,
143
149
  ): { conversationId: string } {
144
- const threadedKey = buildScopedConversationKeyForAssistant(
145
- assistantId,
150
+ const threadedKey = buildScopedConversationKey(
146
151
  sourceChannel,
147
152
  externalChatId,
148
153
  sourceThreadId,
@@ -155,7 +160,7 @@ function resolveInboundConversation(
155
160
  // other thread-scoped channel (Telegram topics) has no flat-key aliasing —
156
161
  // a thread id always resolves the threaded key directly.
157
162
  if (sourceChannel !== "slack" || !threadId) {
158
- return getOrCreateConversation(threadedKey);
163
+ return getOrCreateConversation(threadedKey, opts);
159
164
  }
160
165
 
161
166
  const threadedMapping = getConversationByKey(threadedKey);
@@ -163,8 +168,7 @@ function resolveInboundConversation(
163
168
  return { conversationId: threadedMapping.conversationId };
164
169
  }
165
170
 
166
- const legacyKey = buildScopedConversationKeyForAssistant(
167
- assistantId,
171
+ const legacyKey = buildScopedConversationKey(
168
172
  sourceChannel,
169
173
  externalChatId,
170
174
  null,
@@ -185,7 +189,7 @@ function resolveInboundConversation(
185
189
  }
186
190
  }
187
191
 
188
- return getOrCreateConversation(threadedKey);
192
+ return getOrCreateConversation(threadedKey, opts);
189
193
  }
190
194
 
191
195
  /**
@@ -276,9 +280,7 @@ export function recordInbound(
276
280
  };
277
281
  }
278
282
 
279
- const assistantId = options?.assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID;
280
283
  const mapping = resolveInboundConversation(
281
- assistantId,
282
284
  sourceChannel,
283
285
  externalChatId,
284
286
  options?.sourceThreadId,
@@ -7,7 +7,8 @@
7
7
  * list APIs.
8
8
  */
9
9
 
10
- import { and, eq, inArray, isNull, sql } from "drizzle-orm";
10
+ import { and, eq, inArray, isNull, type SQL, sql } from "drizzle-orm";
11
+ import type { AnySQLiteColumn } from "drizzle-orm/sqlite-core";
11
12
 
12
13
  import { getDb } from "./db-connection.js";
13
14
  import { externalConversationBindings } from "./schema/index.js";
@@ -45,6 +46,19 @@ function normalizeExternalThreadId(
45
46
  return trimmed ? trimmed : null;
46
47
  }
47
48
 
49
+ /**
50
+ * An upsert value that leaves the stored column alone when the caller said
51
+ * nothing about it, distinguishing "I do not know" from "there is none".
52
+ */
53
+ function keepStoredIfAbsent<T extends string | null | undefined>(
54
+ value: T,
55
+ column: AnySQLiteColumn,
56
+ ): Exclude<T, undefined> | SQL {
57
+ return value === undefined
58
+ ? sql`${column}`
59
+ : (value as Exclude<T, undefined>);
60
+ }
61
+
48
62
  function normalizeExternalChatName(
49
63
  externalChatName?: string | null,
50
64
  ): string | null {
@@ -103,9 +117,23 @@ export function upsertBinding(input: UpsertBindingInput): void {
103
117
  externalChatName ??
104
118
  sql`${externalConversationBindings.externalChatName}`,
105
119
  externalThreadId,
106
- externalUserId: input.externalUserId ?? null,
107
- displayName: input.displayName ?? null,
108
- username: input.username ?? null,
120
+ // An omitted sender field keeps what is stored; an explicit value,
121
+ // including null, replaces it. A caller that knows only the chat
122
+ // coordinates on a message is silent about the sender rather than
123
+ // asserting there is none, and erasing a known name on that silence
124
+ // drops it from the conversation and session APIs.
125
+ externalUserId: keepStoredIfAbsent(
126
+ input.externalUserId,
127
+ externalConversationBindings.externalUserId,
128
+ ),
129
+ displayName: keepStoredIfAbsent(
130
+ input.displayName,
131
+ externalConversationBindings.displayName,
132
+ ),
133
+ username: keepStoredIfAbsent(
134
+ input.username,
135
+ externalConversationBindings.username,
136
+ ),
109
137
  updatedAt: now,
110
138
  lastInboundAt: now,
111
139
  },
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import type { AssistantEvent } from "../api/index.js";
17
+ import type { ChannelId } from "../channels/types.js";
17
18
  import type { LLMCallSite } from "../config/schemas/llm.js";
18
19
  import type { UserMessageAttachment } from "../daemon/message-types/shared.js";
19
20
  import type { ContentBlock, MediaSource } from "../providers/types.js";
@@ -22,12 +23,62 @@ import type { ContentBlock, MediaSource } from "../providers/types.js";
22
23
  // Public types
23
24
  // ---------------------------------------------------------------------------
24
25
 
26
+ /**
27
+ * The chat a turn belongs to, in the channel's own terms.
28
+ *
29
+ * Supplied instead of a `conversationId` when the caller knows where the
30
+ * message came from but not which conversation that is. The pair resolves to
31
+ * the same conversation an inbound message on those coordinates would land
32
+ * in, because it resolves through the same binding.
33
+ *
34
+ * That sameness is the reason this exists at all. A caller could keep its own
35
+ * `externalChatId -> conversationId` map and pass the id, and turns would run
36
+ * in the right place. What it could not do is make the rest of the assistant
37
+ * agree: conversation reset is addressed by channel coordinates
38
+ * (`handleDeleteConversation`), the deny lanes attach an access-request card
39
+ * by them (`findInboundConversationId`), and the conversation and session
40
+ * APIs read the channel, chat name and sender off the external binding. A
41
+ * private map is a second name for the same conversation, and everything
42
+ * keyed on the public one quietly misses.
43
+ */
44
+ export interface ConversationChannelAddress {
45
+ /** Channel the message arrived on. */
46
+ sourceChannel: ChannelId;
47
+ /** The chat, in the channel's own id space. */
48
+ externalChatId: string;
49
+ /**
50
+ * Thread within the chat, where the channel has threads. Only Slack and
51
+ * Telegram scope a conversation by thread; elsewhere this is carried as
52
+ * binding metadata and does not split the conversation.
53
+ */
54
+ externalThreadId?: string | null;
55
+ /** Human-readable chat name, for the conversation list. */
56
+ externalChatName?: string | null;
57
+ /** Who sent it, in the channel's id space. */
58
+ externalUserId?: string | null;
59
+ displayName?: string | null;
60
+ username?: string | null;
61
+ }
62
+
25
63
  export interface RunConversationTurnOptions {
26
64
  /**
27
65
  * Conversation to run the turn in. If omitted, a new conversation is
28
- * created (its ID is generated with `uuidv7` and returned in the result).
66
+ * created (its ID is generated with `uuidv7` and returned in the result),
67
+ * unless {@link RunConversationTurnOptions.channel} says which chat this
68
+ * belongs to, in which case that chat's conversation is used.
29
69
  */
30
70
  conversationId?: string;
71
+ /**
72
+ * The chat this turn belongs to, resolved to a conversation and bound to
73
+ * the channel. See {@link ConversationChannelAddress}.
74
+ *
75
+ * Ignored when `conversationId` is given: an explicit conversation is the
76
+ * caller saying which one, and re-resolving would overrule it. A caller
77
+ * that wants both the binding and a conversation of its own choosing is
78
+ * describing two different conversations, which is a bug worth surfacing
79
+ * as one rather than silently picking a winner.
80
+ */
81
+ channel?: ConversationChannelAddress;
31
82
  /**
32
83
  * User message content blocks for this turn. Text blocks become the
33
84
  * user message body; image/file blocks are resolved to inline
@@ -121,6 +172,77 @@ function extractContentAndAttachments(
121
172
  return { text: textParts.join("\n"), attachments };
122
173
  }
123
174
 
175
+ /**
176
+ * Resolve a chat to its conversation, binding the two if they are not already.
177
+ *
178
+ * Two writes, the same two `handleChannelInbound` makes for an arriving
179
+ * message, and for the same reasons. The conversation key is what makes the
180
+ * chat resolve to this conversation next time and what conversation reset
181
+ * addresses; the external binding is what the conversation and session APIs
182
+ * read to show which channel a conversation came from and who is on the other
183
+ * end. One without the other is a conversation that is half-registered, and
184
+ * which half is missing decides which feature quietly stops working.
185
+ *
186
+ * Deliberately not `recordInbound`, which is the same resolution plus an
187
+ * inbound event row. That row exists to dedup a vendor's redelivery and to
188
+ * correlate later edits, and a caller driving a turn on its own schedule has
189
+ * neither a vendor nor a message id to correlate. Plugin deliveries are
190
+ * deduped upstream in the gateway before the message ever gets here.
191
+ */
192
+ async function resolveChannelConversation(
193
+ channel: ConversationChannelAddress,
194
+ conversationType?: "standard" | "background",
195
+ ): Promise<{ conversationId: string; created: boolean }> {
196
+ const { findInboundConversationId, resolveInboundConversation } =
197
+ await import("../persistence/delivery-crud.js");
198
+ const { upsertBinding } =
199
+ await import("../persistence/external-conversation-store.js");
200
+
201
+ // Asked before resolving, because resolving is what creates it: once the id
202
+ // is in hand a new conversation is indistinguishable from an existing one,
203
+ // and the caller announces only the new. `findInboundConversationId` is the
204
+ // read-only mirror of the resolution below, so the two agree on what
205
+ // "already bound" means, including the Slack flat-key alias.
206
+ const created =
207
+ findInboundConversationId(
208
+ channel.sourceChannel,
209
+ channel.externalChatId,
210
+ channel.externalThreadId,
211
+ ) === null;
212
+
213
+ const { conversationId } = resolveInboundConversation(
214
+ channel.sourceChannel,
215
+ channel.externalChatId,
216
+ channel.externalThreadId,
217
+ // Both apply only if this call is what mints the conversation. `origin` is
218
+ // first-message attribution, which a chat's first turn is the only chance
219
+ // to record.
220
+ {
221
+ origin: channel.sourceChannel,
222
+ ...(conversationType ? { conversationType } : {}),
223
+ },
224
+ );
225
+
226
+ // Refreshed on every turn rather than only on creation, because the sender's
227
+ // display name and the chat's name are the vendor's to change and the
228
+ // conversation list shows whichever we last heard. The optional fields pass
229
+ // through as given: a caller that omits one on a later turn knows only the
230
+ // chat coordinates this time, which `upsertBinding` reads as silence rather
231
+ // than as the sender having no name.
232
+ upsertBinding({
233
+ conversationId,
234
+ sourceChannel: channel.sourceChannel,
235
+ externalChatId: channel.externalChatId,
236
+ externalChatName: channel.externalChatName,
237
+ externalThreadId: channel.externalThreadId ?? null,
238
+ externalUserId: channel.externalUserId,
239
+ displayName: channel.displayName,
240
+ username: channel.username,
241
+ });
242
+
243
+ return { conversationId, created };
244
+ }
245
+
124
246
  // ---------------------------------------------------------------------------
125
247
  // Public API
126
248
  // ---------------------------------------------------------------------------
@@ -166,8 +288,25 @@ export async function runConversationTurn(
166
288
  // below) without requiring a client to approve prompts.
167
289
  const { INTERNAL_GUARDIAN_TRUST_CONTEXT } =
168
290
  await import("../daemon/trust-context.js");
169
- const conversationId = options.conversationId ?? uuidv7();
170
- const rowExisted = getConversation(conversationId) != null;
291
+
292
+ // A channel address resolves through the same binding an inbound message
293
+ // uses, so a turn addressed by chat lands in that chat's conversation
294
+ // rather than a private one only this caller can find.
295
+ const channelConversation =
296
+ !options.conversationId && options.channel
297
+ ? await resolveChannelConversation(
298
+ options.channel,
299
+ options.conversationType,
300
+ )
301
+ : undefined;
302
+ const conversationId =
303
+ options.conversationId ?? channelConversation?.conversationId ?? uuidv7();
304
+ // A channel address reports its own novelty, since resolving it creates the
305
+ // row: asking the DB here answers yes for a conversation minted a line ago
306
+ // and swallows the announcement it is owed.
307
+ const rowExisted = channelConversation
308
+ ? !channelConversation.created
309
+ : getConversation(conversationId) != null;
171
310
  const conversation = await getOrCreateConversation(conversationId, {
172
311
  trustContext: INTERNAL_GUARDIAN_TRUST_CONTEXT,
173
312
  ...(options.conversationType
@@ -199,6 +338,28 @@ export async function runConversationTurn(
199
338
  resolveMediaSourceData,
200
339
  );
201
340
 
341
+ // The channel this turn speaks on. Runtime assembly reads the per-turn
342
+ // context first and falls back to the conversation's `originChannel`, then
343
+ // to `vellum`, so a turn into a conversation carrying no origin runs as
344
+ // `vellum` without this: the wrong channel for the message row and for the
345
+ // channel-permission cascade the tools are approved against. Null when the
346
+ // caller names no channel, which both restores that fallback and clears any
347
+ // context left by a previous turn.
348
+ const turnChannelContext = options.channel
349
+ ? {
350
+ userMessageChannel: options.channel.sourceChannel,
351
+ assistantMessageChannel: options.channel.sourceChannel,
352
+ }
353
+ : null;
354
+ // Carried on the metadata as well, because a queued turn is drained after
355
+ // this call returns and reads its channel from there. Without it the drain
356
+ // inherits whichever turn was in flight, which on a shared conversation is
357
+ // some other channel entirely.
358
+ const metadata = {
359
+ ...PLUGIN_TURN_MESSAGE_METADATA,
360
+ ...(turnChannelContext ?? {}),
361
+ };
362
+
202
363
  // Build the event emitter: fan out to SSE/event-hub subscribers via
203
364
  // broadcastMessage, then invoke the collector callback. This mirrors the
204
365
  // buildEventEmitter pattern from process-message.ts so plugin-driven
@@ -221,7 +382,7 @@ export async function runConversationTurn(
221
382
  onEvent,
222
383
  requestId,
223
384
  isInteractive: false,
224
- metadata: { ...PLUGIN_TURN_MESSAGE_METADATA },
385
+ metadata,
225
386
  });
226
387
  if (enqueueResult.rejected) {
227
388
  throw new Error(
@@ -236,12 +397,14 @@ export async function runConversationTurn(
236
397
  };
237
398
  }
238
399
 
400
+ conversation.setTurnChannelContext(turnChannelContext);
401
+
239
402
  const userMessageId = await conversation.processMessage({
240
403
  content: text,
241
404
  attachments,
242
405
  onEvent,
243
406
  isInteractive: false,
244
- metadata: { ...PLUGIN_TURN_MESSAGE_METADATA },
407
+ metadata,
245
408
  ...(options.callSite ? { callSite: options.callSite } : {}),
246
409
  });
247
410
 
@@ -385,6 +385,7 @@ export { openTranscriptionSession } from "./transcription-session.js";
385
385
  // (e.g. meeting-bot flushing a transcript excerpt) should prefer this over the
386
386
  // stateless `provider.sendMessage()` call.
387
387
  export type {
388
+ ConversationChannelAddress,
388
389
  RunConversationTurnOptions,
389
390
  RunConversationTurnResult,
390
391
  } from "./conversation-turn.js";
@@ -709,7 +709,6 @@ export async function handleChannelInbound({
709
709
  externalMessageId,
710
710
  sourceMessageId,
711
711
  sourceThreadId: channelThreadId,
712
- canonicalAssistantId,
713
712
  assistantId,
714
713
  content,
715
714
  channelId: resolvedMember?.channelId,
@@ -723,28 +722,24 @@ export async function handleChannelInbound({
723
722
  externalMessageId,
724
723
  {
725
724
  sourceMessageId,
726
- assistantId: canonicalAssistantId,
727
725
  sourceThreadId: channelThreadId,
728
726
  },
729
727
  );
730
728
 
731
729
  const replyCallbackUrl = body.replyCallbackUrl;
732
730
 
733
- // external_conversation_bindings is assistant-agnostic. Restrict writes to
734
- // self so assistant-scoped legacy routes do not overwrite each other's
735
- // channel binding metadata for the same chat.
736
- if (canonicalAssistantId === DAEMON_INTERNAL_ASSISTANT_ID) {
737
- upsertBinding({
738
- conversationId: result.conversationId,
739
- sourceChannel,
740
- externalChatId: conversationExternalId,
741
- externalChatName: slackChannelName,
742
- externalThreadId: channelThreadId ?? null,
743
- externalUserId: canonicalSenderId ?? rawSenderId ?? null,
744
- displayName: body.actorDisplayName ?? null,
745
- username: body.actorUsername ?? null,
746
- });
747
- }
731
+ // `external_conversation_bindings` is assistant-agnostic: one row per chat,
732
+ // whichever caller writes it.
733
+ upsertBinding({
734
+ conversationId: result.conversationId,
735
+ sourceChannel,
736
+ externalChatId: conversationExternalId,
737
+ externalChatName: slackChannelName,
738
+ externalThreadId: channelThreadId ?? null,
739
+ externalUserId: canonicalSenderId ?? rawSenderId ?? null,
740
+ displayName: body.actorDisplayName ?? null,
741
+ username: body.actorUsername ?? null,
742
+ });
748
743
 
749
744
  // ── Actor role resolution ──
750
745
  // Built from the gateway-stamped trust verdict (ACL + identity). An absent
@@ -45,7 +45,6 @@ export interface EditInterceptParams {
45
45
  externalMessageId: string;
46
46
  sourceMessageId: string;
47
47
  sourceThreadId?: string;
48
- canonicalAssistantId: string;
49
48
  assistantId: string;
50
49
  content: string | undefined;
51
50
  /** Channel ID for channel-level interaction tracking. */
@@ -68,7 +67,6 @@ export async function handleEditIntercept(
68
67
  externalMessageId,
69
68
  sourceMessageId,
70
69
  sourceThreadId,
71
- canonicalAssistantId,
72
70
  assistantId,
73
71
  content,
74
72
  } = params;
@@ -78,7 +76,7 @@ export async function handleEditIntercept(
78
76
  sourceChannel,
79
77
  conversationExternalId,
80
78
  externalMessageId,
81
- { sourceMessageId, sourceThreadId, assistantId: canonicalAssistantId },
79
+ { sourceMessageId, sourceThreadId },
82
80
  );
83
81
 
84
82
  if (editResult.duplicate) {
@@ -182,7 +182,6 @@ export async function handleSlackReactionIntercept(
182
182
  externalMessageId,
183
183
  {
184
184
  sourceMessageId: reactedMessageTs,
185
- assistantId: canonicalAssistantId,
186
185
  sourceThreadId: threadTs,
187
186
  },
188
187
  );