switchroom 0.16.23 → 0.16.24

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 (127) hide show
  1. package/dist/agent-scheduler/index.js +80 -80
  2. package/dist/auth-broker/index.js +80 -80
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -10
  5. package/dist/cli/notion-write-pretool.mjs +82 -82
  6. package/dist/cli/self-improve-apply-guard-pretool.mjs +6 -0
  7. package/dist/cli/skill-validate-pretool.mjs +2936 -119
  8. package/dist/cli/switchroom.js +671 -430
  9. package/dist/host-control/main.js +156 -156
  10. package/dist/vault/approvals/kernel-server.js +82 -82
  11. package/dist/vault/broker/server.js +83 -83
  12. package/package.json +5 -5
  13. package/telegram-plugin/answer-stream.ts +13 -43
  14. package/telegram-plugin/auth-snapshot-format.ts +27 -30
  15. package/telegram-plugin/auto-fallback-fleet.ts +6 -11
  16. package/telegram-plugin/card-format.ts +28 -25
  17. package/telegram-plugin/credits-watch.ts +5 -10
  18. package/telegram-plugin/dist/bridge/bridge.js +112 -112
  19. package/telegram-plugin/dist/gateway/gateway.js +1855 -2077
  20. package/telegram-plugin/dist/server.js +160 -160
  21. package/telegram-plugin/draft-stream.ts +4 -4
  22. package/telegram-plugin/format.ts +101 -662
  23. package/telegram-plugin/gateway/approval-callback.ts +2 -3
  24. package/telegram-plugin/gateway/approval-card.test.ts +17 -4
  25. package/telegram-plugin/gateway/approval-card.ts +16 -6
  26. package/telegram-plugin/gateway/approvals-commands.ts +18 -24
  27. package/telegram-plugin/gateway/auth-command.ts +74 -74
  28. package/telegram-plugin/gateway/auth-line.ts +5 -15
  29. package/telegram-plugin/gateway/boot-card.ts +20 -22
  30. package/telegram-plugin/gateway/boot-version.ts +3 -2
  31. package/telegram-plugin/gateway/config-approval-handler.test.ts +35 -33
  32. package/telegram-plugin/gateway/config-approval-handler.ts +24 -24
  33. package/telegram-plugin/gateway/config-snapshot.ts +9 -9
  34. package/telegram-plugin/gateway/diff-preview-card.test.ts +8 -8
  35. package/telegram-plugin/gateway/diff-preview-card.ts +2 -5
  36. package/telegram-plugin/gateway/disconnect-flush.ts +0 -4
  37. package/telegram-plugin/gateway/effort-command.ts +17 -17
  38. package/telegram-plugin/gateway/folder-picker-handler.test.ts +8 -2
  39. package/telegram-plugin/gateway/folder-picker-handler.ts +3 -4
  40. package/telegram-plugin/gateway/gateway.ts +751 -624
  41. package/telegram-plugin/gateway/inject-handler.test.ts +15 -13
  42. package/telegram-plugin/gateway/inject-handler.ts +5 -5
  43. package/telegram-plugin/gateway/ipc-protocol.ts +33 -1
  44. package/telegram-plugin/gateway/ipc-server.ts +28 -0
  45. package/telegram-plugin/gateway/linear-activity.ts +16 -14
  46. package/telegram-plugin/gateway/linear-setup.ts +1 -1
  47. package/telegram-plugin/gateway/model-command.ts +25 -25
  48. package/telegram-plugin/gateway/oversize-card-body.ts +6 -7
  49. package/telegram-plugin/gateway/skill-proposal-card.ts +167 -0
  50. package/telegram-plugin/inline-keyboard-callbacks.ts +19 -13
  51. package/telegram-plugin/issues-card.ts +6 -7
  52. package/telegram-plugin/model-unavailable.ts +8 -12
  53. package/telegram-plugin/operator-events-history.ts +1 -1
  54. package/telegram-plugin/operator-events.ts +24 -28
  55. package/telegram-plugin/package.json +1 -1
  56. package/telegram-plugin/pending-work-progress.ts +36 -36
  57. package/telegram-plugin/permission-title.ts +36 -20
  58. package/telegram-plugin/pty-partial-handler.ts +5 -13
  59. package/telegram-plugin/quota-check.ts +5 -5
  60. package/telegram-plugin/quota-watch.ts +13 -18
  61. package/telegram-plugin/recent-outbound-dedup.ts +5 -5
  62. package/telegram-plugin/registry/turns-schema.ts +43 -3
  63. package/telegram-plugin/retry-api-call.ts +15 -7
  64. package/telegram-plugin/rich-send.ts +57 -0
  65. package/telegram-plugin/secret-detect/vault-error.test.ts +6 -6
  66. package/telegram-plugin/secret-detect/vault-error.ts +29 -22
  67. package/telegram-plugin/shared/bot-runtime.ts +23 -6
  68. package/telegram-plugin/silence-poke.ts +26 -69
  69. package/telegram-plugin/slot-banner-driver.ts +9 -6
  70. package/telegram-plugin/slot-banner.ts +5 -8
  71. package/telegram-plugin/steering.ts +0 -4
  72. package/telegram-plugin/stream-controller.ts +59 -62
  73. package/telegram-plugin/stream-reply-handler.ts +40 -97
  74. package/telegram-plugin/subagent-watcher.ts +2 -2
  75. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +5 -2
  76. package/telegram-plugin/tests/answer-stream.test.ts +54 -63
  77. package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
  78. package/telegram-plugin/tests/auth-command-vernacular.test.ts +3 -2
  79. package/telegram-plugin/tests/auth-snapshot-format.test.ts +19 -18
  80. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +15 -13
  81. package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +27 -12
  82. package/telegram-plugin/tests/boot-card-render.test.ts +59 -48
  83. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  84. package/telegram-plugin/tests/bot-api.harness.ts +23 -1
  85. package/telegram-plugin/tests/bot-runtime.test.ts +23 -18
  86. package/telegram-plugin/tests/card-format.test.ts +6 -4
  87. package/telegram-plugin/tests/config-snapshot.test.ts +1 -1
  88. package/telegram-plugin/tests/credits-watch.test.ts +5 -5
  89. package/telegram-plugin/tests/fake-bot-api.ts +58 -4
  90. package/telegram-plugin/tests/finalize-callback.test.ts +11 -9
  91. package/telegram-plugin/tests/foreground-nesting.test.ts +1 -1
  92. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +3 -13
  93. package/telegram-plugin/tests/issues-card.test.ts +15 -12
  94. package/telegram-plugin/tests/linear-agent-activity.test.ts +8 -5
  95. package/telegram-plugin/tests/model-command.test.ts +2 -2
  96. package/telegram-plugin/tests/model-unavailable.test.ts +13 -13
  97. package/telegram-plugin/tests/multi-turn-continuity.test.ts +6 -10
  98. package/telegram-plugin/tests/operator-events.test.ts +7 -9
  99. package/telegram-plugin/tests/pending-work-progress.test.ts +20 -21
  100. package/telegram-plugin/tests/permission-title.test.ts +45 -41
  101. package/telegram-plugin/tests/pty-partial-handler.test.ts +8 -8
  102. package/telegram-plugin/tests/quota-check.test.ts +3 -3
  103. package/telegram-plugin/tests/quota-watch.test.ts +8 -4
  104. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +4 -3
  105. package/telegram-plugin/tests/silence-poke.test.ts +75 -112
  106. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +137 -0
  107. package/telegram-plugin/tests/skill-proposal-card.test.ts +103 -0
  108. package/telegram-plugin/tests/slot-banner-driver.e2e.test.ts +36 -24
  109. package/telegram-plugin/tests/slot-banner.test.ts +9 -6
  110. package/telegram-plugin/tests/status-accent.test.ts +29 -32
  111. package/telegram-plugin/tests/{stream-controller-html-fallback.test.ts → stream-controller-parse-fallback.test.ts} +40 -42
  112. package/telegram-plugin/tests/stream-controller.test.ts +63 -52
  113. package/telegram-plugin/tests/stream-reply-error-paths.test.ts +43 -38
  114. package/telegram-plugin/tests/stream-reply-handler.test.ts +122 -249
  115. package/telegram-plugin/tests/streaming-e2e.test.ts +35 -30
  116. package/telegram-plugin/tests/streaming-orchestration.test.ts +29 -28
  117. package/telegram-plugin/tests/telegram-format.test.ts +120 -1083
  118. package/telegram-plugin/tests/tool-activity-summary.test.ts +144 -145
  119. package/telegram-plugin/tests/welcome-text.test.ts +72 -65
  120. package/telegram-plugin/tests/worker-activity-feed.test.ts +119 -137
  121. package/telegram-plugin/text-voice-scrub.ts +8 -11
  122. package/telegram-plugin/tool-activity-summary.ts +29 -29
  123. package/telegram-plugin/welcome-text.ts +82 -83
  124. package/telegram-plugin/worker-activity-feed.ts +2 -3
  125. package/telegram-plugin/html-sanitize.ts +0 -244
  126. package/telegram-plugin/tests/html-sanitize.test.ts +0 -146
  127. package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -162
