@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.
Files changed (55) hide show
  1. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/reactions.ts +8 -2
  2. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/reactions.ts +8 -2
  3. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +6 -0
  4. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +8 -0
  5. package/node_modules/@vellumai/service-contracts/src/reactions.ts +8 -2
  6. package/node_modules/@vellumai/slack-text/src/emoji.test.ts +6 -0
  7. package/node_modules/@vellumai/slack-text/src/emoji.ts +3 -1
  8. package/openapi.yaml +2 -0
  9. package/package.json +1 -1
  10. package/src/__tests__/channel-guardian.test.ts +1 -0
  11. package/src/__tests__/discord-callback-round-trip.test.ts +6 -1
  12. package/src/__tests__/messaging-send-tool.test.ts +148 -0
  13. package/src/__tests__/notification-discord-adapter.test.ts +7 -2
  14. package/src/__tests__/notification-slack-adapter.test.ts +2 -0
  15. package/src/__tests__/notification-telegram-adapter.test.ts +8 -3
  16. package/src/__tests__/slack-edit-ordering-characterization.test.ts +17 -5
  17. package/src/__tests__/verification-outbound-delivery.test.ts +7 -2
  18. package/src/calls/spoken-reply-rules.ts +20 -0
  19. package/src/calls/voice-session-bridge.ts +6 -2
  20. package/src/calls/voice-triage-escalate.ts +1 -1
  21. package/src/channels/__tests__/types.test.ts +17 -0
  22. package/src/channels/types.ts +36 -49
  23. package/src/cli/commands/__tests__/clients.help.test.ts +35 -0
  24. package/src/cli/commands/__tests__/conversations-slack.test.ts +3 -0
  25. package/src/cli/commands/clients.help.ts +49 -7
  26. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +60 -10
  27. package/src/daemon/reaction-record.test.ts +15 -11
  28. package/src/daemon/reaction-record.ts +10 -2
  29. package/src/live-voice/__tests__/live-voice-events.test.ts +1 -1
  30. package/src/live-voice/live-voice-session.ts +31 -7
  31. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +91 -6
  32. package/src/messaging/providers/channel-transport.ts +11 -0
  33. package/src/messaging/providers/discord/send.test.ts +39 -0
  34. package/src/messaging/providers/discord/send.ts +28 -14
  35. package/src/messaging/providers/discord/transport.ts +8 -1
  36. package/src/messaging/providers/index.ts +17 -0
  37. package/src/messaging/providers/send-result.ts +35 -0
  38. package/src/messaging/providers/slack/adapter.ts +3 -0
  39. package/src/messaging/providers/slack/send.test.ts +24 -0
  40. package/src/messaging/providers/slack/send.ts +32 -1
  41. package/src/messaging/providers/slack/transport.ts +10 -1
  42. package/src/messaging/providers/telegram-bot/send.test.ts +67 -0
  43. package/src/messaging/providers/telegram-bot/send.ts +26 -21
  44. package/src/messaging/providers/telegram-bot/transport.ts +9 -7
  45. package/src/messaging/providers/whatsapp/send.test.ts +139 -0
  46. package/src/messaging/providers/whatsapp/send.ts +29 -9
  47. package/src/messaging/providers/whatsapp/transport.ts +7 -3
  48. package/src/messaging/reaction-envelopes.ts +4 -3
  49. package/src/persistence/external-conversation-store.ts +6 -1
  50. package/src/runtime/routes/inbound-message-handler.ts +6 -0
  51. package/src/runtime/routes/inbound-stages/bootstrap-intercept.test.ts +6 -0
  52. package/src/runtime/routes/inbound-stages/edit-intercept.ts +127 -3
  53. package/src/tools/channel/react-to-message.test.ts +6 -0
  54. package/src/tools/channel/react-to-message.ts +2 -0
  55. package/src/types/host-capabilities.ts +95 -0
@@ -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
- ): ReactionEmojiFields & { emojiKind: ReactionEmojiKind; emojiName: string } {
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
- ): ReactionEmojiFields & { emojiKind: ReactionEmojiKind; emojiName: string } {
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
- ): ReactionEmojiFields & { emojiKind: ReactionEmojiKind; emojiName: string } {
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
- ): ReactionEmojiFields & { emojiKind: ReactionEmojiKind; emojiName: string } {
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
- ): ReactionEmojiFields & { emojiKind: ReactionEmojiKind; emojiName: string } {
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
- ): ReactionEmojiFields & { emojiKind: ReactionEmojiKind; emojiName: string } {
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 === 1 && modifiers[0] === modifier;
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
@@ -4553,6 +4553,8 @@ paths:
4553
4553
  - type
4554
4554
  slackBotMentioned:
4555
4555
  type: boolean
4556
+ slackEditedTs:
4557
+ type: string
4556
4558
  account:
4557
4559
  type: string
4558
4560
  actorTeamId:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.10-dev.202609092113.0fb45bc",
3
+ "version": "0.11.10-dev.202609092314.f7dc525",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 { messageId: "1401234567890123456" };
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
- DiscordPartialSendError: actualSend.DiscordPartialSendError,
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 { lastMessageId: String(2000 + sendCalls.length) };
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
- // Mirror the real send result: the id of the sent message, which the
40
- // adapter surfaces so the delivery row can address the card later.
41
- return { lastMessageId: String(1000 + sendCalls.length) };
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(eventId: string, content: string): Promise<void> {
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.todo(
94
- "GUARD (future PR): an edit whose Slack edited.ts is older than the stored one is ignored",
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
- "1. Be concise — keep responses to 1-3 sentences. Phone conversations should be brief and natural.",
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. Your text is sent directly to a text-to-speech engine. Never use markdown formatting (asterisks, headers, backticks, links) or emojis in your spoken responses. Write plain conversational text only. Protocol markers like ${opts.isCallerGuardian ? "[END_CALL]" : "[ASK_GUARDIAN: ...] and [END_CALL]"} are not spoken text and should still be used normally.`,
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 give them your full, careful answer to their previous question — do not repeat the holding phrase.)";
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);