switchroom 0.16.23 → 0.16.27

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 (138) 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 +804 -465
  9. package/dist/host-control/main.js +169 -163
  10. package/dist/vault/approvals/kernel-server.js +82 -82
  11. package/dist/vault/broker/server.js +83 -83
  12. package/package.json +4 -4
  13. package/telegram-plugin/answer-stream.ts +20 -49
  14. package/telegram-plugin/auth-snapshot-format.ts +27 -30
  15. package/telegram-plugin/auto-fallback-fleet.ts +6 -11
  16. package/telegram-plugin/bridge/bridge.ts +1 -1
  17. package/telegram-plugin/card-format.ts +28 -25
  18. package/telegram-plugin/credits-watch.ts +5 -10
  19. package/telegram-plugin/dist/bridge/bridge.js +113 -113
  20. package/telegram-plugin/dist/gateway/gateway.js +2085 -2102
  21. package/telegram-plugin/dist/server.js +161 -161
  22. package/telegram-plugin/draft-stream.ts +4 -4
  23. package/telegram-plugin/format.ts +427 -680
  24. package/telegram-plugin/gateway/approval-callback.ts +2 -3
  25. package/telegram-plugin/gateway/approval-card.test.ts +17 -4
  26. package/telegram-plugin/gateway/approval-card.ts +16 -6
  27. package/telegram-plugin/gateway/approvals-commands.ts +18 -24
  28. package/telegram-plugin/gateway/auth-command.ts +74 -74
  29. package/telegram-plugin/gateway/auth-line.ts +5 -15
  30. package/telegram-plugin/gateway/boot-card.ts +20 -22
  31. package/telegram-plugin/gateway/boot-version.ts +3 -2
  32. package/telegram-plugin/gateway/config-approval-handler.test.ts +35 -33
  33. package/telegram-plugin/gateway/config-approval-handler.ts +24 -24
  34. package/telegram-plugin/gateway/config-snapshot.ts +9 -9
  35. package/telegram-plugin/gateway/diff-preview-card.test.ts +8 -8
  36. package/telegram-plugin/gateway/diff-preview-card.ts +2 -5
  37. package/telegram-plugin/gateway/disconnect-flush.ts +0 -4
  38. package/telegram-plugin/gateway/drive-write-approval.test.ts +10 -10
  39. package/telegram-plugin/gateway/drive-write-approval.ts +14 -8
  40. package/telegram-plugin/gateway/effort-command.ts +17 -17
  41. package/telegram-plugin/gateway/folder-picker-handler.test.ts +8 -2
  42. package/telegram-plugin/gateway/folder-picker-handler.ts +3 -4
  43. package/telegram-plugin/gateway/gateway.ts +881 -633
  44. package/telegram-plugin/gateway/inject-handler.test.ts +15 -13
  45. package/telegram-plugin/gateway/inject-handler.ts +5 -5
  46. package/telegram-plugin/gateway/ipc-protocol.ts +33 -1
  47. package/telegram-plugin/gateway/ipc-server.ts +39 -6
  48. package/telegram-plugin/gateway/linear-activity.ts +16 -14
  49. package/telegram-plugin/gateway/linear-setup.ts +1 -1
  50. package/telegram-plugin/gateway/model-command.ts +25 -25
  51. package/telegram-plugin/gateway/oversize-card-body.ts +6 -7
  52. package/telegram-plugin/gateway/permission-timeout.ts +76 -0
  53. package/telegram-plugin/gateway/skill-proposal-card.ts +167 -0
  54. package/telegram-plugin/inline-keyboard-callbacks.ts +19 -13
  55. package/telegram-plugin/issues-card.ts +6 -7
  56. package/telegram-plugin/model-unavailable.ts +8 -12
  57. package/telegram-plugin/operator-events-history.ts +1 -1
  58. package/telegram-plugin/operator-events.ts +24 -28
  59. package/telegram-plugin/package.json +1 -1
  60. package/telegram-plugin/pending-work-progress.ts +36 -36
  61. package/telegram-plugin/permission-title.ts +39 -20
  62. package/telegram-plugin/pty-partial-handler.ts +5 -13
  63. package/telegram-plugin/quota-check.ts +5 -5
  64. package/telegram-plugin/quota-watch.ts +13 -18
  65. package/telegram-plugin/recent-outbound-dedup.ts +5 -5
  66. package/telegram-plugin/registry/turns-schema.ts +43 -3
  67. package/telegram-plugin/retry-api-call.ts +42 -7
  68. package/telegram-plugin/rich-send.ts +86 -0
  69. package/telegram-plugin/secret-detect/vault-error.test.ts +6 -6
  70. package/telegram-plugin/secret-detect/vault-error.ts +29 -22
  71. package/telegram-plugin/shared/bot-runtime.ts +29 -7
  72. package/telegram-plugin/silence-poke.ts +26 -69
  73. package/telegram-plugin/silent-reply-anchor.ts +9 -2
  74. package/telegram-plugin/slot-banner-driver.ts +9 -6
  75. package/telegram-plugin/slot-banner.ts +5 -8
  76. package/telegram-plugin/status-no-truncate.ts +11 -5
  77. package/telegram-plugin/steering.ts +0 -4
  78. package/telegram-plugin/stream-controller.ts +59 -62
  79. package/telegram-plugin/stream-reply-handler.ts +49 -98
  80. package/telegram-plugin/subagent-watcher.ts +2 -2
  81. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +5 -2
  82. package/telegram-plugin/tests/answer-stream.test.ts +54 -63
  83. package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
  84. package/telegram-plugin/tests/auth-command-vernacular.test.ts +3 -2
  85. package/telegram-plugin/tests/auth-snapshot-format.test.ts +19 -18
  86. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +15 -13
  87. package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +27 -12
  88. package/telegram-plugin/tests/boot-card-render.test.ts +59 -48
  89. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  90. package/telegram-plugin/tests/bot-api.harness.ts +23 -1
  91. package/telegram-plugin/tests/bot-runtime.test.ts +23 -18
  92. package/telegram-plugin/tests/card-format.test.ts +6 -4
  93. package/telegram-plugin/tests/config-snapshot.test.ts +1 -1
  94. package/telegram-plugin/tests/credits-watch.test.ts +5 -5
  95. package/telegram-plugin/tests/fake-bot-api.ts +58 -4
  96. package/telegram-plugin/tests/finalize-callback.test.ts +11 -9
  97. package/telegram-plugin/tests/foreground-nesting.test.ts +1 -1
  98. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +3 -13
  99. package/telegram-plugin/tests/ipc-server-validate-send-outbound.test.ts +6 -2
  100. package/telegram-plugin/tests/issues-card.test.ts +15 -12
  101. package/telegram-plugin/tests/length-error-classify.test.ts +131 -0
  102. package/telegram-plugin/tests/linear-agent-activity.test.ts +8 -5
  103. package/telegram-plugin/tests/model-command.test.ts +2 -2
  104. package/telegram-plugin/tests/model-unavailable.test.ts +13 -13
  105. package/telegram-plugin/tests/multi-turn-continuity.test.ts +6 -10
  106. package/telegram-plugin/tests/operator-events.test.ts +7 -9
  107. package/telegram-plugin/tests/paragraph-normalizer.test.ts +273 -0
  108. package/telegram-plugin/tests/pending-work-progress.test.ts +20 -21
  109. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +12 -2
  110. package/telegram-plugin/tests/permission-timeout.test.ts +77 -0
  111. package/telegram-plugin/tests/permission-title.test.ts +88 -41
  112. package/telegram-plugin/tests/pty-partial-handler.test.ts +8 -8
  113. package/telegram-plugin/tests/quota-check.test.ts +3 -3
  114. package/telegram-plugin/tests/quota-watch.test.ts +8 -4
  115. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +4 -3
  116. package/telegram-plugin/tests/silence-poke.test.ts +75 -112
  117. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +137 -0
  118. package/telegram-plugin/tests/skill-proposal-card.test.ts +103 -0
  119. package/telegram-plugin/tests/slot-banner-driver.e2e.test.ts +36 -24
  120. package/telegram-plugin/tests/slot-banner.test.ts +9 -6
  121. package/telegram-plugin/tests/status-accent.test.ts +29 -32
  122. package/telegram-plugin/tests/{stream-controller-html-fallback.test.ts → stream-controller-parse-fallback.test.ts} +40 -42
  123. package/telegram-plugin/tests/stream-controller.test.ts +63 -52
  124. package/telegram-plugin/tests/stream-reply-error-paths.test.ts +43 -38
  125. package/telegram-plugin/tests/stream-reply-handler.test.ts +122 -249
  126. package/telegram-plugin/tests/streaming-e2e.test.ts +35 -30
  127. package/telegram-plugin/tests/streaming-orchestration.test.ts +29 -28
  128. package/telegram-plugin/tests/telegram-format.test.ts +120 -1083
  129. package/telegram-plugin/tests/tool-activity-summary.test.ts +144 -145
  130. package/telegram-plugin/tests/welcome-text.test.ts +72 -65
  131. package/telegram-plugin/tests/worker-activity-feed.test.ts +119 -137
  132. package/telegram-plugin/text-voice-scrub.ts +8 -11
  133. package/telegram-plugin/tool-activity-summary.ts +29 -29
  134. package/telegram-plugin/welcome-text.ts +82 -83
  135. package/telegram-plugin/worker-activity-feed.ts +2 -3
  136. package/telegram-plugin/html-sanitize.ts +0 -244
  137. package/telegram-plugin/tests/html-sanitize.test.ts +0 -146
  138. package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -162
