@vellumai/assistant 0.11.10-dev.202609092113.0fb45bc → 0.11.10-dev.202609092213.434c99d
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/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/reactions.ts +8 -2
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/reactions.ts +8 -2
- package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +8 -0
- package/node_modules/@vellumai/service-contracts/src/reactions.ts +8 -2
- package/node_modules/@vellumai/slack-text/src/emoji.test.ts +6 -0
- package/node_modules/@vellumai/slack-text/src/emoji.ts +3 -1
- package/package.json +1 -1
- package/src/__tests__/channel-guardian.test.ts +1 -0
- package/src/__tests__/discord-callback-round-trip.test.ts +6 -1
- package/src/__tests__/messaging-send-tool.test.ts +148 -0
- package/src/__tests__/notification-discord-adapter.test.ts +7 -2
- package/src/__tests__/notification-slack-adapter.test.ts +2 -0
- package/src/__tests__/notification-telegram-adapter.test.ts +8 -3
- package/src/__tests__/verification-outbound-delivery.test.ts +7 -2
- package/src/calls/spoken-reply-rules.ts +20 -0
- package/src/calls/voice-session-bridge.ts +6 -2
- package/src/calls/voice-triage-escalate.ts +1 -1
- package/src/cli/commands/__tests__/conversations-slack.test.ts +3 -0
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +60 -10
- package/src/daemon/reaction-record.test.ts +15 -11
- package/src/daemon/reaction-record.ts +10 -2
- package/src/live-voice/__tests__/live-voice-events.test.ts +1 -1
- package/src/live-voice/live-voice-session.ts +31 -7
- package/src/messaging/providers/__tests__/transport-dispatch.test.ts +91 -6
- package/src/messaging/providers/channel-transport.ts +11 -0
- package/src/messaging/providers/discord/send.test.ts +39 -0
- package/src/messaging/providers/discord/send.ts +28 -14
- package/src/messaging/providers/discord/transport.ts +8 -1
- package/src/messaging/providers/index.ts +17 -0
- package/src/messaging/providers/send-result.ts +35 -0
- package/src/messaging/providers/slack/adapter.ts +3 -0
- package/src/messaging/providers/slack/send.test.ts +24 -0
- package/src/messaging/providers/slack/send.ts +32 -1
- package/src/messaging/providers/slack/transport.ts +10 -1
- package/src/messaging/providers/telegram-bot/send.test.ts +67 -0
- package/src/messaging/providers/telegram-bot/send.ts +26 -21
- package/src/messaging/providers/telegram-bot/transport.ts +9 -7
- package/src/messaging/providers/whatsapp/send.test.ts +139 -0
- package/src/messaging/providers/whatsapp/send.ts +29 -9
- package/src/messaging/providers/whatsapp/transport.ts +7 -3
- package/src/messaging/reaction-envelopes.ts +4 -3
- package/src/persistence/external-conversation-store.ts +6 -1
- package/src/runtime/routes/inbound-stages/bootstrap-intercept.test.ts +6 -0
- package/src/tools/channel/react-to-message.test.ts +6 -0
- package/src/tools/channel/react-to-message.ts +2 -0
package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/reactions.ts
CHANGED
|
@@ -41,6 +41,12 @@ export const ReactionEmojiFieldsSchema = z.object({
|
|
|
41
41
|
});
|
|
42
42
|
export type ReactionEmojiFields = z.infer<typeof ReactionEmojiFieldsSchema>;
|
|
43
43
|
|
|
44
|
+
/** The typed fields with the kind and name known. */
|
|
45
|
+
export type ReactionEmojiIdentity = ReactionEmojiFields & {
|
|
46
|
+
emojiKind: ReactionEmojiKind;
|
|
47
|
+
emojiName: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
44
50
|
/**
|
|
45
51
|
* The typed emoji fields a source actually carries, with undefined ones
|
|
46
52
|
* omitted: an absent key and a present-but-undefined one serialize alike, but the
|
|
@@ -96,7 +102,7 @@ export function parseDiscordEmojiMention(
|
|
|
96
102
|
*/
|
|
97
103
|
export function classifyReactionEmojiSpelling(
|
|
98
104
|
emoji: string,
|
|
99
|
-
):
|
|
105
|
+
): ReactionEmojiIdentity {
|
|
100
106
|
const custom = parseDiscordEmojiMention(emoji);
|
|
101
107
|
if (custom) {
|
|
102
108
|
// The plain `<:name:id>` form says nothing about animation: the
|
|
@@ -123,7 +129,7 @@ export function classifyReactionEmojiSpelling(
|
|
|
123
129
|
*/
|
|
124
130
|
export function reactionEmojiIdentity(
|
|
125
131
|
reaction: { emoji: string } & ReactionEmojiFields,
|
|
126
|
-
):
|
|
132
|
+
): ReactionEmojiIdentity {
|
|
127
133
|
return reaction.emojiKind !== undefined && reaction.emojiName !== undefined
|
|
128
134
|
? {
|
|
129
135
|
...pickReactionEmojiFields(reaction),
|
|
@@ -41,6 +41,12 @@ export const ReactionEmojiFieldsSchema = z.object({
|
|
|
41
41
|
});
|
|
42
42
|
export type ReactionEmojiFields = z.infer<typeof ReactionEmojiFieldsSchema>;
|
|
43
43
|
|
|
44
|
+
/** The typed fields with the kind and name known. */
|
|
45
|
+
export type ReactionEmojiIdentity = ReactionEmojiFields & {
|
|
46
|
+
emojiKind: ReactionEmojiKind;
|
|
47
|
+
emojiName: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
44
50
|
/**
|
|
45
51
|
* The typed emoji fields a source actually carries, with undefined ones
|
|
46
52
|
* omitted: an absent key and a present-but-undefined one serialize alike, but the
|
|
@@ -96,7 +102,7 @@ export function parseDiscordEmojiMention(
|
|
|
96
102
|
*/
|
|
97
103
|
export function classifyReactionEmojiSpelling(
|
|
98
104
|
emoji: string,
|
|
99
|
-
):
|
|
105
|
+
): ReactionEmojiIdentity {
|
|
100
106
|
const custom = parseDiscordEmojiMention(emoji);
|
|
101
107
|
if (custom) {
|
|
102
108
|
// The plain `<:name:id>` form says nothing about animation: the
|
|
@@ -123,7 +129,7 @@ export function classifyReactionEmojiSpelling(
|
|
|
123
129
|
*/
|
|
124
130
|
export function reactionEmojiIdentity(
|
|
125
131
|
reaction: { emoji: string } & ReactionEmojiFields,
|
|
126
|
-
):
|
|
132
|
+
): ReactionEmojiIdentity {
|
|
127
133
|
return reaction.emojiKind !== undefined && reaction.emojiName !== undefined
|
|
128
134
|
? {
|
|
129
135
|
...pickReactionEmojiFields(reaction),
|
|
@@ -287,6 +287,14 @@ export const ChannelDeliveryResultSchema = z.object({
|
|
|
287
287
|
ok: z.boolean(),
|
|
288
288
|
/** The message timestamp returned by the delivery endpoint. */
|
|
289
289
|
ts: z.string().optional(),
|
|
290
|
+
/**
|
|
291
|
+
* Every provider post the text of this delivery created, in send order, in
|
|
292
|
+
* the channel's own id space: one id for a single post, one per chunk when
|
|
293
|
+
* the channel split the text. Posts made for attachments are not included.
|
|
294
|
+
* A recorder needs every one of these, since a reaction, edit, or delete
|
|
295
|
+
* can name any chunk; `ts` alone names at most one.
|
|
296
|
+
*/
|
|
297
|
+
messageIds: z.array(z.string()).optional(),
|
|
290
298
|
});
|
|
291
299
|
|
|
292
300
|
export type ChannelDeliveryResult = z.infer<typeof ChannelDeliveryResultSchema>;
|
|
@@ -41,6 +41,12 @@ export const ReactionEmojiFieldsSchema = z.object({
|
|
|
41
41
|
});
|
|
42
42
|
export type ReactionEmojiFields = z.infer<typeof ReactionEmojiFieldsSchema>;
|
|
43
43
|
|
|
44
|
+
/** The typed fields with the kind and name known. */
|
|
45
|
+
export type ReactionEmojiIdentity = ReactionEmojiFields & {
|
|
46
|
+
emojiKind: ReactionEmojiKind;
|
|
47
|
+
emojiName: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
44
50
|
/**
|
|
45
51
|
* The typed emoji fields a source actually carries, with undefined ones
|
|
46
52
|
* omitted: an absent key and a present-but-undefined one serialize alike, but the
|
|
@@ -96,7 +102,7 @@ export function parseDiscordEmojiMention(
|
|
|
96
102
|
*/
|
|
97
103
|
export function classifyReactionEmojiSpelling(
|
|
98
104
|
emoji: string,
|
|
99
|
-
):
|
|
105
|
+
): ReactionEmojiIdentity {
|
|
100
106
|
const custom = parseDiscordEmojiMention(emoji);
|
|
101
107
|
if (custom) {
|
|
102
108
|
// The plain `<:name:id>` form says nothing about animation: the
|
|
@@ -123,7 +129,7 @@ export function classifyReactionEmojiSpelling(
|
|
|
123
129
|
*/
|
|
124
130
|
export function reactionEmojiIdentity(
|
|
125
131
|
reaction: { emoji: string } & ReactionEmojiFields,
|
|
126
|
-
):
|
|
132
|
+
): ReactionEmojiIdentity {
|
|
127
133
|
return reaction.emojiKind !== undefined && reaction.emojiName !== undefined
|
|
128
134
|
? {
|
|
129
135
|
...pickReactionEmojiFields(reaction),
|
|
@@ -20,6 +20,12 @@ describe("slackEmojiCharacter", () => {
|
|
|
20
20
|
expect(glyph(slackEmojiCharacter("thumbsup::skin-tone-3"))).toBe("👍🏼");
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
+
test("a same-tone composite repeats the tone for each person", () => {
|
|
24
|
+
expect(
|
|
25
|
+
glyph(slackEmojiCharacter("people_holding_hands::skin-tone-3")),
|
|
26
|
+
).toBe(glyph("🧑🏼🤝🧑🏼"));
|
|
27
|
+
});
|
|
28
|
+
|
|
23
29
|
test("places the tone inside a multi-person sequence", () => {
|
|
24
30
|
expect(glyph(slackEmojiCharacter("woman-raising-hand::skin-tone-2"))).toBe(
|
|
25
31
|
glyph("🙋🏻♀️"),
|
|
@@ -54,11 +54,13 @@ export function slackEmojiCharacter(name: string): string | undefined {
|
|
|
54
54
|
return entry.unicode;
|
|
55
55
|
}
|
|
56
56
|
const modifier = SKIN_TONE_MODIFIERS[Number(toned[2]) - 2]!;
|
|
57
|
+
// A multi-person emoji repeats the tone for each person; the variant
|
|
58
|
+
// Slack's single suffix names is the one carrying that tone and no other.
|
|
57
59
|
const skin = entry.skins?.find((s) => {
|
|
58
60
|
const modifiers = s.hexcode
|
|
59
61
|
.split("-")
|
|
60
62
|
.filter((h) => SKIN_TONE_MODIFIERS.includes(h));
|
|
61
|
-
return modifiers.length
|
|
63
|
+
return modifiers.length > 0 && modifiers.every((h) => h === modifier);
|
|
62
64
|
});
|
|
63
65
|
return skin?.unicode ?? entry.unicode;
|
|
64
66
|
}
|
package/package.json
CHANGED
|
@@ -38,6 +38,7 @@ const telegramDeliverCalls: Array<{
|
|
|
38
38
|
mock.module("../messaging/providers/telegram-bot/send.js", () => ({
|
|
39
39
|
sendTelegramReply: async (chatId: string, text: string) => {
|
|
40
40
|
telegramDeliverCalls.push({ chatId, text });
|
|
41
|
+
return { messageIds: [] };
|
|
41
42
|
},
|
|
42
43
|
sendTelegramAttachments: async () => ({
|
|
43
44
|
allFailed: false,
|
|
@@ -19,13 +19,18 @@ const sent: Array<{ channelId: string; text: string }> = [];
|
|
|
19
19
|
// need. `mock.module` replaces the module wholesale, so a partial stub breaks
|
|
20
20
|
// the transport's import of any export it omits, and would break again the
|
|
21
21
|
// next time `send.ts` grows one.
|
|
22
|
+
const { acknowledgedSend } =
|
|
23
|
+
await import("../messaging/providers/send-result.js");
|
|
22
24
|
const actualSend = await import("../messaging/providers/discord/send.js");
|
|
23
25
|
|
|
26
|
+
const actualDiscordSend =
|
|
27
|
+
await import("../messaging/providers/discord/send.js");
|
|
24
28
|
mock.module("../messaging/providers/discord/send.js", () => ({
|
|
29
|
+
...actualDiscordSend,
|
|
25
30
|
...actualSend,
|
|
26
31
|
sendDiscordReply: async (target: { channelId: string }, text: string) => {
|
|
27
32
|
sent.push({ channelId: target.channelId, text });
|
|
28
|
-
return
|
|
33
|
+
return acknowledgedSend(["1401234567890123456"]);
|
|
29
34
|
},
|
|
30
35
|
}));
|
|
31
36
|
|
|
@@ -42,6 +42,17 @@ function makeProvider(id: string, displayName: string): MessagingProvider {
|
|
|
42
42
|
const phoneProvider = makeProvider("phone", "Phone");
|
|
43
43
|
const telegramProvider = makeProvider("telegram", "Telegram");
|
|
44
44
|
const outlookProvider = makeProvider("outlook", "Outlook");
|
|
45
|
+
// Slack's provider threads exactly where it is told and reports that thread
|
|
46
|
+
// on the result; Telegram's ignores a requested thread and reports none.
|
|
47
|
+
const slackProvider: MessagingProvider = {
|
|
48
|
+
...makeProvider("slack", "Slack"),
|
|
49
|
+
sendMessage: async (_connection, conversationId, _text, options) => ({
|
|
50
|
+
id: "1700000000.000100",
|
|
51
|
+
timestamp: 123,
|
|
52
|
+
conversationId,
|
|
53
|
+
...(options?.threadId ? { threadId: options.threadId } : {}),
|
|
54
|
+
}),
|
|
55
|
+
};
|
|
45
56
|
let provider: MessagingProvider = phoneProvider;
|
|
46
57
|
|
|
47
58
|
let connection: OAuthConnection | undefined = undefined;
|
|
@@ -125,6 +136,13 @@ mock.module("../notifications/conversation-pairing.js", () => ({
|
|
|
125
136
|
resolveProactiveHomeConversation: resolveProactiveHomeConversationMock,
|
|
126
137
|
}));
|
|
127
138
|
|
|
139
|
+
mock.module("../persistence/external-conversation-store.js", () => ({
|
|
140
|
+
normalizeExternalThreadId: (value: string | null | undefined) => {
|
|
141
|
+
const trimmed = value?.trim();
|
|
142
|
+
return trimmed ? trimmed : null;
|
|
143
|
+
},
|
|
144
|
+
}));
|
|
145
|
+
|
|
128
146
|
mock.module("../notifications/delivered-post-record.js", () => ({
|
|
129
147
|
recordDeliveredChannelPost: recordDeliveredChannelPostMock,
|
|
130
148
|
}));
|
|
@@ -349,6 +367,136 @@ describe("messaging-send tool", () => {
|
|
|
349
367
|
expect(recordDeliveredChannelPostMock).not.toHaveBeenCalled();
|
|
350
368
|
});
|
|
351
369
|
|
|
370
|
+
test("records nothing when the post landed in the turn's own thread, even though the home is elsewhere", async () => {
|
|
371
|
+
// A Slack agent-style DM keys one conversation per thread. Its home
|
|
372
|
+
// resolves to the chat's notification conversation, so the home test
|
|
373
|
+
// alone would record this send as a cross-post away from the thread it
|
|
374
|
+
// was made in. The turn's own snapshot (channel, chat, thread) decides
|
|
375
|
+
// instead, against the thread the provider says it delivered into.
|
|
376
|
+
provider = slackProvider;
|
|
377
|
+
|
|
378
|
+
await run(
|
|
379
|
+
{
|
|
380
|
+
platform: "slack",
|
|
381
|
+
conversation_id: "D0123456789",
|
|
382
|
+
thread_id: "1700000000.000001",
|
|
383
|
+
text: "hello",
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
workingDir: "/tmp",
|
|
387
|
+
conversationId: "conv-A",
|
|
388
|
+
assistantId: "ast-1",
|
|
389
|
+
trustClass: "guardian" as const,
|
|
390
|
+
executionChannel: "slack",
|
|
391
|
+
requesterChatId: "D0123456789",
|
|
392
|
+
sourceThreadId: "1700000000.000001",
|
|
393
|
+
},
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
expect(resolveProactiveHomeConversationMock).not.toHaveBeenCalled();
|
|
397
|
+
expect(recordDeliveredChannelPostMock).not.toHaveBeenCalled();
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("records a send into the turn's chat but a different thread as a cross-post", async () => {
|
|
401
|
+
provider = slackProvider;
|
|
402
|
+
|
|
403
|
+
await run(
|
|
404
|
+
{
|
|
405
|
+
platform: "slack",
|
|
406
|
+
conversation_id: "D0123456789",
|
|
407
|
+
thread_id: "1700000000.000002",
|
|
408
|
+
text: "hello",
|
|
409
|
+
},
|
|
410
|
+
{
|
|
411
|
+
workingDir: "/tmp",
|
|
412
|
+
conversationId: "conv-A",
|
|
413
|
+
assistantId: "ast-1",
|
|
414
|
+
trustClass: "guardian" as const,
|
|
415
|
+
executionChannel: "slack",
|
|
416
|
+
requesterChatId: "D0123456789",
|
|
417
|
+
sourceThreadId: "1700000000.000001",
|
|
418
|
+
},
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
expect(resolveProactiveHomeConversationMock).toHaveBeenCalledTimes(1);
|
|
422
|
+
expect(recordDeliveredChannelPostMock).toHaveBeenCalledTimes(1);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("a thread-less delivery never matches a turn that arrived in a thread", async () => {
|
|
426
|
+
provider = slackProvider;
|
|
427
|
+
|
|
428
|
+
await run(
|
|
429
|
+
{
|
|
430
|
+
platform: "slack",
|
|
431
|
+
conversation_id: "D0123456789",
|
|
432
|
+
text: "hello",
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
workingDir: "/tmp",
|
|
436
|
+
conversationId: "conv-A",
|
|
437
|
+
assistantId: "ast-1",
|
|
438
|
+
trustClass: "guardian" as const,
|
|
439
|
+
executionChannel: "slack",
|
|
440
|
+
requesterChatId: "D0123456789",
|
|
441
|
+
sourceThreadId: "1700000000.000001",
|
|
442
|
+
},
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
expect(recordDeliveredChannelPostMock).toHaveBeenCalledTimes(1);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("decides by where the provider delivered, not by the thread requested", async () => {
|
|
449
|
+
// Telegram's provider ignores a requested thread and reports none, so a
|
|
450
|
+
// turn that arrived in a topic and asked for that topic still gets a
|
|
451
|
+
// post in the thread-less chat; that post is recorded, not suppressed.
|
|
452
|
+
provider = telegramProvider;
|
|
453
|
+
|
|
454
|
+
await run(
|
|
455
|
+
{
|
|
456
|
+
platform: "telegram",
|
|
457
|
+
conversation_id: "123456789",
|
|
458
|
+
thread_id: "777",
|
|
459
|
+
text: "hello",
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
workingDir: "/tmp",
|
|
463
|
+
conversationId: "conv-A",
|
|
464
|
+
assistantId: "ast-1",
|
|
465
|
+
trustClass: "guardian" as const,
|
|
466
|
+
executionChannel: "telegram",
|
|
467
|
+
requesterChatId: "123456789",
|
|
468
|
+
sourceThreadId: "777",
|
|
469
|
+
},
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
expect(recordDeliveredChannelPostMock).toHaveBeenCalledTimes(1);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("a turn that arrived through no channel falls back to the home comparison", async () => {
|
|
476
|
+
provider = slackProvider;
|
|
477
|
+
resolveProactiveHomeConversationMock.mockImplementation(async () => ({
|
|
478
|
+
conversationId: "conv-A",
|
|
479
|
+
createdNewConversation: false,
|
|
480
|
+
}));
|
|
481
|
+
|
|
482
|
+
await run(
|
|
483
|
+
{
|
|
484
|
+
platform: "slack",
|
|
485
|
+
conversation_id: "D0123456789",
|
|
486
|
+
text: "hello",
|
|
487
|
+
},
|
|
488
|
+
{
|
|
489
|
+
workingDir: "/tmp",
|
|
490
|
+
conversationId: "conv-A",
|
|
491
|
+
assistantId: "ast-1",
|
|
492
|
+
trustClass: "guardian" as const,
|
|
493
|
+
},
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
expect(resolveProactiveHomeConversationMock).toHaveBeenCalledTimes(1);
|
|
497
|
+
expect(recordDeliveredChannelPostMock).not.toHaveBeenCalled();
|
|
498
|
+
});
|
|
499
|
+
|
|
352
500
|
test("records nothing for a provider that is not a channel", async () => {
|
|
353
501
|
provider = outlookProvider;
|
|
354
502
|
connection = {
|
|
@@ -29,9 +29,14 @@ mock.module("../messaging/providers/discord/api.js", () => ({
|
|
|
29
29
|
},
|
|
30
30
|
}));
|
|
31
31
|
|
|
32
|
+
const { acknowledgedSend } =
|
|
33
|
+
await import("../messaging/providers/send-result.js");
|
|
32
34
|
const actualSend = await import("../messaging/providers/discord/send.js");
|
|
35
|
+
const actualDiscordSend =
|
|
36
|
+
await import("../messaging/providers/discord/send.js");
|
|
33
37
|
mock.module("../messaging/providers/discord/send.js", () => ({
|
|
34
|
-
|
|
38
|
+
...actualDiscordSend,
|
|
39
|
+
...actualSend,
|
|
35
40
|
sendDiscordReply: async (
|
|
36
41
|
target: { channelId: string },
|
|
37
42
|
text: string,
|
|
@@ -48,7 +53,7 @@ mock.module("../messaging/providers/discord/send.js", () => ({
|
|
|
48
53
|
: new Error("simulated component rejection");
|
|
49
54
|
}
|
|
50
55
|
sendCalls.push({ channelId: target.channelId, text, approval });
|
|
51
|
-
return
|
|
56
|
+
return acknowledgedSend([String(2000 + sendCalls.length)]);
|
|
52
57
|
},
|
|
53
58
|
editDiscordMessage: async (
|
|
54
59
|
target: { channelId: string },
|
|
@@ -18,7 +18,9 @@ const sendCalls: Array<{
|
|
|
18
18
|
}> = [];
|
|
19
19
|
|
|
20
20
|
const actualSend = await import("../messaging/providers/slack/send.js");
|
|
21
|
+
const actualSlackSend = await import("../messaging/providers/slack/send.js");
|
|
21
22
|
mock.module("../messaging/providers/slack/send.js", () => ({
|
|
23
|
+
...actualSlackSend,
|
|
22
24
|
...actualSend,
|
|
23
25
|
sendSlackReply: async (
|
|
24
26
|
chatId: string,
|
|
@@ -22,7 +22,12 @@ const editCalls: Array<{
|
|
|
22
22
|
/** When set, editTelegramMessage rejects with this message. */
|
|
23
23
|
let editFailure: string | undefined;
|
|
24
24
|
|
|
25
|
+
const { acknowledgedSend } =
|
|
26
|
+
await import("../messaging/providers/send-result.js");
|
|
27
|
+
const actualTelegramSend =
|
|
28
|
+
await import("../messaging/providers/telegram-bot/send.js");
|
|
25
29
|
mock.module("../messaging/providers/telegram-bot/send.js", () => ({
|
|
30
|
+
...actualTelegramSend,
|
|
26
31
|
sendTelegramReply: async (
|
|
27
32
|
chatId: string,
|
|
28
33
|
text: string,
|
|
@@ -36,9 +41,9 @@ mock.module("../messaging/providers/telegram-bot/send.js", () => ({
|
|
|
36
41
|
text,
|
|
37
42
|
approval: approval as (typeof sendCalls)[0]["approval"],
|
|
38
43
|
});
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
return
|
|
44
|
+
// The real send result: the id of the sent message, which the adapter
|
|
45
|
+
// surfaces so the delivery row can address the card later.
|
|
46
|
+
return acknowledgedSend([String(1000 + sendCalls.length)]);
|
|
42
47
|
},
|
|
43
48
|
sendTelegramAttachments: async () => ({
|
|
44
49
|
allFailed: false,
|
|
@@ -33,7 +33,9 @@ mock.module("../config/env.js", () => ({
|
|
|
33
33
|
const deliveries: Array<{ transport: string; to: string; text: string }> = [];
|
|
34
34
|
|
|
35
35
|
const realSlackSend = await import("../messaging/providers/slack/send.js");
|
|
36
|
+
const actualSlackSend = await import("../messaging/providers/slack/send.js");
|
|
36
37
|
mock.module("../messaging/providers/slack/send.js", () => ({
|
|
38
|
+
...actualSlackSend,
|
|
37
39
|
...realSlackSend,
|
|
38
40
|
sendSlackReply: async (chatId: string, text: string) => {
|
|
39
41
|
deliveries.push({ transport: "slack", to: chatId, text });
|
|
@@ -47,7 +49,7 @@ mock.module("../messaging/providers/telegram-bot/send.js", () => ({
|
|
|
47
49
|
...realTelegramSend,
|
|
48
50
|
sendTelegramReply: async (chatId: string, text: string) => {
|
|
49
51
|
deliveries.push({ transport: "telegram", to: chatId, text });
|
|
50
|
-
return {};
|
|
52
|
+
return { messageIds: [] };
|
|
51
53
|
},
|
|
52
54
|
}));
|
|
53
55
|
|
|
@@ -59,11 +61,14 @@ mock.module("../messaging/providers/discord/api.js", () => ({
|
|
|
59
61
|
}));
|
|
60
62
|
|
|
61
63
|
const realDiscordSend = await import("../messaging/providers/discord/send.js");
|
|
64
|
+
const actualDiscordSend =
|
|
65
|
+
await import("../messaging/providers/discord/send.js");
|
|
62
66
|
mock.module("../messaging/providers/discord/send.js", () => ({
|
|
67
|
+
...actualDiscordSend,
|
|
63
68
|
...realDiscordSend,
|
|
64
69
|
sendDiscordReply: async (target: { channelId: string }, text: string) => {
|
|
65
70
|
deliveries.push({ transport: "discord", to: target.channelId, text });
|
|
66
|
-
return {};
|
|
71
|
+
return { messageIds: [] };
|
|
67
72
|
},
|
|
68
73
|
}));
|
|
69
74
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-facing rules for any reply that is synthesized to speech. Shared by
|
|
3
|
+
* the phone-call control prompt (voice-session-bridge.ts) and the in-app
|
|
4
|
+
* live-voice control prompt (live-voice/live-voice-session.ts) so the two
|
|
5
|
+
* surfaces cannot drift on what a spoken reply is allowed to look like.
|
|
6
|
+
*
|
|
7
|
+
* Both rules describe the delivery path, not the surface: the caller hears
|
|
8
|
+
* the text, and sanitizeForTts (tts-text-sanitizer.ts) strips markdown
|
|
9
|
+
* syntax but keeps the content, so a five-bullet answer is still five
|
|
10
|
+
* bullets of audio with the dashes removed. The length rule is what keeps
|
|
11
|
+
* that answer from being produced in the first place.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** One to three spoken sentences; the caller is listening, not reading. */
|
|
15
|
+
export const SPOKEN_REPLY_LENGTH_RULE =
|
|
16
|
+
"Keep each reply to one to three spoken sentences. The caller is listening, not reading: lead with the answer, say it in the fewest words that are still complete, and stop. No preamble, no recap of the question, no closing offer of more.";
|
|
17
|
+
|
|
18
|
+
/** Plain conversational text only; the text goes straight to a TTS engine. */
|
|
19
|
+
export const SPOKEN_REPLY_PLAIN_TEXT_RULE =
|
|
20
|
+
"Your text is sent directly to a text-to-speech engine. Never use markdown formatting (asterisks, headers, backticks, links), bulleted or numbered lists, or emojis. Write plain conversational text only; if something is naturally a list, say it as one sentence.";
|
|
@@ -58,6 +58,10 @@ import { sensitiveToolReach } from "../tools/tool-approval-handler.js";
|
|
|
58
58
|
import { createAbortReason } from "../util/abort-reasons.js";
|
|
59
59
|
import { getLogger } from "../util/logger.js";
|
|
60
60
|
import { truncate } from "../util/truncate.js";
|
|
61
|
+
import {
|
|
62
|
+
SPOKEN_REPLY_LENGTH_RULE,
|
|
63
|
+
SPOKEN_REPLY_PLAIN_TEXT_RULE,
|
|
64
|
+
} from "./spoken-reply-rules.js";
|
|
61
65
|
import {
|
|
62
66
|
CALL_OPENING_MARKER,
|
|
63
67
|
CALL_VERIFICATION_COMPLETE_MARKER,
|
|
@@ -630,7 +634,7 @@ function buildVoiceCallControlPrompt(opts: {
|
|
|
630
634
|
lines.push(
|
|
631
635
|
"CALL PROTOCOL RULES:",
|
|
632
636
|
disclosureRule,
|
|
633
|
-
|
|
637
|
+
`1. ${SPOKEN_REPLY_LENGTH_RULE}`,
|
|
634
638
|
...(opts.isCallerGuardian
|
|
635
639
|
? [
|
|
636
640
|
"2. You are speaking directly with your guardian (your user). Do NOT use [ASK_GUARDIAN:]. If you need permission, information, or confirmation, ask them directly in the conversation. They can answer you right now.",
|
|
@@ -691,7 +695,7 @@ function buildVoiceCallControlPrompt(opts: {
|
|
|
691
695
|
lines.push(
|
|
692
696
|
"9. After the opening greeting turn, treat the Task field as background context only — do not re-execute its instructions on subsequent turns.",
|
|
693
697
|
'10. Do not make up information. If you are unsure, use [ASK_GUARDIAN: your question] to consult your guardian. For tool permission requests, use [ASK_GUARDIAN_APPROVAL: {"question":"...","toolName":"...","input":{...}}].',
|
|
694
|
-
`11.
|
|
698
|
+
`11. ${SPOKEN_REPLY_PLAIN_TEXT_RULE} Protocol markers like ${opts.isCallerGuardian ? "[END_CALL]" : "[ASK_GUARDIAN: ...] and [END_CALL]"} are not spoken text and should still be used normally.`,
|
|
695
699
|
`12. Speak the caller's language: reply in the language of the caller's most recent actual speech, and follow them if they switch languages mid-call. Synthetic user turns (parenthetical markers like the call-connected and verification-completed notices) are not caller speech and never set the language. Before the caller has spoken, such as on the opening greeting turn, ${preSpeechLanguageRuleFragment(config.services.stt.language, sttCatalogKeyForRole(config.services.stt, "telephony"))}.`,
|
|
696
700
|
`13. ${PHONE_NO_SETUP_FLOWS_RULE}`,
|
|
697
701
|
);
|
|
@@ -334,7 +334,7 @@ export function createFrontDoorStreamGate(
|
|
|
334
334
|
* just above it.
|
|
335
335
|
*/
|
|
336
336
|
export const ESCALATION_CONTINUATION_CONTENT =
|
|
337
|
-
"(You just told the caller you needed a moment to think. Now
|
|
337
|
+
"(You just told the caller you needed a moment to think. Now answer their previous question out loud. Cover what they asked, but as a few spoken sentences rather than a written write-up: they are listening, not reading. Do not repeat the holding phrase.)";
|
|
338
338
|
|
|
339
339
|
/**
|
|
340
340
|
* Compact, registry-derived digest of the tools the ESCALATED leg can use.
|
|
@@ -109,7 +109,10 @@ mock.module("../../../ipc/gateway-client.js", () => ({
|
|
|
109
109
|
}),
|
|
110
110
|
}));
|
|
111
111
|
|
|
112
|
+
const actualSlackSend =
|
|
113
|
+
await import("../../../messaging/providers/slack/send.js");
|
|
112
114
|
mock.module("../../../messaging/providers/slack/send.js", () => ({
|
|
115
|
+
...actualSlackSend,
|
|
113
116
|
updateSlackMessage: async () => ({ ok: true, ts: "ts" }),
|
|
114
117
|
sendSlackReply: async (
|
|
115
118
|
chatId: string,
|
|
@@ -20,6 +20,7 @@ import { resolveProactiveHomeConversation } from "../../../../notifications/conv
|
|
|
20
20
|
import { recordDeliveredChannelPost } from "../../../../notifications/delivered-post-record.js";
|
|
21
21
|
import { getConversation } from "../../../../persistence/conversation-crud.js";
|
|
22
22
|
import { syncMessageToDisk } from "../../../../persistence/conversation-disk-view.js";
|
|
23
|
+
import { normalizeExternalThreadId } from "../../../../persistence/external-conversation-store.js";
|
|
23
24
|
import type {
|
|
24
25
|
ToolContext,
|
|
25
26
|
ToolExecutionResult,
|
|
@@ -64,26 +65,48 @@ const ATTACHMENT_CAPABLE_PLATFORMS = new Set(["gmail", "outlook"]);
|
|
|
64
65
|
* envelope and in `channel_outbound_posts` like every other post the daemon
|
|
65
66
|
* makes.
|
|
66
67
|
*
|
|
67
|
-
* A send
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
68
|
+
* A send into the sending turn's own chat writes no row: its tool call and
|
|
69
|
+
* result already sit in that conversation's history, and a second assistant
|
|
70
|
+
* row beside the tool pair would break history repair. Whether the send is
|
|
71
|
+
* "its own" is decided by the delivered target against the turn, not by the
|
|
72
|
+
* home: the turn arrived on a channel, in a chat, in a thread (the turn-local
|
|
73
|
+
* snapshot on the tool context), and a post the provider delivered to exactly
|
|
74
|
+
* that place is a same-conversation send even when the chat's home resolves
|
|
75
|
+
* elsewhere, as it does for a thread-scoped chat whose home is the chat's
|
|
76
|
+
* notification conversation. The delivered thread is the one the provider
|
|
77
|
+
* reports, not the one requested: a provider that ignores the request lands
|
|
78
|
+
* the post in the thread-less chat, and the record must say so. The home
|
|
79
|
+
* comparison stays as the second test, for a sender that arrived through no
|
|
80
|
+
* channel but is the home. The post is then in the outbound index only
|
|
81
|
+
* through no path, which is the same class as a raw API send and is deferred
|
|
82
|
+
* with it.
|
|
72
83
|
*
|
|
73
84
|
* Failures here never fail the send: the message is already out.
|
|
74
85
|
*/
|
|
75
86
|
async function recordSentChannelPost(params: {
|
|
76
87
|
providerId: string;
|
|
77
88
|
externalChatId: string;
|
|
89
|
+
/** The thread the provider delivered into, as it reported it. */
|
|
90
|
+
deliveredThreadId: string | undefined;
|
|
78
91
|
text: string;
|
|
79
92
|
providerMessageId: string;
|
|
80
|
-
|
|
93
|
+
sender: ToolContext;
|
|
81
94
|
}): Promise<void> {
|
|
82
|
-
const { providerId, externalChatId } = params;
|
|
95
|
+
const { providerId, externalChatId, sender } = params;
|
|
83
96
|
if (!isChannelId(providerId) || !params.providerMessageId) {
|
|
84
97
|
return;
|
|
85
98
|
}
|
|
86
99
|
try {
|
|
100
|
+
if (
|
|
101
|
+
isSendIntoOwnChat(
|
|
102
|
+
sender,
|
|
103
|
+
providerId,
|
|
104
|
+
externalChatId,
|
|
105
|
+
params.deliveredThreadId,
|
|
106
|
+
)
|
|
107
|
+
) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
87
110
|
const home = await resolveProactiveHomeConversation({
|
|
88
111
|
sourceChannel: providerId,
|
|
89
112
|
externalChatId,
|
|
@@ -91,7 +114,7 @@ async function recordSentChannelPost(params: {
|
|
|
91
114
|
conversationType: "background",
|
|
92
115
|
title: `Messages to ${externalChatId}`,
|
|
93
116
|
});
|
|
94
|
-
if (home.conversationId ===
|
|
117
|
+
if (home.conversationId === sender.conversationId) {
|
|
95
118
|
return;
|
|
96
119
|
}
|
|
97
120
|
const recorded = await recordDeliveredChannelPost({
|
|
@@ -100,7 +123,7 @@ async function recordSentChannelPost(params: {
|
|
|
100
123
|
externalChatId,
|
|
101
124
|
text: params.text,
|
|
102
125
|
providerMessageId: params.providerMessageId,
|
|
103
|
-
crossPostedFrom:
|
|
126
|
+
crossPostedFrom: sender.conversationId,
|
|
104
127
|
});
|
|
105
128
|
const homeConversation = getConversation(home.conversationId);
|
|
106
129
|
if (homeConversation) {
|
|
@@ -118,6 +141,32 @@ async function recordSentChannelPost(params: {
|
|
|
118
141
|
}
|
|
119
142
|
}
|
|
120
143
|
|
|
144
|
+
/**
|
|
145
|
+
* True when the delivered chat and thread are the ones the sending turn
|
|
146
|
+
* arrived in. Reads the turn-local snapshot the tool context carries
|
|
147
|
+
* (`executionChannel`, `requesterChatId`, `sourceThreadId`), never the live
|
|
148
|
+
* binding, which a concurrent inbound can rewrite while this call runs.
|
|
149
|
+
* Thread ids compare in the binding store's normalized form, so an absent
|
|
150
|
+
* thread on both sides matches and a thread-less delivery never matches a
|
|
151
|
+
* turn that arrived in a thread.
|
|
152
|
+
*/
|
|
153
|
+
function isSendIntoOwnChat(
|
|
154
|
+
sender: ToolContext,
|
|
155
|
+
channel: string,
|
|
156
|
+
externalChatId: string,
|
|
157
|
+
deliveredThreadId: string | undefined,
|
|
158
|
+
): boolean {
|
|
159
|
+
if (!sender.executionChannel || !sender.requesterChatId) {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
return (
|
|
163
|
+
sender.executionChannel === channel &&
|
|
164
|
+
sender.requesterChatId === externalChatId &&
|
|
165
|
+
normalizeExternalThreadId(sender.sourceThreadId) ===
|
|
166
|
+
normalizeExternalThreadId(deliveredThreadId)
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
121
170
|
export async function run(
|
|
122
171
|
input: Record<string, unknown>,
|
|
123
172
|
context: ToolContext,
|
|
@@ -372,9 +421,10 @@ export async function run(
|
|
|
372
421
|
await recordSentChannelPost({
|
|
373
422
|
providerId: provider.id,
|
|
374
423
|
externalChatId: conversationId,
|
|
424
|
+
deliveredThreadId: result.threadId,
|
|
375
425
|
text,
|
|
376
426
|
providerMessageId: result.id,
|
|
377
|
-
|
|
427
|
+
sender: context,
|
|
378
428
|
});
|
|
379
429
|
|
|
380
430
|
return ok(`Message sent (ID: ${result.id}${threadSuffix}).`);
|