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
@@ -1,9 +1,12 @@
1
1
  /**
2
2
  * Integration tests for the `stream_reply` MCP tool handler.
3
3
  *
4
- * Exercises the extracted `handleStreamReply` against the mock bot harness
5
- * with realistic deps (format rendering, access check, thread resolution,
6
- * handoff prefix, history record).
4
+ * Post-#2669: there is one rendering path. The handler passes the raw GFM
5
+ * markdown straight to the stream controller, which sends it via
6
+ * `bot.api.sendRichMessage(chat, { markdown }, opts)` and edits via
7
+ * `bot.api.editMessageText(chat, id, { markdown }, opts)`. The only fork is
8
+ * `format:'text'` → the literal plain `sendMessage` path. There is no
9
+ * markdownToHtml / escapeMarkdownV2 dep and no activeDraftParseModes state.
7
10
  */
8
11
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
9
12
  import {
@@ -12,7 +15,7 @@ import {
12
15
  type StreamReplyState,
13
16
  } from '../stream-reply-handler.js'
14
17
  import type { DraftStreamHandle } from '../draft-stream.js'
15
- import { markdownToHtml as realMarkdownToHtml } from '../format.js'
18
+ import { RICH_MESSAGE_MAX_CHARS } from '../format.js'
16
19
  import { createMockBot, installBotResetHook, microtaskFlush } from './bot-api.harness.js'
17
20
  import {
18
21
  handlePtyPartialPure,
@@ -22,7 +25,6 @@ import {
22
25
  function makeState(): StreamReplyState {
23
26
  return {
24
27
  activeDraftStreams: new Map<string, DraftStreamHandle>(),
25
- activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
26
28
  }
27
29
  }
28
30
 
@@ -32,13 +34,12 @@ function makeDeps(
32
34
  ): StreamReplyDeps {
33
35
  return {
34
36
  bot,
35
- markdownToHtml: (t) => `<b>${t}</b>`,
36
- escapeMarkdownV2: (t) => `\\${t}\\`,
37
37
  repairEscapedWhitespace: (t) => t,
38
38
  assertAllowedChat: () => {},
39
39
  resolveThreadId: (_, explicit) => (explicit != null ? Number(explicit) : undefined),
40
40
  disableLinkPreview: true,
41
- defaultFormat: 'html',
41
+ // Anything other than the literal 'text' is the rich-markdown path.
42
+ defaultFormat: 'markdown',
42
43
  logStreamingEvent: () => {},
43
44
  historyEnabled: false,
44
45
  recordOutbound: () => {},
@@ -48,6 +49,15 @@ function makeDeps(
48
49
  }
49
50
  }
50
51
 
52
+ /** The raw markdown body of the i-th rich send. */
53
+ function richSendMarkdown(bot: ReturnType<typeof createMockBot>, i = 0): string {
54
+ return (bot.api.sendRichMessage.mock.calls[i][1] as { markdown: string }).markdown
55
+ }
56
+ /** The raw markdown body of the i-th rich edit. */
57
+ function richEditMarkdown(bot: ReturnType<typeof createMockBot>, i = 0): string {
58
+ return (bot.api.editMessageText.mock.calls[i][2] as { markdown: string }).markdown
59
+ }
60
+
51
61
  describe('handleStreamReply', () => {
52
62
  const bot = createMockBot()
53
63
  installBotResetHook(bot)
@@ -55,7 +65,7 @@ describe('handleStreamReply', () => {
55
65
  beforeEach(() => vi.useFakeTimers())
56
66
  afterEach(() => vi.useRealTimers())
57
67
 
58
- it('first call creates stream + sends with rendered HTML text', async () => {
68
+ it('first call creates stream + sends raw markdown via sendRichMessage', async () => {
59
69
  const state = makeState()
60
70
  const deps = makeDeps(bot)
61
71
 
@@ -65,102 +75,81 @@ describe('handleStreamReply', () => {
65
75
 
66
76
  expect(result.status).toBe('updated')
67
77
  expect(result.messageId).toBe(500)
68
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
69
- expect(bot.api.sendMessage.mock.calls[0][1]).toBe('<b>hi</b>')
70
- expect(bot.api.sendMessage.mock.calls[0][2]?.parse_mode).toBe('HTML')
78
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
79
+ expect(bot.api.sendMessage).not.toHaveBeenCalled()
80
+ expect(richSendMarkdown(bot)).toBe('hi')
71
81
  expect(state.activeDraftStreams.size).toBe(1)
72
82
  })
73
83
 
74
- it('respects format=markdownv2 uses MDv2 escaper and parse_mode', async () => {
84
+ it('a non-text format ships raw GFM markdown unescaped (no parse_mode)', async () => {
75
85
  const state = makeState()
76
86
  const deps = makeDeps(bot)
77
87
 
78
88
  const pending = handleStreamReply(
79
- { chat_id: '1', text: 'hi', format: 'markdownv2' },
89
+ { chat_id: '1', text: '**hi** _there_', format: 'gfm' },
80
90
  state,
81
91
  deps,
82
92
  )
83
93
  await microtaskFlush()
84
94
  await pending
85
95
 
86
- expect(bot.api.sendMessage.mock.calls[0][1]).toBe('\\hi\\')
87
- expect(bot.api.sendMessage.mock.calls[0][2]?.parse_mode).toBe('MarkdownV2')
96
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
97
+ expect(richSendMarkdown(bot)).toBe('**hi** _there_')
98
+ // No parse_mode on rich opts.
99
+ expect(bot.api.sendRichMessage.mock.calls[0][2]?.parse_mode).toBeUndefined()
88
100
  })
89
101
 
90
- it('respects format=text — no parse_mode, raw text', async () => {
102
+ it('respects format=text — literal plain sendMessage, raw text', async () => {
91
103
  const state = makeState()
92
104
  const deps = makeDeps(bot)
93
105
 
94
106
  const pending = handleStreamReply(
95
- { chat_id: '1', text: 'plain', format: 'text' },
107
+ { chat_id: '1', text: 'plain < text >', format: 'text' },
96
108
  state,
97
109
  deps,
98
110
  )
99
111
  await microtaskFlush()
100
112
  await pending
101
113
 
102
- expect(bot.api.sendMessage.mock.calls[0][1]).toBe('plain')
114
+ expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
115
+ expect(bot.api.sendRichMessage).not.toHaveBeenCalled()
116
+ expect(bot.api.sendMessage.mock.calls[0][1]).toBe('plain < text >')
103
117
  expect(bot.api.sendMessage.mock.calls[0][2]?.parse_mode).toBeUndefined()
104
118
  })
105
119
 
106
- it('throws when text exceeds 4096 (no silent id:pending)', async () => {
107
- // Pins the bug found in prod: a >4096-char text would hit draft-
108
- // stream's length guard, silently stop, and the handler would return
109
- // status:finalized, messageId:null — the MCP response read
110
- // "finalized (id: pending)" looking like success. Fixed upstream by
111
- // an over-limit pre-check that throws BEFORE touching stream state,
112
- // so both first-send-over-limit AND mid-stream-over-limit fail loudly
113
- // instead of corrupting the stream. done=true not required.
120
+ it('throws when text exceeds the rich-message cap (no silent id:pending)', async () => {
114
121
  const state = makeState()
115
122
  const deps = makeDeps(bot)
116
- const tooLong = 'x'.repeat(5000)
123
+ const tooLong = 'x'.repeat(RICH_MESSAGE_MAX_CHARS + 1)
117
124
 
118
125
  await expect(
119
126
  handleStreamReply({ chat_id: '1', text: tooLong, done: true }, state, deps),
120
- ).rejects.toThrow(/exceeds Telegram's 4096-char limit/)
127
+ ).rejects.toThrow(new RegExp(`exceeds Telegram's ${RICH_MESSAGE_MAX_CHARS}-char`))
121
128
 
122
- // Mock bot should NOT have received any sendMessage call.
129
+ expect(bot.api.sendRichMessage).not.toHaveBeenCalled()
123
130
  expect(bot.api.sendMessage).not.toHaveBeenCalled()
124
131
  })
125
132
 
126
133
  it('mid-stream over-limit throws without corrupting stream state', async () => {
127
- // A stream that starts small but a later update() goes over 4096.
128
- // Before the upfront length check, the draft-stream would set its
129
- // internal stopped=true flag and silently drop all further text —
130
- // including the done=true final answer. The pre-check now throws
131
- // on the over-limit call, leaving the stream intact so the caller
132
- // can fall back to `reply`. The previously-sent short text stays
133
- // visible in Telegram; the throw is the signal to the caller.
134
134
  const state = makeState()
135
135
  const deps = makeDeps(bot)
136
136
 
137
- await handleStreamReply(
138
- { chat_id: '1', text: 'short' },
139
- state,
140
- deps,
141
- )
137
+ await handleStreamReply({ chat_id: '1', text: 'short' }, state, deps)
142
138
  await microtaskFlush()
143
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
139
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
144
140
 
145
- // Second call: now over limit.
146
141
  await expect(
147
142
  handleStreamReply(
148
- { chat_id: '1', text: 'y'.repeat(5000), done: true },
143
+ { chat_id: '1', text: 'y'.repeat(RICH_MESSAGE_MAX_CHARS + 1), done: true },
149
144
  state,
150
145
  deps,
151
146
  ),
152
- ).rejects.toThrow(/exceeds Telegram's 4096-char limit/)
147
+ ).rejects.toThrow(new RegExp(`exceeds Telegram's ${RICH_MESSAGE_MAX_CHARS}-char`))
153
148
 
154
- // No additional API calls from the rejected update.
155
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
149
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
156
150
  })
157
151
 
158
152
  it('done=true finalizes the stream but does NOT touch the reaction (#1713)', async () => {
159
- // #1713: stream_reply done=true is a NON-EVENT for the status
160
- // reaction. Stream completion is "I'm done speaking", not "turn
161
- // over"; the model may continue with post-stream tool work. Only
162
- // the gateway's turn_end IPC handler finalizes the reaction.
163
- // This is a deliberate revert of the Bug Z fix (PR #602 follow-up).
164
153
  const state = makeState()
165
154
  const deps = makeDeps(bot)
166
155
 
@@ -177,10 +166,6 @@ describe('handleStreamReply', () => {
177
166
  })
178
167
 
179
168
  it('done=true on a named lane does NOT fire terminal 👍', async () => {
180
- // Named lanes (lane:'progress', lane:'thinking', lane:'activity'
181
- // etc.) are internal driver emits, not user-visible answers. They
182
- // must not be allowed to claim turn-completion: a progress-lane
183
- // emit firing setDone would race the actual answer message.
184
169
  const state = makeState()
185
170
  const deps = makeDeps(bot)
186
171
 
@@ -194,20 +179,16 @@ describe('handleStreamReply', () => {
194
179
  })
195
180
 
196
181
  it('done=true does NOT fire 👍 if finalize never produced a messageId', async () => {
197
- // The over-limit branch throws before getMessageId() is non-null.
198
- // Even if it didn't throw, a null messageId means the initial send
199
- // never landed, so 👍 must not fire. Pinning that the gating on
200
- // `getMessageId() != null` holds.
201
182
  const state = makeState()
202
183
  const deps = makeDeps(bot)
203
184
 
204
185
  await expect(
205
186
  handleStreamReply(
206
- { chat_id: '1', text: 'x'.repeat(5000), done: true },
187
+ { chat_id: '1', text: 'x'.repeat(RICH_MESSAGE_MAX_CHARS + 1), done: true },
207
188
  state,
208
189
  deps,
209
190
  ),
210
- ).rejects.toThrow(/exceeds Telegram's 4096-char limit/)
191
+ ).rejects.toThrow(new RegExp(`exceeds Telegram's ${RICH_MESSAGE_MAX_CHARS}-char`))
211
192
  })
212
193
 
213
194
  it('done=true with historyEnabled records the final message row', async () => {
@@ -231,7 +212,7 @@ describe('handleStreamReply', () => {
231
212
  chat_id: '1',
232
213
  thread_id: 42,
233
214
  message_ids: [500],
234
- texts: ['final text'], // raw text, NOT HTML-rendered
215
+ texts: ['final text'], // raw text
235
216
  })
236
217
  })
237
218
 
@@ -282,10 +263,11 @@ describe('handleStreamReply', () => {
282
263
  handleStreamReply({ chat_id: 'evil', text: 'x' }, state, deps),
283
264
  ).rejects.toThrow('chat not allowed')
284
265
 
266
+ expect(bot.api.sendRichMessage).not.toHaveBeenCalled()
285
267
  expect(bot.api.sendMessage).not.toHaveBeenCalled()
286
268
  })
287
269
 
288
- it('subsequent calls reuse the same stream + edit in place', async () => {
270
+ it('subsequent calls reuse the same stream + rich-edit in place', async () => {
289
271
  const state = makeState()
290
272
  const deps = makeDeps(bot)
291
273
 
@@ -298,13 +280,13 @@ describe('handleStreamReply', () => {
298
280
  await microtaskFlush()
299
281
  await p2
300
282
 
301
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
283
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
302
284
  expect(bot.api.editMessageText).toHaveBeenCalledTimes(1)
303
285
  expect(bot.api.editMessageText.mock.calls[0][1]).toBe(500) // same id
304
- expect(bot.api.editMessageText.mock.calls[0][2]).toBe('<b>step 2</b>')
286
+ expect(richEditMarkdown(bot)).toBe('step 2')
305
287
  })
306
288
 
307
- it('passes repairEscapedWhitespace through before rendering', async () => {
289
+ it('passes repairEscapedWhitespace through before sending', async () => {
308
290
  const state = makeState()
309
291
  const deps = makeDeps(bot, {
310
292
  repairEscapedWhitespace: (t) => t.replace(/\\n/g, '\n'),
@@ -314,8 +296,8 @@ describe('handleStreamReply', () => {
314
296
  await microtaskFlush()
315
297
  await pending
316
298
 
317
- // repair happens first; then markdownToHtml wraps the repaired text
318
- expect(bot.api.sendMessage.mock.calls[0][1]).toBe('<b>a\nb</b>')
299
+ // repair happens first; the repaired raw markdown is the wire payload.
300
+ expect(richSendMarkdown(bot)).toBe('a\nb')
319
301
  })
320
302
 
321
303
  it('different lanes for same chat produce independent Telegram messages', async () => {
@@ -338,7 +320,7 @@ describe('handleStreamReply', () => {
338
320
  await microtaskFlush()
339
321
  const r2 = await p2
340
322
 
341
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(2)
323
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(2)
342
324
  expect(r1.messageId).not.toBe(r2.messageId) // separate messages
343
325
  expect(state.activeDraftStreams.size).toBe(2)
344
326
  expect(state.activeDraftStreams.has('1:_')).toBe(true)
@@ -366,9 +348,9 @@ describe('handleStreamReply', () => {
366
348
  await microtaskFlush()
367
349
  await p2
368
350
 
369
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
351
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
370
352
  expect(bot.api.editMessageText).toHaveBeenCalledTimes(1)
371
- expect(bot.api.editMessageText.mock.calls[0][2]).toBe('<b>step 1 — step 2</b>')
353
+ expect(richEditMarkdown(bot)).toBe('step 1 — step 2')
372
354
  })
373
355
 
374
356
  it('done=true on one lane does not affect other lanes', async () => {
@@ -396,14 +378,6 @@ describe('handleStreamReply', () => {
396
378
  })
397
379
 
398
380
  // ─── Regression: concurrent turns on the same chat+thread+lane ───────
399
- // Before the fix, two simultaneously active turns emitting on
400
- // lane:'progress' (the progress-card driver's lane) computed the same
401
- // streamKey and collapsed into one draft stream. Telegram saw a single
402
- // message flapping between the two turns' narratives instead of two
403
- // separate pinned cards. The fix threads a per-turn `turnKey` through
404
- // `StreamReplyArgs` → `streamKey()` so each active turn gets its own
405
- // slot in `activeDraftStreams` (and therefore its own Telegram message
406
- // and its own pin via `progressPinnedMsgIds`).
407
381
  it('concurrent turns with different turnKeys produce separate draft streams and messages', async () => {
408
382
  const state = makeState()
409
383
  const deps = makeDeps(bot)
@@ -427,7 +401,7 @@ describe('handleStreamReply', () => {
427
401
  const rB = await pB
428
402
 
429
403
  // Two independent Telegram messages (not one edited twice).
430
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(2)
404
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(2)
431
405
  expect(bot.api.editMessageText).not.toHaveBeenCalled()
432
406
  expect(rA.messageId).not.toBe(rB.messageId)
433
407
 
@@ -436,9 +410,9 @@ describe('handleStreamReply', () => {
436
410
  expect(state.activeDraftStreams.has('1:_:progress:1:_:1')).toBe(true)
437
411
  expect(state.activeDraftStreams.has('1:_:progress:1:_:2')).toBe(true)
438
412
 
439
- // Each message carried its own turn's text.
440
- expect(bot.api.sendMessage.mock.calls[0][1]).toBe('<b>turn A step 1</b>')
441
- expect(bot.api.sendMessage.mock.calls[1][1]).toBe('<b>turn B step 1</b>')
413
+ // Each message carried its own turn's raw markdown.
414
+ expect(richSendMarkdown(bot, 0)).toBe('turn A step 1')
415
+ expect(richSendMarkdown(bot, 1)).toBe('turn B step 1')
442
416
  })
443
417
 
444
418
  it('subsequent updates with same turnKey reuse the stream (edit in place)', async () => {
@@ -463,11 +437,10 @@ describe('handleStreamReply', () => {
463
437
  await microtaskFlush()
464
438
  await p2
465
439
 
466
- // One send (first call) + one edit (second call) on the same message.
467
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
440
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
468
441
  expect(bot.api.editMessageText).toHaveBeenCalledTimes(1)
469
442
  expect(bot.api.editMessageText.mock.calls[0][1]).toBe(500)
470
- expect(bot.api.editMessageText.mock.calls[0][2]).toBe('<b>A first + second</b>')
443
+ expect(richEditMarkdown(bot)).toBe('A first + second')
471
444
  expect(state.activeDraftStreams.size).toBe(1)
472
445
  expect(state.activeDraftStreams.has('1:_:progress:1:_:1')).toBe(true)
473
446
  })
@@ -476,7 +449,6 @@ describe('handleStreamReply', () => {
476
449
  const state = makeState()
477
450
  const deps = makeDeps(bot)
478
451
 
479
- // Turn A opens
480
452
  const pa1 = handleStreamReply(
481
453
  { chat_id: '1', text: 'A step 1', lane: 'progress', turnKey: '1:_:1' },
482
454
  state, deps,
@@ -484,7 +456,6 @@ describe('handleStreamReply', () => {
484
456
  await microtaskFlush()
485
457
  await pa1
486
458
 
487
- // Turn B opens
488
459
  const pb1 = handleStreamReply(
489
460
  { chat_id: '1', text: 'B step 1', lane: 'progress', turnKey: '1:_:2' },
490
461
  state, deps,
@@ -494,7 +465,6 @@ describe('handleStreamReply', () => {
494
465
 
495
466
  vi.advanceTimersByTime(1000)
496
467
 
497
- // Turn A updates
498
468
  const pa2 = handleStreamReply(
499
469
  { chat_id: '1', text: 'A step 1 + 2', lane: 'progress', turnKey: '1:_:1' },
500
470
  state, deps,
@@ -504,7 +474,6 @@ describe('handleStreamReply', () => {
504
474
 
505
475
  vi.advanceTimersByTime(1000)
506
476
 
507
- // Turn B updates
508
477
  const pb2 = handleStreamReply(
509
478
  { chat_id: '1', text: 'B step 1 + 2', lane: 'progress', turnKey: '1:_:2' },
510
479
  state, deps,
@@ -512,19 +481,17 @@ describe('handleStreamReply', () => {
512
481
  await microtaskFlush()
513
482
  await pb2
514
483
 
515
- // Two sends (one per turn), two edits (one per turn's update).
516
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(2)
484
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(2)
517
485
  expect(bot.api.editMessageText).toHaveBeenCalledTimes(2)
518
486
 
519
- // The edits must target distinct message ids — one per turn's
520
- // original message — not both collapse onto the same id.
521
487
  const editTargets = bot.api.editMessageText.mock.calls.map((c) => c[1])
522
488
  expect(new Set(editTargets).size).toBe(2)
523
489
 
524
- // And each edit carries its own turn's text — no cross-contamination.
525
- const editTexts = bot.api.editMessageText.mock.calls.map((c) => c[2])
526
- expect(editTexts).toContain('<b>A step 1 + 2</b>')
527
- expect(editTexts).toContain('<b>B step 1 + 2</b>')
490
+ const editTexts = bot.api.editMessageText.mock.calls.map(
491
+ (c) => (c[2] as { markdown: string }).markdown,
492
+ )
493
+ expect(editTexts).toContain('A step 1 + 2')
494
+ expect(editTexts).toContain('B step 1 + 2')
528
495
  })
529
496
 
530
497
  it('done=true on one turnKey does not close the other concurrent turn', async () => {
@@ -547,11 +514,8 @@ describe('handleStreamReply', () => {
547
514
 
548
515
  expect(state.activeDraftStreams.size).toBe(2)
549
516
 
550
- // Advance past the throttle window so the finalize edit can flush
551
- // instead of sitting on the debounce timer (fake timers).
552
517
  vi.advanceTimersByTime(1000)
553
518
 
554
- // Finalize turn A
555
519
  const pAFinal = handleStreamReply(
556
520
  { chat_id: '1', text: 'A final', lane: 'progress', turnKey: '1:_:1', done: true },
557
521
  state, deps,
@@ -559,18 +523,12 @@ describe('handleStreamReply', () => {
559
523
  await microtaskFlush()
560
524
  await pAFinal
561
525
 
562
- // Turn A's slot is gone; turn B's is still live.
563
526
  expect(state.activeDraftStreams.has('1:_:progress:1:_:1')).toBe(false)
564
527
  expect(state.activeDraftStreams.has('1:_:progress:1:_:2')).toBe(true)
565
528
  expect(state.activeDraftStreams.size).toBe(1)
566
529
  })
567
530
 
568
531
  it('turnKey omitted falls back to legacy chat+thread+lane key (no regression for non-progress callers)', async () => {
569
- // Other lanes (default, thinking, activity) don't pass turnKey. They
570
- // must still multiplex the legacy way: one stream per chat+thread+lane.
571
- // This pins the backwards-compatible behavior of streamKey() when
572
- // turnKey is undefined — a non-progress caller shouldn't suddenly
573
- // create a new stream on every call.
574
532
  const state = makeState()
575
533
  const deps = makeDeps(bot)
576
534
 
@@ -582,67 +540,48 @@ describe('handleStreamReply', () => {
582
540
  await microtaskFlush()
583
541
  await p2
584
542
 
585
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
543
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
586
544
  expect(bot.api.editMessageText).toHaveBeenCalledTimes(1)
587
545
  expect(state.activeDraftStreams.size).toBe(1)
588
546
  expect(state.activeDraftStreams.has('1:_')).toBe(true)
589
547
  })
590
548
 
591
- it('bug 1: parseMode mismatch with existing stream rotates to fresh stream with new parseMode + rendered text', async () => {
592
- // Reproduces the reported bug: PTY-tail auto-stream seeds a stream
593
- // with format:'text' (parseMode undefined). A later explicit
594
- // stream_reply on the same key with format:'html' + markdown text
595
- // must NOT inherit the stale parseMode it must finalize the old
596
- // stream and create a fresh one with parse_mode=HTML so the markdown
597
- // converts to HTML tags instead of sending literal asterisks.
549
+ it('single-mode reuse: a format switch on the same lane does NOT rotate the stream (#2669)', async () => {
550
+ // Pre-#2669 a parseMode mismatch (text→html) finalized the old stream
551
+ // and created a fresh one. There is now ONE mode: a non-text format on
552
+ // an existing rich stream reuses it (edit in place). Only a literal
553
+ // 'text' stream differs, and that distinction is fixed at creation.
598
554
  const state = makeState()
599
- const deps = makeDeps(bot, {
600
- markdownToHtml: realMarkdownToHtml,
601
- defaultFormat: 'text',
602
- })
555
+ const deps = makeDeps(bot, { defaultFormat: 'markdown' })
603
556
 
604
- // First call: PTY-tail-style, format:'text'
557
+ // First call: rich markdown.
605
558
  const p1 = handleStreamReply(
606
- { chat_id: '1', text: 'Running Bash: ls', format: 'text' },
559
+ { chat_id: '1', text: 'plain start', format: 'markdown' },
607
560
  state, deps,
608
561
  )
609
562
  await microtaskFlush()
610
563
  await p1
611
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
612
- expect(bot.api.sendMessage.mock.calls[0][2]?.parse_mode).toBeUndefined()
564
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
613
565
 
614
566
  vi.advanceTimersByTime(1000)
615
567
 
616
- // Second call on the same stream key: model explicitly uses html +
617
- // markdown. Must produce a new send (stream rotated), parse_mode HTML,
618
- // and literal markdown converted to Telegram HTML tags.
568
+ // Second call on the same key with a different (still non-text) format:
569
+ // the stream is reused and the markdown ships raw, unescaped.
619
570
  const p2 = handleStreamReply(
620
- { chat_id: '1', text: '**bold** and `code`', format: 'html' },
571
+ { chat_id: '1', text: '**bold** and `code`', format: 'gfm' },
621
572
  state, deps,
622
573
  )
623
574
  await microtaskFlush()
624
575
  await p2
625
576
 
626
- // A fresh stream means a second sendMessage, not an edit of the old
627
- // one (the old stream was finalized + discarded).
628
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(2)
629
- const secondSend = bot.api.sendMessage.mock.calls[1]
630
- expect(secondSend[2]?.parse_mode).toBe('HTML')
631
- // markdownToHtml renders `**bold**` → `<b>bold</b>` and
632
- // `` `code` `` → `<code>code</code>`.
633
- expect(secondSend[1]).toContain('<b>bold</b>')
634
- expect(secondSend[1]).toContain('<code>code</code>')
635
- expect(secondSend[1]).not.toContain('**')
636
- expect(secondSend[1]).not.toMatch(/`code`/)
577
+ // No new send edited in place.
578
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
579
+ expect(bot.api.editMessageText).toHaveBeenCalledTimes(1)
580
+ // Raw markdown passed through verbatim — NOT converted to HTML.
581
+ expect(richEditMarkdown(bot)).toBe('**bold** and `code`')
637
582
  })
638
583
 
639
- // ─── Regression: PTY-tail duplicate message. Before the fix,
640
- // stream_reply did not add itself to suppressPtyPreview, so a PTY
641
- // partial firing after a finalized stream (TUI capture of the same
642
- // assistant text) created a duplicate message with the raw TUI text
643
- // and visibly escaped HTML tags. See log sequence: msg 559 finalized,
644
- // then msg 560 draft_send path=pty_preview with the same content.
645
- // Now stream_reply claims the suppress slot on the first call.
584
+ // ─── Regression: PTY-tail duplicate message ──────────────────────────
646
585
 
647
586
  it('adds sKey (without lane) to suppressPtyPreview on first call', async () => {
648
587
  const state: StreamReplyState = {
@@ -673,18 +612,12 @@ describe('handleStreamReply', () => {
673
612
  await microtaskFlush()
674
613
  await pending
675
614
 
676
- // The stream itself is keyed with the lane...
677
615
  expect(state.activeDraftStreams.has('42:_:thinking')).toBe(true)
678
- // ...but the PTY-suppression key is lane-less so the PTY handler
679
- // (which has no concept of lanes) actually sees it as suppressed.
680
616
  expect(state.suppressPtyPreview!.has('42:_')).toBe(true)
681
617
  expect(state.suppressPtyPreview!.has('42:_:thinking')).toBe(false)
682
618
  })
683
619
 
684
620
  it('suppression survives done=true so late PTY partials are still dropped', async () => {
685
- // This covers the exact production sequence from telegram-plugin.log:
686
- // stream_reply done=true → draft_edit final → PTY partial arrives
687
- // 500ms later with the TUI capture → must NOT create a new message.
688
621
  const state: StreamReplyState = {
689
622
  ...makeState(),
690
623
  suppressPtyPreview: new Set<string>(),
@@ -699,25 +632,15 @@ describe('handleStreamReply', () => {
699
632
  await microtaskFlush()
700
633
  await pending
701
634
 
702
- // After done=true the stream is gone from activeDraftStreams...
703
635
  expect(state.activeDraftStreams.has('42:_')).toBe(false)
704
- // ...but the suppress slot must remain so a PTY partial landing
705
- // AFTER finalize is dropped. server.ts clears this on turn_end.
706
636
  expect(state.suppressPtyPreview!.has('42:_')).toBe(true)
707
637
  })
708
638
 
709
639
  it('end-to-end: PTY partial after stream_reply finalize is suppressed (no dup message)', async () => {
710
- // Reproduces the production sequence:
711
- // 1. stream_reply done=true for chat 42
712
- // 2. PTY-tail fires with the TUI capture of the same assistant text
713
- // 3. PTY handler sees suppress flag and drops the partial
714
- // Before the fix, step 2 created a duplicate Telegram message with
715
- // raw TUI text and visibly-escaped HTML tags (see log msg 559 → 560).
716
640
  const activeDraftStreams = new Map<string, DraftStreamHandle>()
717
641
  const suppressPtyPreview = new Set<string>()
718
642
  const streamState: StreamReplyState = {
719
643
  activeDraftStreams,
720
- activeDraftParseModes: new Map(),
721
644
  suppressPtyPreview,
722
645
  }
723
646
  const streamDeps = makeDeps(bot)
@@ -730,7 +653,8 @@ describe('handleStreamReply', () => {
730
653
  )
731
654
  await microtaskFlush()
732
655
  await pending
733
- const sendsAfterStream = bot.api.sendMessage.mock.calls.length
656
+ const sendsAfterStream =
657
+ bot.api.sendMessage.mock.calls.length + bot.api.sendRichMessage.mock.calls.length
734
658
 
735
659
  // Step 2: PTY partial fires into the SHARED state — same Sets/Maps.
736
660
  const ptyState: PtyHandlerState = {
@@ -742,19 +666,20 @@ describe('handleStreamReply', () => {
742
666
  lastPtyPreviewByChat: new Map(),
743
667
  }
744
668
  const action = handlePtyPartialPure(
745
- 'TUI capture: <b>final answer</b>',
669
+ 'TUI capture: final answer',
746
670
  ptyState,
747
671
  { bot, renderText: (t) => t },
748
672
  )
749
673
  await microtaskFlush()
750
674
 
751
- // Step 3: partial was dropped — no extra sendMessage call.
675
+ // Step 3: partial was dropped — no extra send call of any kind.
752
676
  expect(action).toBe('suppressed')
753
- expect(bot.api.sendMessage.mock.calls.length).toBe(sendsAfterStream)
677
+ expect(
678
+ bot.api.sendMessage.mock.calls.length + bot.api.sendRichMessage.mock.calls.length,
679
+ ).toBe(sendsAfterStream)
754
680
  })
755
681
 
756
682
  it('works without suppressPtyPreview (backwards compat)', async () => {
757
- // Callers that don't thread the set through must still function.
758
683
  const state = makeState() // no suppressPtyPreview
759
684
  const deps = makeDeps(bot)
760
685
 
@@ -786,13 +711,6 @@ describe('handleStreamReply', () => {
786
711
  })
787
712
 
788
713
  describe('progressCardActive coexistence', () => {
789
- // The progress card and the answer message live on different lanes
790
- // (progress vs default) and render different content (tool structure
791
- // vs model prose), so default-lane stream_reply(done=false) is
792
- // accepted in checklist mode. The card is no longer treated as the
793
- // sole mid-turn surface — it shows tool structure on its own lane
794
- // while the model's progressive replies stream into the answer
795
- // message. See #481.
796
714
  it('accepts default-lane done=false when progress card is active (streams into answer)', async () => {
797
715
  const state: StreamReplyState = {
798
716
  ...makeState(),
@@ -808,14 +726,9 @@ describe('handleStreamReply', () => {
808
726
  await microtaskFlush()
809
727
  const result = await pending
810
728
 
811
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
812
- expect(bot.api.sendMessage.mock.calls[0][1]).toBe('<b>working...</b>')
729
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
730
+ expect(richSendMarkdown(bot)).toBe('working...')
813
731
  expect(result.status).toBe('updated')
814
- // PTY-preview slot is claimed because the model is now the
815
- // answer-lane surface owner — late PTY partials should defer to
816
- // the model's stream rather than racing it with a parallel edit.
817
- // (Same suppression pattern as before — no longer for cleanup
818
- // after a rejection, but for ownership during normal streaming.)
819
732
  expect(state.suppressPtyPreview?.has('1:_')).toBe(true)
820
733
  })
821
734
 
@@ -831,8 +744,8 @@ describe('handleStreamReply', () => {
831
744
  await microtaskFlush()
832
745
  const result = await pending
833
746
 
834
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
835
- expect(bot.api.sendMessage.mock.calls[0][1]).toBe('<b>final answer</b>')
747
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
748
+ expect(richSendMarkdown(bot)).toBe('final answer')
836
749
  expect(result.status).toBe('finalized')
837
750
  expect(result.messageId).toBe(500)
838
751
  })
@@ -849,7 +762,7 @@ describe('handleStreamReply', () => {
849
762
  await microtaskFlush()
850
763
  await pending
851
764
 
852
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
765
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
853
766
  })
854
767
 
855
768
  it('legacy behavior preserved when progressCardActive is false', async () => {
@@ -860,7 +773,7 @@ describe('handleStreamReply', () => {
860
773
  await microtaskFlush()
861
774
  await pending
862
775
 
863
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
776
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
864
777
  })
865
778
  })
866
779
 
@@ -877,7 +790,7 @@ describe('handleStreamReply', () => {
877
790
  await pending
878
791
 
879
792
  expect(lookup).toHaveBeenCalledWith('1', null)
880
- expect(bot.api.sendMessage.mock.calls[0][2]?.reply_parameters).toEqual({
793
+ expect(bot.api.sendRichMessage.mock.calls[0][2]?.reply_parameters).toEqual({
881
794
  message_id: 4242,
882
795
  })
883
796
  })
@@ -897,9 +810,8 @@ describe('handleStreamReply', () => {
897
810
  await microtaskFlush()
898
811
  await pending
899
812
 
900
- // Lookup is skipped entirely when reply_to is explicit.
901
813
  expect(lookup).not.toHaveBeenCalled()
902
- expect(bot.api.sendMessage.mock.calls[0][2]?.reply_parameters).toEqual({
814
+ expect(bot.api.sendRichMessage.mock.calls[0][2]?.reply_parameters).toEqual({
903
815
  message_id: 777,
904
816
  })
905
817
  })
@@ -920,7 +832,7 @@ describe('handleStreamReply', () => {
920
832
  await pending
921
833
 
922
834
  expect(lookup).not.toHaveBeenCalled()
923
- expect(bot.api.sendMessage.mock.calls[0][2]?.reply_parameters).toBeUndefined()
835
+ expect(bot.api.sendRichMessage.mock.calls[0][2]?.reply_parameters).toBeUndefined()
924
836
  })
925
837
 
926
838
  it('no reply_parameters when history lookup returns null (empty history)', async () => {
@@ -935,7 +847,7 @@ describe('handleStreamReply', () => {
935
847
  await pending
936
848
 
937
849
  expect(lookup).toHaveBeenCalledTimes(1)
938
- expect(bot.api.sendMessage.mock.calls[0][2]?.reply_parameters).toBeUndefined()
850
+ expect(bot.api.sendRichMessage.mock.calls[0][2]?.reply_parameters).toBeUndefined()
939
851
  })
940
852
 
941
853
  it('no auto-quote when getLatestInboundMessageId dep is omitted (legacy callers)', async () => {
@@ -946,7 +858,7 @@ describe('handleStreamReply', () => {
946
858
  await microtaskFlush()
947
859
  await pending
948
860
 
949
- expect(bot.api.sendMessage.mock.calls[0][2]?.reply_parameters).toBeUndefined()
861
+ expect(bot.api.sendRichMessage.mock.calls[0][2]?.reply_parameters).toBeUndefined()
950
862
  })
951
863
 
952
864
  it('passes thread id to the lookup', async () => {
@@ -974,17 +886,14 @@ describe('handleStreamReply', () => {
974
886
  )
975
887
  const deps = makeDeps(bot, { getLatestInboundMessageId: lookup })
976
888
 
977
- // First call → send with reply_parameters.
978
889
  await handleStreamReply({ chat_id: '1', text: 'hi' }, state, deps)
979
890
  await microtaskFlush()
980
891
 
981
- // Second call on the same stream → edit. editMessageText must NOT
982
- // receive reply_parameters (Telegram rejects it on edit).
983
892
  vi.advanceTimersByTime(1000)
984
893
  await handleStreamReply({ chat_id: '1', text: 'hi there' }, state, deps)
985
894
  await microtaskFlush()
986
895
 
987
- expect(bot.api.sendMessage.mock.calls[0][2]?.reply_parameters).toEqual({
896
+ expect(bot.api.sendRichMessage.mock.calls[0][2]?.reply_parameters).toEqual({
988
897
  message_id: 4242,
989
898
  })
990
899
  expect(bot.api.editMessageText).toHaveBeenCalled()
@@ -994,7 +903,7 @@ describe('handleStreamReply', () => {
994
903
  })
995
904
 
996
905
  describe('reply_markup persistence', () => {
997
- it('reply_markup in args is included in sendMessage opts on stream creation', async () => {
906
+ it('reply_markup in args is included in sendRichMessage opts on stream creation', async () => {
998
907
  const state = makeState()
999
908
  const deps = makeDeps(bot)
1000
909
  const keyboard = { inline_keyboard: [[{ text: 'Steer', callback_data: 'steer:1' }]] }
@@ -1007,10 +916,10 @@ describe('handleStreamReply', () => {
1007
916
  await microtaskFlush()
1008
917
  await pending
1009
918
 
1010
- expect(bot.api.sendMessage.mock.calls[0][2]?.reply_markup).toBe(keyboard)
919
+ expect(bot.api.sendRichMessage.mock.calls[0][2]?.reply_markup).toBe(keyboard)
1011
920
  })
1012
921
 
1013
- it('reply_markup persists through editMessageText on subsequent updates', async () => {
922
+ it('reply_markup rides on both the rich send AND the rich edit', async () => {
1014
923
  const state = makeState()
1015
924
  const deps = makeDeps(bot)
1016
925
  const keyboard = { inline_keyboard: [[{ text: 'Steer', callback_data: 'steer:1' }]] }
@@ -1032,6 +941,7 @@ describe('handleStreamReply', () => {
1032
941
  await microtaskFlush()
1033
942
  await p2
1034
943
 
944
+ expect(bot.api.sendRichMessage.mock.calls[0][2]?.reply_markup).toBe(keyboard)
1035
945
  expect(bot.api.editMessageText).toHaveBeenCalledTimes(1)
1036
946
  expect(bot.api.editMessageText.mock.calls[0][3]?.reply_markup).toBe(keyboard)
1037
947
  })
@@ -1070,17 +980,12 @@ describe('handleStreamReply', () => {
1070
980
  await microtaskFlush()
1071
981
  await pending
1072
982
 
1073
- expect(bot.api.sendMessage.mock.calls[0][2]?.reply_markup).toBeUndefined()
983
+ expect(bot.api.sendRichMessage.mock.calls[0][2]?.reply_markup).toBeUndefined()
1074
984
  })
1075
985
  })
1076
986
 
1077
987
  describe('lookupExistingMessageId hook (#626 — multiple status messages regression)', () => {
1078
- it('reuses an externally-known messageId on stream creation — first emit edits, no sendMessage', async () => {
1079
- // The pin manager already knows the anchor message id for this
1080
- // turnKey from a previous emit cycle (e.g. before done=true wiped
1081
- // activeDraftStreams[sKey]). The hook hands that id back; the
1082
- // new stream initializes with it, so the FIRST update edits in
1083
- // place. No fresh sendMessage = no extra "status message" lands.
988
+ it('reuses an externally-known messageId on stream creation — first emit edits, no send', async () => {
1084
989
  const state = makeState()
1085
990
  const deps = makeDeps(bot, {
1086
991
  lookupExistingMessageId: ({ turnKey, lane }) => {
@@ -1097,6 +1002,7 @@ describe('handleStreamReply', () => {
1097
1002
  await microtaskFlush()
1098
1003
  const result = await pending
1099
1004
 
1005
+ expect(bot.api.sendRichMessage).not.toHaveBeenCalled()
1100
1006
  expect(bot.api.sendMessage).not.toHaveBeenCalled()
1101
1007
  expect(bot.api.editMessageText).toHaveBeenCalledTimes(1)
1102
1008
  const [, id] = bot.api.editMessageText.mock.calls[0]
@@ -1104,9 +1010,7 @@ describe('handleStreamReply', () => {
1104
1010
  expect(result.messageId).toBe(4242)
1105
1011
  })
1106
1012
 
1107
- it('hook returns null → falls through to legacy sendMessage path', async () => {
1108
- // Back-compat sanity: a hook that returns null on every call
1109
- // produces identical behavior to omitting the hook entirely.
1013
+ it('hook returns null → falls through to legacy send path', async () => {
1110
1014
  const state = makeState()
1111
1015
  const deps = makeDeps(bot, {
1112
1016
  lookupExistingMessageId: () => null,
@@ -1120,16 +1024,11 @@ describe('handleStreamReply', () => {
1120
1024
  await microtaskFlush()
1121
1025
  await pending
1122
1026
 
1123
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
1027
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
1124
1028
  expect(bot.api.editMessageText).not.toHaveBeenCalled()
1125
1029
  })
1126
1030
 
1127
1031
  it('hook NOT consulted when an active draft stream already exists for the lane+turn', async () => {
1128
- // Lifecycle invariant: the hook only fires on stream creation.
1129
- // If activeDraftStreams[sKey] is already populated (turn in
1130
- // progress, no done=true yet), the existing stream handles
1131
- // edits — the hook is never consulted, so it can't disturb the
1132
- // running stream's state.
1133
1032
  const state = makeState()
1134
1033
  let lookupCalls = 0
1135
1034
  const deps = makeDeps(bot, {
@@ -1139,8 +1038,6 @@ describe('handleStreamReply', () => {
1139
1038
  },
1140
1039
  })
1141
1040
 
1142
- // First emit creates the stream (lookup IS called, returns
1143
- // 9999 → first edit goes to 9999).
1144
1041
  await handleStreamReply(
1145
1042
  { chat_id: '1', text: 'first', lane: 'progress', turnKey: 'turn-B' },
1146
1043
  state,
@@ -1151,8 +1048,6 @@ describe('handleStreamReply', () => {
1151
1048
  expect(lookupCalls).toBe(1)
1152
1049
  expect(bot.api.editMessageText).toHaveBeenCalledTimes(1)
1153
1050
 
1154
- // Second emit on the same lane+turn reuses the existing stream
1155
- // — the lookup is NOT called again. Edits still target 9999.
1156
1051
  await handleStreamReply(
1157
1052
  { chat_id: '1', text: 'second', lane: 'progress', turnKey: 'turn-B' },
1158
1053
  state,
@@ -1163,10 +1058,7 @@ describe('handleStreamReply', () => {
1163
1058
  expect(bot.api.editMessageText).toHaveBeenCalledTimes(2)
1164
1059
  })
1165
1060
 
1166
- it('hook throws → error logged, handler falls through to fresh sendMessage', async () => {
1167
- // Defensive contract: a buggy lookup must never break the
1168
- // outbound path. Caller's writeError gets the diagnostic; the
1169
- // emit lands as a fresh send.
1061
+ it('hook throws → error logged, handler falls through to fresh send', async () => {
1170
1062
  const state = makeState()
1171
1063
  const writeError = vi.fn()
1172
1064
  const deps = makeDeps(bot, {
@@ -1183,44 +1075,28 @@ describe('handleStreamReply', () => {
1183
1075
  )
1184
1076
  await microtaskFlush()
1185
1077
 
1186
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
1078
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
1187
1079
  expect(writeError).toHaveBeenCalled()
1188
1080
  const errLine = (writeError.mock.calls[0]?.[0] as string) ?? ''
1189
1081
  expect(errLine).toContain('lookupExistingMessageId failed')
1190
1082
  })
1191
1083
 
1192
- it('full #626 lifecycle scenario — done=true → activeDraftStreams cleared → next emit edits via hook (one anchor message total)', async () => {
1193
- // The end-to-end repro of #626. Sequence:
1194
- // 1. First progress-card emit (isFirstEmit=true) → fresh
1195
- // sendMessage on the 'progress' lane for turn-A. Pin
1196
- // manager records messageId=500.
1197
- // 2. done=true emit (e.g. the parent turn_end fires before
1198
- // sub-agents finish) → handler finalizes + DELETES
1199
- // activeDraftStreams[sKey].
1200
- // 3. A subsequent sub-agent event triggers a fresh progress-
1201
- // card emit on the SAME turn-A. Without the hook, the
1202
- // handler would create a new stream → fresh sendMessage →
1203
- // a SECOND status message lands in the chat.
1204
- // 4. With the hook returning the pin-manager's messageId 500,
1205
- // the new stream initializes with 500. The next update
1206
- // hits editMessageText against 500. Total Telegram surface
1207
- // = ONE message.
1084
+ it('full #626 lifecycle scenario — done=true → cleared → next emit edits via hook (one anchor message total)', async () => {
1208
1085
  const state = makeState()
1209
1086
  const knownMessageId = { value: null as number | null }
1210
1087
  const deps = makeDeps(bot, {
1211
1088
  lookupExistingMessageId: () => knownMessageId.value,
1212
1089
  })
1213
1090
 
1214
- // 1. First emit, no known messageId yet → fresh sendMessage
1091
+ // 1. First emit, no known messageId yet → fresh send
1215
1092
  await handleStreamReply(
1216
1093
  { chat_id: '1', text: 'tool 1...', lane: 'progress', turnKey: 'turn-A' },
1217
1094
  state,
1218
1095
  deps,
1219
1096
  )
1220
1097
  await microtaskFlush()
1221
- // The pin manager records id=500 (mock bot's first id).
1222
1098
  knownMessageId.value = 500
1223
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
1099
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
1224
1100
  expect(bot.api.editMessageText).not.toHaveBeenCalled()
1225
1101
 
1226
1102
  // 2. done=true → finalize + clear sKey
@@ -1233,8 +1109,7 @@ describe('handleStreamReply', () => {
1233
1109
  await microtaskFlush()
1234
1110
  expect(state.activeDraftStreams.size).toBe(0)
1235
1111
 
1236
- // 3. Subsequent sub-agent emit on the SAME turn-A — without
1237
- // the hook this would land as sendMessage #2 (the bug).
1112
+ // 3. Subsequent sub-agent emit on the SAME turn-A.
1238
1113
  await handleStreamReply(
1239
1114
  { chat_id: '1', text: 'tool 1, tool 2 ✓, sub-agent...', lane: 'progress', turnKey: 'turn-A' },
1240
1115
  state,
@@ -1242,10 +1117,8 @@ describe('handleStreamReply', () => {
1242
1117
  )
1243
1118
  await microtaskFlush()
1244
1119
 
1245
- // Invariant: total fresh sendMessages on this chat = 1.
1246
- // Anything > 1 is the #626 bug class.
1247
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
1248
- // The post-done emit was an edit against id 500.
1120
+ // Invariant: total fresh rich sends on this chat = 1.
1121
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
1249
1122
  expect(bot.api.editMessageText.mock.calls.some((c) => c[1] === 500)).toBe(true)
1250
1123
  })
1251
1124
  })