@@ -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,12 +150,14 @@ 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
155
+ /**
156
+ * Promote lone prose paragraph breaks into GFM hard breaks so the rich
157
+ * path doesn't collapse them. Optional for backward compat; when omitted,
158
+ * the raw (repaired) text is sent unchanged.
159
+ */
160
+ normalizeParagraphBreaks?: (text: string) => string
174
161
  /** Validates the chat id against the access list. Throws on deny. */
175
162
  assertAllowedChat: (chatId: string) => void
176
163
  /** Resolves the effective thread id (explicit, last-inbound, or undefined). */
@@ -185,7 +172,8 @@ export interface StreamReplyDeps {
185
172
  getLatestInboundMessageId?: (chatId: string, threadId: number | null) => number | null
186
173
  /** Config: disable link previews. Default true. */
187
174
  disableLinkPreview: boolean
188
- /** Config: fallback parse mode when args.format is omitted ('html' | 'markdownv2' | 'text'). */
175
+ /** Config: fallback format when args.format is omitted. Anything other
176
+ * than the literal `'text'` is treated as the rich-markdown path (#2669). */
189
177
  defaultFormat: string
190
178
  /** Observability: per-call event. */
191
179
  logStreamingEvent: (ev: {
@@ -320,7 +308,9 @@ export async function handleStreamReply(
320
308
  deps: StreamReplyDeps,
321
309
  ): Promise<StreamReplyResult> {
322
310
  const chat_id = args.chat_id
323
- const rawText = deps.repairEscapedWhitespace(args.text)
311
+ const rawText = deps.normalizeParagraphBreaks
312
+ ? deps.normalizeParagraphBreaks(deps.repairEscapedWhitespace(args.text))
313
+ : deps.repairEscapedWhitespace(args.text)
324
314
  const done = Boolean(args.done)
325
315
  const format = args.format ?? deps.defaultFormat
326
316
  if (done) {
@@ -341,53 +331,36 @@ export async function handleStreamReply(
341
331
  // the progressive-streaming contract documented in
342
332
  // profiles/default/CLAUDE.md. See #481.
343
333
 
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
- }
334
+ // Single rich-markdown path (#2669). The only fork is the literal
335
+ // `format:'text'` send (plain string, no markdown parsing); everything
336
+ // else ships the raw GFM markdown via the rich-message path. No
337
+ // markdown→HTML / MarkdownV2 rendering happens here anymore — the raw
338
+ // text IS the wire payload.
339
+ const literalText = format === 'text'
340
+ let effectiveText: string = rawText
360
341
 
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).
342
+ // Inline status-accent header (issue #320 fallback). Prepended so it
343
+ // leads the body. Since stream_reply callers pass the full text snapshot
344
+ // each call, the header is prepended on every call that supplies
345
+ // `accent` — keeping the rendered message consistent across edits.
346
+ // Callers that don't want the header on a subsequent edit simply omit
347
+ // `accent`. Unrecognised values are silently ignored (empty string).
368
348
  if (args.accent != null) {
369
349
  const accentHeader = buildAccentHeader(args.accent)
370
350
  if (accentHeader.length > 0) {
371
351
  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
352
  }
378
353
  }
379
354
 
380
355
  // 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
356
+ // (a) a first call over the cap fails cleanly instead of creating a
357
+ // half-initialized stream, and (b) a mid-stream update over the cap
383
358
  // fails loudly instead of setting the internal `stopped=true` flag
384
359
  // and silently dropping all subsequent text. Either way the caller
385
360
  // 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) {
361
+ if (effectiveText.length > RICH_MESSAGE_MAX_CHARS) {
389
362
  throw new Error(
390
- `stream_reply rejected: text exceeds Telegram's 4096-char limit ` +
363
+ `stream_reply rejected: text exceeds Telegram's ${RICH_MESSAGE_MAX_CHARS}-char rich-message limit ` +
391
364
  `(length=${effectiveText.length}, format=${format}). stream_reply does not ` +
392
365
  `auto-chunk — split the text or use \`reply\`, which chunks.`,
393
366
  )
@@ -401,30 +374,10 @@ export async function handleStreamReply(
401
374
  // targets. Cleared on turn_end by server.ts.
402
375
  state.suppressPtyPreview?.add(streamKey(chat_id, threadId))
403
376
  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
- }
377
+ // #2669: there is now a single rendering mode (rich markdown), so the
378
+ // legacy parseMode-rotation that finalized + recreated a stream whose
379
+ // baked parseMode no longer matched is gone — every stream renders the
380
+ // same way and can be reused as-is.
428
381
 
429
382
  const streamExisted = stream != null
430
383
 
@@ -487,7 +440,7 @@ export async function handleStreamReply(
487
440
  bot: deps.bot,
488
441
  chatId: chat_id,
489
442
  threadId,
490
- parseMode,
443
+ literalText,
491
444
  disableLinkPreview: deps.disableLinkPreview,
492
445
  // Pass undefined when caller didn't override, so draft-stream's
493
446
  // DM/group throttle defaults apply (400 ms DMs, 1000 ms groups).
@@ -531,7 +484,6 @@ export async function handleStreamReply(
531
484
  },
532
485
  })
533
486
  state.activeDraftStreams.set(sKey, stream)
534
- state.activeDraftParseModes?.set(sKey, parseMode)
535
487
  }
536
488
 
537
489
  await stream.update(effectiveText)
@@ -539,7 +491,6 @@ export async function handleStreamReply(
539
491
  if (done) {
540
492
  await stream.finalize()
541
493
  state.activeDraftStreams.delete(sKey)
542
- state.activeDraftParseModes?.delete(sKey)
543
494
  // #1713: stream_reply done=true is a NON-EVENT for the status
544
495
  // reaction. The reaction reflects current turn activity, not
545
496
  // delivery state — only the `turn_end` IPC handler finalizes (👍).
@@ -549,15 +500,15 @@ export async function handleStreamReply(
549
500
  // the #1713 issue body for the rationale.
550
501
 
551
502
  // Hard-fail surface: if the stream finalized without ever assigning
552
- // a message id, the initial send never landed (4096+ chars hits
503
+ // a message id, the initial send never landed (over-cap text hits
553
504
  // draft-stream's length guard and silently stops). Throw so the MCP
554
505
  // caller sees isError:true instead of a misleading "finalized
555
506
  // (id: pending)". The caller can fall back to `reply`, which chunks.
556
507
  if (stream.getMessageId() == null) {
557
508
  throw new Error(
558
509
  `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).`,
510
+ `max=${RICH_MESSAGE_MAX_CHARS}). Telegram's rich-message limit is ${RICH_MESSAGE_MAX_CHARS} chars and ` +
511
+ `stream_reply does not auto-chunk. Split the text or use \`reply\` (which chunks).`,
561
512
  )
562
513
  }
563
514
 
@@ -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