@vellumai/assistant 0.11.10-dev.202609092113.0fb45bc → 0.11.10-dev.202609092314.f7dc525
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/inbound-contract.ts +6 -0
- 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/openapi.yaml +2 -0
- 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__/slack-edit-ordering-characterization.test.ts +17 -5
- 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/channels/__tests__/types.test.ts +17 -0
- package/src/channels/types.ts +36 -49
- package/src/cli/commands/__tests__/clients.help.test.ts +35 -0
- package/src/cli/commands/__tests__/conversations-slack.test.ts +3 -0
- package/src/cli/commands/clients.help.ts +49 -7
- 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-message-handler.ts +6 -0
- package/src/runtime/routes/inbound-stages/bootstrap-intercept.test.ts +6 -0
- package/src/runtime/routes/inbound-stages/edit-intercept.ts +127 -3
- package/src/tools/channel/react-to-message.test.ts +6 -0
- package/src/tools/channel/react-to-message.ts +2 -0
- package/src/types/host-capabilities.ts +95 -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),
|
|
@@ -85,6 +85,12 @@ export const SourceMetadataSchema = z
|
|
|
85
85
|
commandIntent: CommandIntentSchema.optional(),
|
|
86
86
|
/** Slack-specific: whether the bot was @-mentioned. */
|
|
87
87
|
slackBotMentioned: z.boolean().optional(),
|
|
88
|
+
/**
|
|
89
|
+
* Slack-specific: the `edited.ts` float-string from a `message_changed`
|
|
90
|
+
* event. Used by the daemon to reject out-of-order edit deliveries whose
|
|
91
|
+
* edit timestamp is older than the one already stored.
|
|
92
|
+
*/
|
|
93
|
+
slackEditedTs: z.string().optional(),
|
|
88
94
|
/** Slack workspace/team ID. */
|
|
89
95
|
account: z.string().optional(),
|
|
90
96
|
/**
|
|
@@ -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/openapi.yaml
CHANGED
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,
|
|
@@ -53,7 +53,11 @@ async function seedMessage(): Promise<string> {
|
|
|
53
53
|
return inserted.id;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
async function applyEdit(
|
|
56
|
+
async function applyEdit(
|
|
57
|
+
eventId: string,
|
|
58
|
+
content: string,
|
|
59
|
+
slackEditedTs?: string,
|
|
60
|
+
): Promise<void> {
|
|
57
61
|
await handleEditIntercept({
|
|
58
62
|
sourceChannel: "slack",
|
|
59
63
|
conversationExternalId: CHANNEL,
|
|
@@ -61,6 +65,7 @@ async function applyEdit(eventId: string, content: string): Promise<void> {
|
|
|
61
65
|
sourceMessageId: ORIGINAL_TS,
|
|
62
66
|
assistantId: "self",
|
|
63
67
|
content,
|
|
68
|
+
slackEditedTs,
|
|
64
69
|
});
|
|
65
70
|
}
|
|
66
71
|
|
|
@@ -90,10 +95,17 @@ describe("Slack edit ordering (characterization)", () => {
|
|
|
90
95
|
expect(stringifyMessageContent(row!.content)).toBe("revised text");
|
|
91
96
|
});
|
|
92
97
|
|
|
93
|
-
test.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
98
|
+
test("GUARD: an edit whose Slack edited.ts is older than the stored one is ignored", async () => {
|
|
99
|
+
const messageId = await seedMessage();
|
|
100
|
+
|
|
101
|
+
// The newer edit (ts = .000300) arrives first and is applied.
|
|
102
|
+
await applyEdit("edit-event-newer", "second revision", "1700000000.000300");
|
|
103
|
+
// The older edit (ts = .000200) arrives late; the guard must reject it.
|
|
104
|
+
await applyEdit("edit-event-older", "first revision", "1700000000.000200");
|
|
105
|
+
|
|
106
|
+
const row = getMessageById(messageId);
|
|
107
|
+
expect(stringifyMessageContent(row!.content)).toBe("second revision");
|
|
108
|
+
});
|
|
97
109
|
|
|
98
110
|
test.todo(
|
|
99
111
|
"GUARD (future PR): content and mention source data are replaced in the same transaction on every applied edit",
|
|
@@ -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.
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
CLIENT_OS_VALUES,
|
|
5
5
|
INTERACTIVE_INTERFACES,
|
|
6
6
|
INTERFACE_IDS,
|
|
7
|
+
type InterfaceId,
|
|
7
8
|
isInterfaceId,
|
|
8
9
|
parseClientOs,
|
|
9
10
|
parseInterfaceId,
|
|
@@ -90,6 +91,22 @@ describe("parseInterfaceId", () => {
|
|
|
90
91
|
});
|
|
91
92
|
|
|
92
93
|
describe("supportsHostProxy", () => {
|
|
94
|
+
// The capability table is a plain object, so an id that names something on
|
|
95
|
+
// Object.prototype must not resolve to the inherited member. Lookups use
|
|
96
|
+
// Object.hasOwn; with `in` these return a function and the caller throws.
|
|
97
|
+
test("object prototype keys are not interfaces", () => {
|
|
98
|
+
for (const id of [
|
|
99
|
+
"toString",
|
|
100
|
+
"constructor",
|
|
101
|
+
"valueOf",
|
|
102
|
+
"hasOwnProperty",
|
|
103
|
+
"isPrototypeOf",
|
|
104
|
+
] as unknown as InterfaceId[]) {
|
|
105
|
+
expect(supportsHostProxy(id)).toBe(false);
|
|
106
|
+
expect(supportsHostProxy(id, "host_bash")).toBe(false);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
93
110
|
// ── macOS: supports all four host proxy capabilities. ──
|
|
94
111
|
test("macos returns true (no capability)", () => {
|
|
95
112
|
expect(supportsHostProxy("macos")).toBe(true);
|