@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 +1 -1
- package/src/__tests__/channel-delivery-store.test.ts +14 -14
- package/src/__tests__/edit-propagation.test.ts +0 -7
- package/src/__tests__/run-conversation-turn-persistence.test.ts +222 -0
- package/src/__tests__/slack-edit-ordering-characterization.test.ts +0 -1
- package/src/persistence/delivery-crud.ts +31 -29
- package/src/persistence/external-conversation-store.ts +32 -4
- package/src/plugin-api/conversation-turn.ts +168 -5
- package/src/plugin-api/index.ts +1 -0
- package/src/runtime/routes/inbound-message-handler.ts +12 -17
- package/src/runtime/routes/inbound-stages/edit-intercept.ts +1 -3
- package/src/runtime/routes/inbound-stages/reaction-intercept.ts +0 -1
package/package.json
CHANGED
|
@@ -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
|
|
508
|
-
const r1 = recordInbound("telegram", "chat-1", "msg-1"
|
|
509
|
-
|
|
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).
|
|
511
|
+
expect(r1.conversationId).toBe(r2.conversationId);
|
|
516
512
|
});
|
|
517
513
|
|
|
518
|
-
test("
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
+
});
|
|
@@ -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
|
-
|
|
49
|
-
|
|
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
|
|
64
|
+
return `${CONVERSATION_KEY_SCOPE}:${sourceChannel}:${externalChatId}:thread:${threadId}`;
|
|
57
65
|
}
|
|
58
|
-
return
|
|
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
|
-
|
|
139
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
|
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
|
|
407
|
+
metadata,
|
|
245
408
|
...(options.callSite ? { callSite: options.callSite } : {}),
|
|
246
409
|
});
|
|
247
410
|
|
package/src/plugin-api/index.ts
CHANGED
|
@@ -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
|
|
734
|
-
//
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
|
79
|
+
{ sourceMessageId, sourceThreadId },
|
|
82
80
|
);
|
|
83
81
|
|
|
84
82
|
if (editResult.duplicate) {
|