@@ -31,10 +31,15 @@ export interface BannerBotApi {
31
31
  text: string,
32
32
  opts?: Record<string, unknown>,
33
33
  ): Promise<{ message_id: number }>;
34
+ sendRichMessage(
35
+ chat_id: string | number,
36
+ rich_message: { markdown: string },
37
+ opts?: Record<string, unknown>,
38
+ ): Promise<{ message_id: number }>;
34
39
  editMessageText(
35
40
  chat_id: string | number,
36
41
  message_id: number,
37
- text: string,
42
+ text: string | { markdown: string },
38
43
  opts?: Record<string, unknown>,
39
44
  ): Promise<unknown>;
40
45
  pinChatMessage(
@@ -107,9 +112,8 @@ export async function refreshBanner(
107
112
  if (action.kind === 'pin') {
108
113
  let sent: { message_id: number };
109
114
  try {
110
- sent = await args.bot.api.sendMessage(args.ownerChatId, action.text, {
111
- parse_mode: 'HTML',
112
- link_preview_options: { is_disabled: true },
115
+ // sendRichMessage doesn't accept link_preview_options — omit it.
116
+ sent = await args.bot.api.sendRichMessage(args.ownerChatId, { markdown: action.text }, {
113
117
  // OAuth slot banner is a status notice — silence the open ping.
114
118
  // (the pin below is already silent; the edit path doesn't ping.)
115
119
  disable_notification: true,
@@ -135,9 +139,8 @@ export async function refreshBanner(
135
139
  await args.bot.api.editMessageText(
136
140
  args.ownerChatId,
137
141
  action.messageId,
138
- action.text,
142
+ { markdown: action.text },
139
143
  {
140
- parse_mode: 'HTML',
141
144
  link_preview_options: { is_disabled: true },
142
145
  },
143
146
  );
@@ -72,15 +72,12 @@ export function formatBannerHtml(
72
72
  defaultSlot: string,
73
73
  ): string {
74
74
  return [
75
- `📌 <b>${escapeHtml(agentName)}</b> is running on slot <code>${escapeHtml(currentSlot)}</code>`,
76
- `<i>(failover from <code>${escapeHtml(defaultSlot)}</code>)</i>`,
75
+ `📌 **${escapeMarkdown(agentName)}** is running on slot \`${currentSlot}\``,
76
+ `_(failover from \`${defaultSlot}\`)_`,
77
77
  ].join(' ');
78
78
  }
79
79
 
80
- function escapeHtml(text: string): string {
81
- return text
82
- .replace(/&/g, '&amp;')
83
- .replace(/</g, '&lt;')
84
- .replace(/>/g, '&gt;')
85
- .replace(/"/g, '&quot;');
80
+ /** Local GFM-markdown escaper (keeps this module dependency-free). */
81
+ function escapeMarkdown(text: string): string {
82
+ return text.replace(/([\\`*_~=\[\]|])/g, '\\$1');
86
83
  }
@@ -9,7 +9,6 @@
9
9
  * These helpers are pure so server.ts stays testable without standing up
10
10
  * grammy.
11
11
  */
12
- import { escapeHtml } from './format.js'
13
12
 
14
13
  /**
15
14
  * Detect and strip the `/queue ` or `/q ` prefix.
@@ -181,6 +180,3 @@ export function buildChannelMetaAttributes(opts: ChannelMetaAttributeOptions): s
181
180
  }
182
181
  return parts.length === 0 ? '' : ' ' + parts.join(' ')
183
182
  }
184
-
185
- /** Re-export so callers that want HTML-escape for body content don't need a second import. */
186
- export { escapeHtml }
@@ -6,10 +6,13 @@
6
6
  * - the `stream_reply` MCP case block (model-driven streaming)
7
7
  * - `handlePtyPartial` (PTY-tail TUI extractor → live preview)
8
8
  *
9
- * Both paths do the same thing: given a chat/thread/parseMode, create a
10
- * draft stream whose `send` closure calls `bot.api.sendMessage` and whose
11
- * `edit` closure calls `bot.api.editMessageText`, both wrapped in the
12
- * shared retry/429/not-modified policy (`robustApiCall`).
9
+ * Both paths do the same thing: given a chat/thread, create a draft stream
10
+ * whose `send` closure first calls `bot.api.sendRichMessage` and whose
11
+ * `edit` closure calls `bot.api.editMessageText({ markdown })`, both wrapped
12
+ * in the shared retry/429/not-modified policy (`robustApiCall`). Because
13
+ * send and edit share the same opts, the "edit-rich-if-sent-rich" invariant
14
+ * holds automatically (#2669). A `format:'text'` literal stream bypasses the
15
+ * rich parser entirely (plain `sendMessage` / plain-string `editMessageText`).
13
16
  *
14
17
  * This module exists primarily so that wiring can be exercised by
15
18
  * integration tests against a mock bot.api, without having to mock the
@@ -17,32 +20,11 @@
17
20
  */
18
21
 
19
22
  import { createDraftStream, type DraftStreamHandle } from './draft-stream.js'
20
- import { htmlToPlainText } from './html-sanitize.js'
21
-
22
- /**
23
- * Telegram returns `400 Bad Request: can't parse entities: …` when the
24
- * body contains malformed HTML / MarkdownV2 / nested unbalanced tags.
25
- * Detect that specific error class so we can fall back to plain text
26
- * without confusing it with other 400s (rate-limit, message-not-found,
27
- * thread-not-found, etc.).
28
- */
29
- function isParseEntitiesError(err: unknown): boolean {
30
- const msg =
31
- typeof err === 'string'
32
- ? err
33
- : err instanceof Error
34
- ? err.message
35
- : typeof err === 'object' && err != null && 'description' in err
36
- ? typeof (err as { description: unknown }).description === 'string'
37
- ? (err as { description: string }).description
38
- : ''
39
- : ''
40
- return /can't parse entities|can't find end of the entity|unsupported start tag|unmatched end tag/i.test(msg)
41
- }
23
+ import { richMessage, isParseEntitiesError } from './rich-send.js'
42
24
 
43
25
  /**
44
26
  * Minimal bot.api surface the controller needs. Real callers pass grammy's
45
- * `bot.api`; tests pass a mock with just these two methods.
27
+ * `bot.api`; tests pass a mock with these methods.
46
28
  */
47
29
  export interface StreamBotApi {
48
30
  sendMessage(
@@ -50,16 +32,20 @@ export interface StreamBotApi {
50
32
  text: string,
51
33
  opts: StreamSendOpts,
52
34
  ): Promise<{ message_id: number }>
35
+ sendRichMessage(
36
+ chat_id: string,
37
+ rich_message: { markdown: string },
38
+ opts: StreamSendOpts,
39
+ ): Promise<{ message_id: number }>
53
40
  editMessageText(
54
41
  chat_id: string,
55
42
  message_id: number,
56
- text: string,
43
+ text: string | { markdown: string },
57
44
  opts: StreamSendOpts,
58
45
  ): Promise<unknown>
59
46
  }
60
47
 
61
48
  export interface StreamSendOpts {
62
- parse_mode?: 'HTML' | 'MarkdownV2'
63
49
  message_thread_id?: number
64
50
  link_preview_options?: { is_disabled: boolean }
65
51
  /**
@@ -99,7 +85,13 @@ export interface StreamControllerConfig {
99
85
  bot: { api: StreamBotApi }
100
86
  chatId: string
101
87
  threadId?: number
102
- parseMode?: 'HTML' | 'MarkdownV2'
88
+ /**
89
+ * When true, this stream is a literal `format:'text'` send: the body
90
+ * bypasses the rich-markdown parser entirely (plain `sendMessage` /
91
+ * plain-string `editMessageText`). Default (false/undefined) → the
92
+ * rich-markdown path via `sendRichMessage` / `editMessageText({ markdown })`.
93
+ */
94
+ literalText?: boolean
103
95
  disableLinkPreview?: boolean
104
96
  /**
105
97
  * Optional quote-reply target. When set, the initial send attaches
@@ -186,7 +178,7 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
186
178
  bot,
187
179
  chatId,
188
180
  threadId,
189
- parseMode,
181
+ literalText = false,
190
182
  disableLinkPreview = true,
191
183
  throttleMs,
192
184
  idleMs,
@@ -205,9 +197,9 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
205
197
 
206
198
  // Base opts shared by send + edit. The initial send adds reply_parameters
207
199
  // and protect_content on top (see below); edits must NOT carry those —
208
- // Telegram's editMessageText rejects them.
200
+ // Telegram's editMessageText rejects them. Both send and edit share these
201
+ // opts, so a rich send is always followed by a rich edit (#2669 invariant).
209
202
  const baseOpts: StreamSendOpts = {
210
- ...(parseMode ? { parse_mode: parseMode } : {}),
211
203
  ...(threadId != null ? { message_thread_id: threadId } : {}),
212
204
  ...(disableLinkPreview ? { link_preview_options: { is_disabled: true } } : {}),
213
205
  ...(replyMarkup != null ? { reply_markup: replyMarkup } : {}),
@@ -226,38 +218,44 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
226
218
  ...(cfg.disableNotification === true ? { disable_notification: true } : {}),
227
219
  }
228
220
 
229
- // Strip parse_mode from a copy of opts — used for the parse-entities
230
- // fallback path. We keep thread/preview/reply markup untouched.
231
- const sendOptsPlain: StreamSendOpts = { ...sendOpts }
232
- delete sendOptsPlain.parse_mode
233
- const baseOptsPlain: StreamSendOpts = { ...baseOpts }
234
- delete baseOptsPlain.parse_mode
221
+ // Send the body via the rich-markdown path, unless this is a literal
222
+ // `format:'text'` stream (plain sendMessage, no rich wrapper).
223
+ // sendRichMessage does NOT accept link_preview_options (rich messages
224
+ // control previews via entity detection), so strip it for the rich path.
225
+ const doSend = (text: string, opts: StreamSendOpts) => {
226
+ if (literalText) return bot.api.sendMessage(chatId, text, opts)
227
+ const richOpts = { ...opts }
228
+ delete richOpts.link_preview_options
229
+ return bot.api.sendRichMessage(chatId, richMessage(text), richOpts)
230
+ }
231
+ const doEdit = (id: number, text: string, opts: StreamSendOpts) =>
232
+ bot.api.editMessageText(chatId, id, literalText ? text : richMessage(text), opts)
235
233
 
236
234
  return createDraftStream(
237
235
  async (text) => {
238
236
  try {
239
237
  const sent = await retry(
240
- () => bot.api.sendMessage(chatId, text, sendOpts),
238
+ () => doSend(text, sendOpts),
241
239
  { threadId, chat_id: chatId },
242
240
  )
243
241
  onSend?.(sent.message_id, text.length)
244
242
  return sent.message_id
245
243
  } catch (err) {
246
- if (parseMode != null && isParseEntitiesError(err)) {
247
- // First send rejected for parse_mode error. There is no
248
- // message_id to edit (the send 400'd before any message was
249
- // created), so a single fresh send in plain-text is the
250
- // correct recovery — see issue #657. Strip tags from the
251
- // body so the user sees readable prose, not raw markup.
244
+ if (!literalText && isParseEntitiesError(err)) {
245
+ // First send rejected because the markdown couldn't be parsed.
246
+ // There is no message_id to edit (the send 400'd before any
247
+ // message was created), so a single fresh send as PLAIN text
248
+ // (no rich wrapper, so the parser never runs) is the correct
249
+ // recovery — see issue #657. The raw markdown source is itself
250
+ // readable, so we send it verbatim.
252
251
  warn?.(
253
- `stream-controller: sendMessage parse-entities rejected — retrying once as plain text (${err instanceof Error ? err.message : String(err)})`,
252
+ `stream-controller: send parse-entities rejected — retrying once as plain text (${err instanceof Error ? err.message : String(err)})`,
254
253
  )
255
- const plainText = htmlToPlainText(text)
256
254
  const sent = await retry(
257
- () => bot.api.sendMessage(chatId, plainText, sendOptsPlain),
255
+ () => bot.api.sendMessage(chatId, text, sendOpts),
258
256
  { threadId, chat_id: chatId },
259
257
  )
260
- onSend?.(sent.message_id, plainText.length)
258
+ onSend?.(sent.message_id, text.length)
261
259
  return sent.message_id
262
260
  }
263
261
  throw err
@@ -266,27 +264,26 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
266
264
  async (id, text) => {
267
265
  try {
268
266
  await retry(
269
- () => bot.api.editMessageText(chatId, id, text, baseOpts),
267
+ () => doEdit(id, text, baseOpts),
270
268
  { threadId, chat_id: chatId },
271
269
  )
272
270
  onEdit?.(id, text.length)
273
271
  } catch (err) {
274
- if (parseMode != null && isParseEntitiesError(err)) {
275
- // Edit rejected for parse_mode error — DO NOT send a fresh
276
- // message. The whole point of issue #657 is that the previous
277
- // implementation sent a duplicate plain-text message every
278
- // time HTML rejection fired. Retry the edit on the SAME
279
- // message_id with parse_mode stripped and the body
280
- // tag-stripped to plain text.
272
+ if (!literalText && isParseEntitiesError(err)) {
273
+ // Edit rejected because the markdown couldn't be parsed — DO NOT
274
+ // send a fresh message. The whole point of issue #657 is that the
275
+ // previous implementation sent a duplicate message every time a
276
+ // parse rejection fired. Retry the edit on the SAME message_id as
277
+ // PLAIN text (no rich wrapper, so the parser never runs). The raw
278
+ // markdown source is itself readable, so we send it verbatim.
281
279
  warn?.(
282
- `stream-controller: editMessageText parse-entities rejected — retrying same id=${id} as plain text (${err instanceof Error ? err.message : String(err)})`,
280
+ `stream-controller: edit parse-entities rejected — retrying same id=${id} as plain text (${err instanceof Error ? err.message : String(err)})`,
283
281
  )
284
- const plainText = htmlToPlainText(text)
285
282
  await retry(
286
- () => bot.api.editMessageText(chatId, id, plainText, baseOptsPlain),
283
+ () => bot.api.editMessageText(chatId, id, text, baseOpts),
287
284
  { threadId, chat_id: chatId },
288
285
  )
289
- onEdit?.(id, plainText.length)
286
+ onEdit?.(id, text.length)
290
287
  return
291
288
  }
292
289
  throw err
@@ -22,28 +22,28 @@ import {
22
22
  type StreamBotApi,
23
23
  type RetryPolicy,
24
24
  } from './stream-controller.js'
25
- import { sanitizeTelegramHtml } from './html-sanitize.js'
25
+ import { RICH_MESSAGE_MAX_CHARS } from './format.js'
26
26
  import { chatKey, chatKeyWithSuffix } from './gateway/chat-key.js'
27
27
 
28
28
  /**
29
29
  * Builds the inline status-accent header line for `reply` / `stream_reply`.
30
30
  *
31
- * Returns a string to prepend to the effective (already-rendered) message
32
- * body, including a trailing blank line so the header is visually separated.
33
- * Returns an empty string when accent is undefined or unrecognised (silent
34
- * ignore) so calls without `accent` produce identical output to today.
31
+ * Returns a string to prepend to the message body, including a trailing
32
+ * blank line so the header is visually separated. Returns an empty string
33
+ * when accent is undefined or unrecognised (silent ignore) so calls without
34
+ * `accent` produce identical output to today.
35
35
  *
36
- * The header uses Telegram HTML tags. Callers must ensure parseMode is HTML
37
- * (or that the body has already been rendered to HTML) before prepending.
36
+ * The header is GFM markdown — every outbound goes through the rich-message
37
+ * path (#2669), so `_italic_` / `**bold**` render correctly.
38
38
  */
39
39
  export function buildAccentHeader(accent: string | undefined): string {
40
40
  switch (accent) {
41
41
  case 'in-progress':
42
- return '🔵 <i>In progress…</i>\n\n'
42
+ return '🔵 _In progress…_\n\n'
43
43
  case 'done':
44
- return '✅ <b>Done</b>\n\n'
44
+ return '✅ **Done**\n\n'
45
45
  case 'issue':
46
- return '⚠️ <b>Issue</b>\n\n'
46
+ return '⚠️ **Issue**\n\n'
47
47
  default:
48
48
  return ''
49
49
  }
@@ -118,9 +118,9 @@ export interface StreamReplyArgs {
118
118
  * Optional status accent prepended as a leading header line (issue #320
119
119
  * fallback for missing Telegram quote-bar color API).
120
120
  *
121
- * - `'in-progress'` → `🔵 <i>In progress…</i>\n\n`
122
- * - `'done'` → `✅ <b>Done</b>\n\n`
123
- * - `'issue'` → `⚠️ <b>Issue</b>\n\n`
121
+ * - `'in-progress'` → `🔵 _In progress…_\n\n`
122
+ * - `'done'` → `✅ **Done**\n\n`
123
+ * - `'issue'` → `⚠️ **Issue**\n\n`
124
124
  *
125
125
  * Unrecognised values are silently ignored. Omit for plain reply (default
126
126
  * behavior — identical to today's output).
@@ -130,21 +130,6 @@ export interface StreamReplyArgs {
130
130
 
131
131
  export interface StreamReplyState {
132
132
  activeDraftStreams: Map<string, DraftStreamHandle>
133
- /**
134
- * Tracks the parseMode each active stream was created with, keyed the
135
- * same way as `activeDraftStreams`. Used by `handleStreamReply` to
136
- * detect when a subsequent call's resolved parseMode differs from the
137
- * one baked into the existing stream controller — in that case the
138
- * stale stream is finalized + discarded and a fresh one is created
139
- * with the new parseMode (see bug 1: PTY-tail creates an activity-lane
140
- * stream with parseMode=undefined; a later explicit stream_reply on
141
- * the same key with format:'html' would otherwise inherit undefined
142
- * and send literal markdown).
143
- *
144
- * Optional for backwards compatibility with external callers that
145
- * construct a StreamReplyState without it.
146
- */
147
- activeDraftParseModes?: Map<string, 'HTML' | 'MarkdownV2' | undefined>
148
133
  /**
149
134
  * Chats whose PTY preview is claimed by an in-flight reply/stream_reply
150
135
  * handler. PTY-tail partials for these keys are dropped to avoid
@@ -165,10 +150,6 @@ export interface StreamReplyState {
165
150
  export interface StreamReplyDeps {
166
151
  bot: { api: StreamBotApi }
167
152
  retry?: RetryPolicy
168
- /** Markdown → HTML renderer (used when format === 'html'). */
169
- markdownToHtml: (text: string) => string
170
- /** MarkdownV2 escaper (used when format === 'markdownv2'). */
171
- escapeMarkdownV2: (text: string) => string
172
153
  /** Whitespace repair applied to the raw caller text. */
173
154
  repairEscapedWhitespace: (text: string) => string
174
155
  /** Validates the chat id against the access list. Throws on deny. */
@@ -185,7 +166,8 @@ export interface StreamReplyDeps {
185
166
  getLatestInboundMessageId?: (chatId: string, threadId: number | null) => number | null
186
167
  /** Config: disable link previews. Default true. */
187
168
  disableLinkPreview: boolean
188
- /** Config: fallback parse mode when args.format is omitted ('html' | 'markdownv2' | 'text'). */
169
+ /** Config: fallback format when args.format is omitted. Anything other
170
+ * than the literal `'text'` is treated as the rich-markdown path (#2669). */
189
171
  defaultFormat: string
190
172
  /** Observability: per-call event. */
191
173
  logStreamingEvent: (ev: {
@@ -341,53 +323,36 @@ export async function handleStreamReply(
341
323
  // the progressive-streaming contract documented in
342
324
  // profiles/default/CLAUDE.md. See #481.
343
325
 
344
- let parseMode: 'HTML' | 'MarkdownV2' | undefined
345
- let effectiveText: string
346
- if (format === 'html') {
347
- parseMode = 'HTML'
348
- // Pre-validate the rendered HTML against Telegram's tag allowlist
349
- // before send. The sanitizer escapes unknown tags, drops disallowed
350
- // attributes, and auto-closes unbalanced tags so we don't trip
351
- // Telegram's `400 Bad Request: can't parse entities` (issue #657).
352
- effectiveText = sanitizeTelegramHtml(deps.markdownToHtml(rawText))
353
- } else if (format === 'markdownv2') {
354
- parseMode = 'MarkdownV2'
355
- effectiveText = deps.escapeMarkdownV2(rawText)
356
- } else {
357
- parseMode = undefined
358
- effectiveText = rawText
359
- }
326
+ // Single rich-markdown path (#2669). The only fork is the literal
327
+ // `format:'text'` send (plain string, no markdown parsing); everything
328
+ // else ships the raw GFM markdown via the rich-message path. No
329
+ // markdown→HTML / MarkdownV2 rendering happens here anymore — the raw
330
+ // text IS the wire payload.
331
+ const literalText = format === 'text'
332
+ let effectiveText: string = rawText
360
333
 
361
- // Inline status-accent header (issue #320 fallback). Prepended AFTER
362
- // format rendering so it leads the fully-rendered body. Since
363
- // stream_reply callers pass the full text snapshot each call, the
364
- // header is prepended on every call that supplies `accent` — this
365
- // keeps the rendered message consistent across edits. Callers that
366
- // don't want the header on a subsequent edit simply omit `accent`.
367
- // Unrecognised values are silently ignored (empty string returned).
334
+ // Inline status-accent header (issue #320 fallback). Prepended so it
335
+ // leads the body. Since stream_reply callers pass the full text snapshot
336
+ // each call, the header is prepended on every call that supplies
337
+ // `accent` — keeping the rendered message consistent across edits.
338
+ // Callers that don't want the header on a subsequent edit simply omit
339
+ // `accent`. Unrecognised values are silently ignored (empty string).
368
340
  if (args.accent != null) {
369
341
  const accentHeader = buildAccentHeader(args.accent)
370
342
  if (accentHeader.length > 0) {
371
343
  effectiveText = accentHeader + effectiveText
372
- // Re-sanitize after prepending the HTML header so the combined
373
- // body is still guaranteed parse-mode=HTML safe.
374
- if (parseMode === 'HTML') {
375
- effectiveText = sanitizeTelegramHtml(effectiveText)
376
- }
377
344
  }
378
345
  }
379
346
 
380
347
  // Over-limit pre-check. Throws BEFORE touching stream state so that
381
- // (a) a first call over 4096 fails cleanly instead of creating a
382
- // half-initialized stream, and (b) a mid-stream update over 4096
348
+ // (a) a first call over the cap fails cleanly instead of creating a
349
+ // half-initialized stream, and (b) a mid-stream update over the cap
383
350
  // fails loudly instead of setting the internal `stopped=true` flag
384
351
  // and silently dropping all subsequent text. Either way the caller
385
352
  // sees isError:true and can fall back to `reply`, which chunks.
386
- // Check the rendered text (post-markdown-to-HTML) because that's
387
- // what actually goes to Telegram's 4096-char wire limit.
388
- if (effectiveText.length > 4096) {
353
+ if (effectiveText.length > RICH_MESSAGE_MAX_CHARS) {
389
354
  throw new Error(
390
- `stream_reply rejected: text exceeds Telegram's 4096-char limit ` +
355
+ `stream_reply rejected: text exceeds Telegram's ${RICH_MESSAGE_MAX_CHARS}-char rich-message limit ` +
391
356
  `(length=${effectiveText.length}, format=${format}). stream_reply does not ` +
392
357
  `auto-chunk — split the text or use \`reply\`, which chunks.`,
393
358
  )
@@ -401,30 +366,10 @@ export async function handleStreamReply(
401
366
  // targets. Cleared on turn_end by server.ts.
402
367
  state.suppressPtyPreview?.add(streamKey(chat_id, threadId))
403
368
  let stream = state.activeDraftStreams.get(sKey)
404
-
405
- // Bug 1 fix: parseMode is baked into the stream controller at creation
406
- // time. If a prior call created the stream with a different parseMode
407
- // (e.g. PTY-tail auto-stream using 'text' → undefined, followed by an
408
- // explicit stream_reply with format:'html'), reusing it would send
409
- // literal markdown. Finalize + discard the stale stream so the block
410
- // below creates a fresh one with the correct parseMode.
411
- if (stream != null && state.activeDraftParseModes != null) {
412
- const existingParseMode = state.activeDraftParseModes.get(sKey)
413
- if (existingParseMode !== parseMode) {
414
- try {
415
- await stream.finalize()
416
- } catch (err) {
417
- // Best-effort: the in-flight edit may 429 or race. Surface to
418
- // stderr so the orphaned message id isn't invisible.
419
- deps.writeError(
420
- `telegram channel: stream_reply parseMode-rotation finalize failed: ${err}\n`,
421
- )
422
- }
423
- state.activeDraftStreams.delete(sKey)
424
- state.activeDraftParseModes.delete(sKey)
425
- stream = undefined
426
- }
427
- }
369
+ // #2669: there is now a single rendering mode (rich markdown), so the
370
+ // legacy parseMode-rotation that finalized + recreated a stream whose
371
+ // baked parseMode no longer matched is gone — every stream renders the
372
+ // same way and can be reused as-is.
428
373
 
429
374
  const streamExisted = stream != null
430
375
 
@@ -487,7 +432,7 @@ export async function handleStreamReply(
487
432
  bot: deps.bot,
488
433
  chatId: chat_id,
489
434
  threadId,
490
- parseMode,
435
+ literalText,
491
436
  disableLinkPreview: deps.disableLinkPreview,
492
437
  // Pass undefined when caller didn't override, so draft-stream's
493
438
  // DM/group throttle defaults apply (400 ms DMs, 1000 ms groups).
@@ -531,7 +476,6 @@ export async function handleStreamReply(
531
476
  },
532
477
  })
533
478
  state.activeDraftStreams.set(sKey, stream)
534
- state.activeDraftParseModes?.set(sKey, parseMode)
535
479
  }
536
480
 
537
481
  await stream.update(effectiveText)
@@ -539,7 +483,6 @@ export async function handleStreamReply(
539
483
  if (done) {
540
484
  await stream.finalize()
541
485
  state.activeDraftStreams.delete(sKey)
542
- state.activeDraftParseModes?.delete(sKey)
543
486
  // #1713: stream_reply done=true is a NON-EVENT for the status
544
487
  // reaction. The reaction reflects current turn activity, not
545
488
  // delivery state — only the `turn_end` IPC handler finalizes (👍).
@@ -549,15 +492,15 @@ export async function handleStreamReply(
549
492
  // the #1713 issue body for the rationale.
550
493
 
551
494
  // Hard-fail surface: if the stream finalized without ever assigning
552
- // a message id, the initial send never landed (4096+ chars hits
495
+ // a message id, the initial send never landed (over-cap text hits
553
496
  // draft-stream's length guard and silently stops). Throw so the MCP
554
497
  // caller sees isError:true instead of a misleading "finalized
555
498
  // (id: pending)". The caller can fall back to `reply`, which chunks.
556
499
  if (stream.getMessageId() == null) {
557
500
  throw new Error(
558
501
  `stream_reply finalized without sending any message (length=${rawText.length}, ` +
559
- `max=4096). Telegram's per-message limit is 4096 chars and stream_reply does not ` +
560
- `auto-chunk. Split the text or use \`reply\` (which chunks).`,
502
+ `max=${RICH_MESSAGE_MAX_CHARS}). Telegram's rich-message limit is ${RICH_MESSAGE_MAX_CHARS} chars and ` +
503
+ `stream_reply does not auto-chunk. Split the text or use \`reply\` (which chunks).`,
561
504
  )
562
505
  }
563
506
 
@@ -44,7 +44,7 @@ import { projectSubagentLine, sanitizeCwdToProjectName, detectErrorInTranscriptL
44
44
  import { sanitiseToolArg } from './fleet-state.js'
45
45
  import { clipNarrative, describeToolUse } from './tool-activity-summary.js'
46
46
  import { REPLY_TOOLS, isDraftOfReply } from './narrative-dedup.js'
47
- import { escapeHtml, truncate } from './card-format.js'
47
+ import { truncate } from './card-format.js'
48
48
  import { bumpSubagentActivity, recordSubagentStall, recordSubagentResume, recordSubagentEnd, reapStuckRunningRows, countRunningBackgroundSubagents } from './registry/subagents-schema.js'
49
49
  import { touchTurnActiveMarker } from './gateway/turn-active-marker.js'
50
50
 
@@ -1439,7 +1439,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1439
1439
  if (idleMs >= threshold) {
1440
1440
  entry.stallNotified = true
1441
1441
  entry.stalledAt = n
1442
- const desc = escapeHtml(truncate(entry.description, 80))
1442
+ const desc = truncate(entry.description, 80)
1443
1443
  const idleSec = Math.floor(idleMs / 1000)
1444
1444
  log?.(`subagent-watcher: stall detected for ${entry.agentId} (idle ${idleSec}s): ${desc}`)
1445
1445
  // Bug 3 fix (#333): persist the stall into the registry DB.
@@ -166,13 +166,16 @@ describe('answer-stream — silent-marker suppression at materialize()', () => {
166
166
  stream.update(prose)
167
167
  const msgId = await stream.materialize()
168
168
 
169
- // materialize() sends a fresh message — prose is not a silent marker
169
+ // materialize() sends a fresh message — prose is not a silent marker.
170
+ // Post-#2669 the body is the RAW transcript markdown and there is no
171
+ // parse_mode (the gateway wrapper ships it via the rich-message path).
170
172
  expect(sendMessage).toHaveBeenCalledTimes(1)
171
173
  expect(sendMessage).toHaveBeenCalledWith(
172
174
  'chat45',
173
175
  prose,
174
- expect.objectContaining({ parse_mode: 'HTML' }),
176
+ expect.objectContaining({ link_preview_options: { is_disabled: true } }),
175
177
  )
178
+ expect(sendMessage.mock.calls[0][2]).not.toHaveProperty('parse_mode')
176
179
  expect(typeof msgId).toBe('number')
177
180
  })
178
181