@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.
Files changed (45) 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/outbound-contract.ts +8 -0
  4. package/node_modules/@vellumai/service-contracts/src/reactions.ts +8 -2
  5. package/node_modules/@vellumai/slack-text/src/emoji.test.ts +6 -0
  6. package/node_modules/@vellumai/slack-text/src/emoji.ts +3 -1
  7. package/package.json +1 -1
  8. package/src/__tests__/channel-guardian.test.ts +1 -0
  9. package/src/__tests__/discord-callback-round-trip.test.ts +6 -1
  10. package/src/__tests__/messaging-send-tool.test.ts +148 -0
  11. package/src/__tests__/notification-discord-adapter.test.ts +7 -2
  12. package/src/__tests__/notification-slack-adapter.test.ts +2 -0
  13. package/src/__tests__/notification-telegram-adapter.test.ts +8 -3
  14. package/src/__tests__/verification-outbound-delivery.test.ts +7 -2
  15. package/src/calls/spoken-reply-rules.ts +20 -0
  16. package/src/calls/voice-session-bridge.ts +6 -2
  17. package/src/calls/voice-triage-escalate.ts +1 -1
  18. package/src/cli/commands/__tests__/conversations-slack.test.ts +3 -0
  19. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +60 -10
  20. package/src/daemon/reaction-record.test.ts +15 -11
  21. package/src/daemon/reaction-record.ts +10 -2
  22. package/src/live-voice/__tests__/live-voice-events.test.ts +1 -1
  23. package/src/live-voice/live-voice-session.ts +31 -7
  24. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +91 -6
  25. package/src/messaging/providers/channel-transport.ts +11 -0
  26. package/src/messaging/providers/discord/send.test.ts +39 -0
  27. package/src/messaging/providers/discord/send.ts +28 -14
  28. package/src/messaging/providers/discord/transport.ts +8 -1
  29. package/src/messaging/providers/index.ts +17 -0
  30. package/src/messaging/providers/send-result.ts +35 -0
  31. package/src/messaging/providers/slack/adapter.ts +3 -0
  32. package/src/messaging/providers/slack/send.test.ts +24 -0
  33. package/src/messaging/providers/slack/send.ts +32 -1
  34. package/src/messaging/providers/slack/transport.ts +10 -1
  35. package/src/messaging/providers/telegram-bot/send.test.ts +67 -0
  36. package/src/messaging/providers/telegram-bot/send.ts +26 -21
  37. package/src/messaging/providers/telegram-bot/transport.ts +9 -7
  38. package/src/messaging/providers/whatsapp/send.test.ts +139 -0
  39. package/src/messaging/providers/whatsapp/send.ts +29 -9
  40. package/src/messaging/providers/whatsapp/transport.ts +7 -3
  41. package/src/messaging/reaction-envelopes.ts +4 -3
  42. package/src/persistence/external-conversation-store.ts +6 -1
  43. package/src/runtime/routes/inbound-stages/bootstrap-intercept.test.ts +6 -0
  44. package/src/tools/channel/react-to-message.test.ts +6 -0
  45. package/src/tools/channel/react-to-message.ts +2 -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),
@@ -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/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.202609092213.434c99d",
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,
@@ -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.
@@ -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 made from inside the home conversation itself writes no row: its
68
- * tool call and result already sit in that conversation's history, and a
69
- * second assistant row beside the tool pair would break history repair. The
70
- * post is then in the outbound index only through no path, which is the
71
- * same class as a raw API send and is deferred with it.
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
- senderConversationId: string;
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 === params.senderConversationId) {
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: params.senderConversationId,
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
- senderConversationId: context.conversationId,
427
+ sender: context,
378
428
  });
379
429
 
380
430
  return ok(`Message sent (ID: ${result.id}${threadSuffix}).`);