@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.
- package/package.json +1 -1
- package/src/__tests__/access-request-seed-content-blocks.test.ts +2 -2
- package/src/__tests__/channel-delivery-store.test.ts +57 -0
- package/src/__tests__/channel-retry-sweep.test.ts +168 -0
- package/src/__tests__/external-binding-chat-name.test.ts +60 -0
- package/src/__tests__/provider-meta-persistence.test.ts +67 -0
- package/src/__tests__/tool-approval-seed-content-blocks.test.ts +16 -3
- package/src/daemon/conversation-messaging.ts +42 -0
- package/src/daemon/handlers/shared.ts +9 -0
- package/src/daemon/process-message.ts +61 -16
- package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +70 -1
- package/src/live-voice/live-voice-session.ts +3 -0
- package/src/live-voice/protocol.ts +20 -1
- package/src/messaging/providers/slack/approval-source.test.ts +70 -0
- package/src/messaging/providers/slack/approval-source.ts +18 -5
- package/src/notifications/__tests__/guardian-feed-projection.test.ts +9 -1
- package/src/notifications/approval-card-data.ts +13 -5
- package/src/notifications/guardian-feed-projection.ts +9 -11
- package/src/notifications/guardian-question-mode.ts +33 -1
- package/src/persistence/delivery-crud.ts +51 -1
- package/src/persistence/external-conversation-store.ts +5 -1
- package/src/persistence/migrations/374-channel-inbound-message-id-index.ts +26 -0
- package/src/persistence/steps.ts +2 -0
- package/src/runtime/approval-source-link.ts +6 -0
- package/src/runtime/channel-retry-sweep.ts +90 -0
- package/src/runtime/routes/inbound-message-handler.ts +26 -0
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +20 -5
package/package.json
CHANGED
|
@@ -90,7 +90,7 @@ describe("buildAccessRequestSeedContentBlocks", () => {
|
|
|
90
90
|
});
|
|
91
91
|
expect(data.metadata).toContainEqual({
|
|
92
92
|
label: "Source",
|
|
93
|
-
value: "Slack
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
473
|
-
|
|
474
|
-
|
|
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 =
|
|
570
|
-
|
|
571
|
-
|
|
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 =
|
|
628
|
-
|
|
629
|
-
|
|
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
|
|
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
|
|
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
|
{
|