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,78 +1,77 @@
1
1
  /**
2
- * Integration tests for stream-controller HTML parse-failure fallback
3
- * (issue #657).
2
+ * Integration tests for the stream-controller parse-failure fallback
3
+ * (issue #657, post-#2669 rich-message path).
4
4
  *
5
- * Contract:
6
- * - When the FIRST sendMessage with parse_mode=HTML returns
7
- * `400 Bad Request: can't parse entities`, the recovery is a single
8
- * fresh sendMessage with parse_mode stripped no edit (there is no
9
- * message_id to edit yet). Total outbound: ONE message_id, not two.
10
- * - When a subsequent editMessageText with parse_mode=HTML returns the
11
- * same 400, the recovery is editMessageText AGAIN on the same
12
- * message_id (with parse_mode stripped). Never sendMessage.
5
+ * Contract (unchanged dup-message semantics, new rich transport):
6
+ * - When the FIRST `sendRichMessage({ markdown })` returns
7
+ * `400 Bad Request: can't parse entities`, recovery is a single fresh
8
+ * plain `sendMessage` (the raw markdown as a literal string, no parser)
9
+ * — no edit (there is no message_id to edit yet). Total outbound: ONE
10
+ * message_id, not two.
11
+ * - When a subsequent `editMessageText({ markdown })` returns the same
12
+ * 400, recovery is `editMessageText` AGAIN on the same message_id with
13
+ * a PLAIN string (no rich wrapper). Never a fresh send.
13
14
  *
14
15
  * The previous behaviour (the bug #657 fixes) was a duplicate plain-text
15
- * sendMessage on every HTML parse rejection — visible to the user as two
16
- * messages, one with raw `<b>` tags and one rendered correctly.
16
+ * sendMessage on every parse rejection — visible to the user as two
17
+ * messages, one raw and one rendered correctly.
17
18
  */
18
19
 
19
20
  import { describe, it, expect, vi } from 'vitest'
20
21
  import { createStreamController } from '../stream-controller.js'
21
22
  import { createFakeBotApi, errors } from './fake-bot-api.js'
22
23
 
23
- describe('stream-controller HTML parse-failure fallback (#657)', () => {
24
- it('first send: parse-entities 400 → ONE retry without parse_mode, same outbound count', async () => {
24
+ describe('stream-controller parse-failure fallback (#657)', () => {
25
+ it('first rich send: parse-entities 400 → ONE plain-text retry, same outbound count', async () => {
25
26
  const bot = createFakeBotApi({ startMessageId: 1000 })
26
- // Inject a parse-entities 400 for the first sendMessage. The
27
- // controller must catch it and retry with parse_mode stripped.
27
+ // Inject a parse-entities 400 for the first sendRichMessage. The
28
+ // controller must catch it and retry as a plain sendMessage.
28
29
  bot.faults.next(
29
- 'sendMessage',
30
+ 'sendRichMessage',
30
31
  errors.badRequest("can't parse entities: Unsupported start tag \"frobnicate\""),
31
32
  )
32
33
 
33
34
  const stream = createStreamController({
34
35
  bot: bot as unknown as { api: ReturnType<typeof createFakeBotApi>['api'] },
35
36
  chatId: 'c1',
36
- parseMode: 'HTML',
37
37
  throttleMs: 0,
38
38
  })
39
39
 
40
- await stream.update('<frobnicate>hello</frobnicate>')
40
+ await stream.update('**broken _markdown')
41
41
  await stream.finalize()
42
42
 
43
43
  // Exactly one message landed.
44
44
  expect(bot.state.sent).toHaveLength(1)
45
- // The recovery send had no parse_mode.
46
- expect(bot.state.sent[0].parse_mode).toBeUndefined()
47
- // The body was rendered as plain text (tags stripped).
48
- expect(bot.state.sent[0].text).toContain('hello')
49
- expect(bot.state.sent[0].text).not.toContain('<frobnicate>')
45
+ // The recovery send was a plain sendMessage, not a rich one.
46
+ expect(bot.state.sent[0].rich).toBeFalsy()
47
+ // The raw markdown body shipped verbatim as the literal fallback.
48
+ expect(bot.state.sent[0].text).toBe('**broken _markdown')
50
49
  // The stream's message id matches the surviving send.
51
50
  expect(stream.getMessageId()).toBe(bot.state.sent[0].message_id)
52
51
  })
53
52
 
54
- it('edit on existing message: parse-entities 400 → editMessageText retry on SAME id, never sendMessage', async () => {
53
+ it('edit on existing message: parse-entities 400 → plain editMessageText on SAME id, never a fresh send', async () => {
55
54
  const bot = createFakeBotApi({ startMessageId: 2000 })
56
55
 
57
56
  const stream = createStreamController({
58
57
  bot: bot as unknown as { api: ReturnType<typeof createFakeBotApi>['api'] },
59
58
  chatId: 'c1',
60
- parseMode: 'HTML',
61
59
  throttleMs: 0,
62
60
  })
63
61
 
64
- // First update lands cleanly as a sendMessage.
65
- await stream.update('<b>v1</b>')
62
+ // First update lands cleanly as a rich send.
63
+ await stream.update('**v1**')
66
64
  expect(bot.state.sent).toHaveLength(1)
65
+ expect(bot.state.sent[0].rich).toBe(true)
67
66
  const firstId = bot.state.sent[0].message_id
68
67
 
69
68
  // Second update: inject a parse-entities 400 on editMessageText.
70
69
  bot.faults.next(
71
70
  'editMessageText',
72
- errors.badRequest("can't parse entities: Unmatched end tag at byte offset 12"),
71
+ errors.badRequest("can't parse entities: Can't find end of the entity"),
73
72
  )
74
73
 
75
- await stream.update('<b>v2 broken</b><i>extra')
74
+ await stream.update('**v2 broken _extra')
76
75
  await stream.finalize()
77
76
 
78
77
  // Critical assertion: still ONE outbound message_id total — the
@@ -86,13 +85,13 @@ describe('stream-controller HTML parse-failure fallback (#657)', () => {
86
85
  for (const call of editCalls) {
87
86
  expect(call[1]).toBe(firstId) // same message_id
88
87
  }
89
- // The final edit had parse_mode stripped (key absent).
88
+ // The final edit passed a PLAIN string (the fallback), not { markdown }.
90
89
  const finalCall = editCalls[editCalls.length - 1]
91
- expect(finalCall[3].parse_mode).toBeUndefined()
92
- // The stored text reflects the plain-text fallback.
90
+ expect(typeof finalCall[2]).toBe('string')
91
+ expect(finalCall[2]).toBe('**v2 broken _extra')
92
+ // The stored text reflects the plain-text fallback body.
93
93
  const finalText = bot.state.currentText.get(firstId)
94
- expect(finalText).toBeDefined()
95
- expect(finalText).not.toContain('<b>')
94
+ expect(finalText).toBe('**v2 broken _extra')
96
95
  })
97
96
 
98
97
  it('non-parse 400 (e.g. message-not-found) is NOT swallowed by the fallback', async () => {
@@ -101,25 +100,24 @@ describe('stream-controller HTML parse-failure fallback (#657)', () => {
101
100
  const stream = createStreamController({
102
101
  bot: bot as unknown as { api: ReturnType<typeof createFakeBotApi>['api'] },
103
102
  chatId: 'c1',
104
- parseMode: 'HTML',
105
103
  throttleMs: 0,
106
104
  })
107
105
 
108
- await stream.update('<b>v1</b>')
106
+ await stream.update('**v1**')
109
107
  const firstId = bot.state.sent[0].message_id
110
108
 
111
109
  // Inject message-not-found on the next edit. This is NOT a parse
112
110
  // error — the existing not-found recovery in draft-stream.ts should
113
- // handle it (clear messageId, re-send) and our parse-fallback
114
- // wrapper must let it propagate.
111
+ // handle it (clear messageId, re-send) and the parse-fallback wrapper
112
+ // must let it propagate.
115
113
  bot.faults.next('editMessageText', errors.messageToEditNotFound())
116
114
 
117
- await stream.update('<b>v2</b>')
115
+ await stream.update('**v2**')
118
116
  await stream.finalize()
119
117
 
120
118
  // The not-found recovery path produces a fresh send — that's the
121
- // pre-existing contract. We're asserting it still fires after our
122
- // changes (i.e. we didn't accidentally catch this error class too).
119
+ // pre-existing contract. We're asserting it still fires (i.e. the
120
+ // parse-fallback didn't accidentally catch this error class too).
123
121
  // After: 1 original send + 1 re-send = 2 messages.
124
122
  expect(bot.state.sent.length).toBeGreaterThanOrEqual(2)
125
123
  expect(bot.state.sent[0].message_id).toBe(firstId)
@@ -2,6 +2,12 @@
2
2
  * Integration tests for `createStreamController` — the wiring layer
3
3
  * between `createDraftStream` and grammy's `bot.api`.
4
4
  *
5
+ * Post-#2669: the default path sends via `bot.api.sendRichMessage(chat,
6
+ * { markdown }, opts)` and edits via `bot.api.editMessageText(chat, id,
7
+ * { markdown }, opts)`. A `literalText: true` stream takes the plain
8
+ * `sendMessage` / plain-string `editMessageText` path instead. Both share
9
+ * one opts object, so a rich send is always followed by a rich edit.
10
+ *
5
11
  * Uses the ported openclaw-style mock bot harness (`bot-api.harness.ts`).
6
12
  */
7
13
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
@@ -15,32 +21,32 @@ describe('createStreamController', () => {
15
21
  beforeEach(() => vi.useFakeTimers())
16
22
  afterEach(() => vi.useRealTimers())
17
23
 
18
- it('first update calls bot.api.sendMessage with rendered text + options', async () => {
24
+ it('first update calls bot.api.sendRichMessage with raw markdown + options', async () => {
19
25
  const stream = createStreamController({
20
26
  bot,
21
27
  chatId: '123',
22
28
  threadId: 42,
23
- parseMode: 'HTML',
24
29
  throttleMs: 1000,
25
30
  })
26
31
 
27
- void stream.update('<b>hi</b>')
32
+ void stream.update('**hi**')
28
33
  await microtaskFlush()
29
34
 
30
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
35
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
36
+ expect(bot.api.sendMessage).not.toHaveBeenCalled()
31
37
  expect(bot.api.editMessageText).not.toHaveBeenCalled()
32
- const [chat_id, text, opts] = bot.api.sendMessage.mock.calls[0]
38
+ const [chat_id, rich, opts] = bot.api.sendRichMessage.mock.calls[0]
33
39
  expect(chat_id).toBe('123')
34
- expect(text).toBe('<b>hi</b>')
35
- expect(opts).toMatchObject({
36
- parse_mode: 'HTML',
37
- message_thread_id: 42,
38
- link_preview_options: { is_disabled: true },
39
- })
40
+ // Raw GFM markdown passed straight through, unescaped, no HTML.
41
+ expect(rich).toEqual({ markdown: '**hi**' })
42
+ expect(opts).toMatchObject({ message_thread_id: 42 })
43
+ // sendRichMessage does NOT accept link_preview_options (stripped on the
44
+ // rich send path).
45
+ expect(opts.link_preview_options).toBeUndefined()
40
46
  expect(stream.getMessageId()).toBe(500)
41
47
  })
42
48
 
43
- it('subsequent updates call editMessageText against the captured id', async () => {
49
+ it('subsequent updates call editMessageText({ markdown }) against the captured id', async () => {
44
50
  const stream = createStreamController({ bot, chatId: '123', throttleMs: 1000 })
45
51
 
46
52
  void stream.update('step 1')
@@ -49,15 +55,33 @@ describe('createStreamController', () => {
49
55
  void stream.update('step 1 — step 2')
50
56
  await microtaskFlush()
51
57
 
52
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
58
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
53
59
  expect(bot.api.editMessageText).toHaveBeenCalledTimes(1)
54
60
  const [chat_id, id, text] = bot.api.editMessageText.mock.calls[0]
55
61
  expect(chat_id).toBe('123')
56
62
  expect(id).toBe(500)
57
- expect(text).toBe('step 1 step 2')
63
+ // The edit carries the rich-message wrapper, not a plain string.
64
+ expect(text).toEqual({ markdown: 'step 1 — step 2' })
58
65
  })
59
66
 
60
- it('finalize() flushes pending text as an edit immediately', async () => {
67
+ it('literalText:true takes the plain sendMessage / plain-string edit path', async () => {
68
+ const stream = createStreamController({ bot, chatId: '123', throttleMs: 1000, literalText: true })
69
+
70
+ void stream.update('raw < text >')
71
+ await microtaskFlush()
72
+ expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
73
+ expect(bot.api.sendRichMessage).not.toHaveBeenCalled()
74
+ expect(bot.api.sendMessage.mock.calls[0][1]).toBe('raw < text >')
75
+
76
+ vi.advanceTimersByTime(1000)
77
+ void stream.update('raw < text > more')
78
+ await microtaskFlush()
79
+ expect(bot.api.editMessageText).toHaveBeenCalledTimes(1)
80
+ // Literal edits pass a plain string, not { markdown }.
81
+ expect(bot.api.editMessageText.mock.calls[0][2]).toBe('raw < text > more')
82
+ })
83
+
84
+ it('finalize() flushes pending text as a rich edit immediately', async () => {
61
85
  const stream = createStreamController({ bot, chatId: '123', throttleMs: 1000 })
62
86
 
63
87
  void stream.update('draft')
@@ -69,7 +93,7 @@ describe('createStreamController', () => {
69
93
  await stream.finalize()
70
94
 
71
95
  expect(bot.api.editMessageText).toHaveBeenCalledTimes(1)
72
- expect(bot.api.editMessageText.mock.calls[0][2]).toBe('final')
96
+ expect(bot.api.editMessageText.mock.calls[0][2]).toEqual({ markdown: 'final' })
73
97
  expect(stream.isFinal()).toBe(true)
74
98
  })
75
99
 
@@ -96,18 +120,20 @@ describe('createStreamController', () => {
96
120
  expect(onEdit).toHaveBeenCalledWith(500, 11)
97
121
  })
98
122
 
99
- it('omits parse_mode when not provided (plain text mode)', async () => {
100
- const stream = createStreamController({ bot, chatId: '1' })
101
- void stream.update('plain')
123
+ it('rich send strips link_preview_options but keeps message_thread_id', async () => {
124
+ const stream = createStreamController({ bot, chatId: '1', threadId: 9 })
125
+ void stream.update('hi')
102
126
  await microtaskFlush()
103
- expect(bot.api.sendMessage.mock.calls[0][2]?.parse_mode).toBeUndefined()
127
+ const opts = bot.api.sendRichMessage.mock.calls[0][2]
128
+ expect(opts.link_preview_options).toBeUndefined()
129
+ expect(opts.message_thread_id).toBe(9)
104
130
  })
105
131
 
106
132
  it('omits message_thread_id when threadId is undefined', async () => {
107
133
  const stream = createStreamController({ bot, chatId: '1' })
108
134
  void stream.update('no thread')
109
135
  await microtaskFlush()
110
- expect(bot.api.sendMessage.mock.calls[0][2]?.message_thread_id).toBeUndefined()
136
+ expect(bot.api.sendRichMessage.mock.calls[0][2]?.message_thread_id).toBeUndefined()
111
137
  })
112
138
 
113
139
  it('rapid updates collapse to the latest; exactly one edit lands', async () => {
@@ -115,7 +141,7 @@ describe('createStreamController', () => {
115
141
 
116
142
  void stream.update('initial')
117
143
  await microtaskFlush()
118
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
144
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
119
145
 
120
146
  void stream.update('a')
121
147
  void stream.update('b')
@@ -127,7 +153,7 @@ describe('createStreamController', () => {
127
153
  await microtaskFlush()
128
154
 
129
155
  expect(bot.api.editMessageText).toHaveBeenCalledTimes(1)
130
- expect(bot.api.editMessageText.mock.calls[0][2]).toBe('c')
156
+ expect(bot.api.editMessageText.mock.calls[0][2]).toEqual({ markdown: 'c' })
131
157
  })
132
158
 
133
159
  it('treats "message is not modified" edit errors as success (swallow)', async () => {
@@ -159,7 +185,7 @@ describe('createStreamController', () => {
159
185
  expect(retry.mock.calls[0][1]).toEqual({ threadId: undefined, chat_id: '1' })
160
186
  })
161
187
 
162
- it('replyMarkup is included in sendMessage opts', async () => {
188
+ it('replyMarkup is included in sendRichMessage opts', async () => {
163
189
  const keyboard = { inline_keyboard: [[{ text: 'Steer', callback_data: 'steer:1' }]] }
164
190
  const stream = createStreamController({
165
191
  bot, chatId: '1', throttleMs: 1000, replyMarkup: keyboard,
@@ -167,11 +193,11 @@ describe('createStreamController', () => {
167
193
  void stream.update('hello')
168
194
  await microtaskFlush()
169
195
 
170
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
171
- expect(bot.api.sendMessage.mock.calls[0][2]?.reply_markup).toBe(keyboard)
196
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
197
+ expect(bot.api.sendRichMessage.mock.calls[0][2]?.reply_markup).toBe(keyboard)
172
198
  })
173
199
 
174
- it('replyMarkup persists through editMessageText calls', async () => {
200
+ it('replyMarkup rides on both the rich send AND the rich edit', async () => {
175
201
  const keyboard = { inline_keyboard: [[{ text: 'Steer', callback_data: 'steer:1' }]] }
176
202
  const stream = createStreamController({
177
203
  bot, chatId: '1', throttleMs: 1000, replyMarkup: keyboard,
@@ -183,6 +209,7 @@ describe('createStreamController', () => {
183
209
  void stream.update('second')
184
210
  await microtaskFlush()
185
211
 
212
+ expect(bot.api.sendRichMessage.mock.calls[0][2]?.reply_markup).toBe(keyboard)
186
213
  expect(bot.api.editMessageText).toHaveBeenCalledTimes(1)
187
214
  expect(bot.api.editMessageText.mock.calls[0][3]?.reply_markup).toBe(keyboard)
188
215
  })
@@ -206,11 +233,11 @@ describe('createStreamController', () => {
206
233
  const stream = createStreamController({ bot, chatId: '1', throttleMs: 1000 })
207
234
  void stream.update('hello')
208
235
  await microtaskFlush()
209
- expect(bot.api.sendMessage.mock.calls[0][2]?.reply_markup).toBeUndefined()
236
+ expect(bot.api.sendRichMessage.mock.calls[0][2]?.reply_markup).toBeUndefined()
210
237
  })
211
238
 
212
239
  it('send failure is swallowed by draft-stream loop (pinned behaviour)', async () => {
213
- bot.api.sendMessage.mockImplementationOnce(async () => {
240
+ bot.api.sendRichMessage.mockImplementationOnce(async () => {
214
241
  throw new Error('network down')
215
242
  })
216
243
 
@@ -218,45 +245,29 @@ describe('createStreamController', () => {
218
245
  void stream.update('will fail')
219
246
  await microtaskFlush()
220
247
 
221
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
248
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1)
222
249
  expect(stream.getMessageId()).toBeNull()
223
- // This pins the current "swallow and continue" semantics. When we
224
- // harden error handling later, this test flips to expect a thrown
225
- // error or a status-reaction signal — whichever we decide.
226
250
  })
227
251
 
228
- it('initialMessageId — first update edits the supplied id, no sendMessage fires (#626)', async () => {
229
- // The full-stack proof of the gateway-emit idempotency hook: the
230
- // pin manager hands us an existing message id, the controller
231
- // initializes its state to that id, and the very first update
232
- // routes to bot.api.editMessageText against id 7777 — NEVER to
233
- // bot.api.sendMessage. This is the invariant that makes "multiple
234
- // status messages per turn" structurally impossible when the pin
235
- // manager has tracked a card for the current turnKey.
252
+ it('initialMessageId — first update edits the supplied id via rich edit, no send fires (#626)', async () => {
236
253
  const stream = createStreamController({
237
254
  bot,
238
255
  chatId: '999',
239
256
  threadId: 7,
240
- parseMode: 'HTML',
241
257
  throttleMs: 1000,
242
258
  initialMessageId: 7777,
243
259
  })
244
- void stream.update('<b>edit-only</b>')
260
+ void stream.update('**edit-only**')
245
261
  await microtaskFlush()
262
+ expect(bot.api.sendRichMessage).not.toHaveBeenCalled()
246
263
  expect(bot.api.sendMessage).not.toHaveBeenCalled()
247
264
  expect(bot.api.editMessageText).toHaveBeenCalledTimes(1)
248
265
  const [chat_id, id, text, opts] = bot.api.editMessageText.mock.calls[0]
249
266
  expect(chat_id).toBe('999')
250
267
  expect(id).toBe(7777)
251
- expect(text).toBe('<b>edit-only</b>')
252
- expect(opts).toMatchObject({
253
- parse_mode: 'HTML',
254
- message_thread_id: 7,
255
- link_preview_options: { is_disabled: true },
256
- })
257
- // The handle reports the supplied id immediately so the gateway
258
- // can chain pinManager.considerPin with the right id even on the
259
- // first edit.
268
+ expect(text).toEqual({ markdown: '**edit-only**' })
269
+ // The edit keeps message_thread_id but not reply_parameters/protect.
270
+ expect(opts).toMatchObject({ message_thread_id: 7 })
260
271
  expect(stream.getMessageId()).toBe(7777)
261
272
  })
262
273
  })
@@ -3,30 +3,28 @@
3
3
  * fake bot API (with fault injection) and the real retry policy.
4
4
  *
5
5
  * These tests exercise the full error chain (fake GrammyError → retry
6
- * policy → draft-stream → caller result) end to end. Before this suite,
7
- * those paths existed only in production.
6
+ * policy → draft-stream → caller result) end to end. Post-#2669 the
7
+ * default path is the rich-message send (`sendRichMessage`) / rich edit
8
+ * (`editMessageText({ markdown })`), so faults are injected on those
9
+ * methods and the wire body is the raw GFM markdown (no HTML render).
8
10
  *
9
11
  * NOTE on draft-stream behaviour: the current implementation SILENTLY
10
12
  * SWALLOWS any error that isn't specifically recognised (not-modified,
11
- * message-to-edit-not-found). A 403, thread-not-found, or exhausted 429
12
- * all end up logged and dropped. The handler's "finalized without
13
- * sending any message" check at stream-reply-handler.ts:397 is the only
14
- * signal a caller gets that something went wrong. These tests document
15
- * that contract rather than the one a strict API would have — changing
16
- * it is tracked separately.
13
+ * message-to-edit-not-found, parse-entities). A 403, thread-not-found, or
14
+ * exhausted 429 all end up logged and dropped. The handler's "finalized
15
+ * without sending any message" check is the only signal a caller gets
16
+ * that something went wrong.
17
17
  */
18
18
 
19
19
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
20
20
  import { handleStreamReply, type StreamReplyDeps, type StreamReplyState } from '../stream-reply-handler.js'
21
21
  import type { DraftStreamHandle } from '../draft-stream.js'
22
- import { markdownToHtml as realMarkdownToHtml } from '../format.js'
23
22
  import { createRetryApiCall } from '../retry-api-call.js'
24
23
  import { createFakeBotApi, errors, type FakeBot } from './fake-bot-api.js'
25
24
 
26
25
  function makeState(): StreamReplyState {
27
26
  return {
28
27
  activeDraftStreams: new Map<string, DraftStreamHandle>(),
29
- activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
30
28
  }
31
29
  }
32
30
 
@@ -37,13 +35,12 @@ function makeDeps(
37
35
  return {
38
36
  bot: bot as unknown as StreamReplyDeps['bot'],
39
37
  retry: createRetryApiCall(),
40
- markdownToHtml: (t) => realMarkdownToHtml(t),
41
- escapeMarkdownV2: (t) => t,
42
38
  repairEscapedWhitespace: (t) => t,
43
39
  assertAllowedChat: () => {},
44
40
  resolveThreadId: (_, explicit) => (explicit != null ? Number(explicit) : undefined),
45
41
  disableLinkPreview: true,
46
- defaultFormat: 'html',
42
+ // Anything other than the literal 'text' is the rich-markdown path.
43
+ defaultFormat: 'markdown',
47
44
  logStreamingEvent: () => {},
48
45
  historyEnabled: false,
49
46
  recordOutbound: () => {},
@@ -55,12 +52,7 @@ function makeDeps(
55
52
 
56
53
  /**
57
54
  * Pump vi fake timers + microtasks until all pending work settles.
58
- * The draft-stream's `flushLoop` has setTimeout(0) schedules that need
59
- * explicit advancement even at throttleMs=0, plus intermediate awaits.
60
- *
61
55
  * vi.advanceTimersByTimeAsync isn't implemented under bun's test runner.
62
- * Polyfill via the sync advance + explicit microtask flush so this file
63
- * runs cleanly under both vitest and `bun test`.
64
56
  */
65
57
  async function advanceFakeClock(ms: number): Promise<void> {
66
58
  const viAny = vi as { advanceTimersByTimeAsync?: (ms: number) => Promise<void> }
@@ -91,8 +83,8 @@ describe('stream-reply-handler × real retry × fake bot', () => {
91
83
  })
92
84
 
93
85
  describe('flood-wait retry', () => {
94
- it('retries after retry_after and the message lands', async () => {
95
- bot.faults.next('sendMessage', errors.floodWait(2))
86
+ it('retries after retry_after and the rich message lands', async () => {
87
+ bot.faults.next('sendRichMessage', errors.floodWait(2, 'sendRichMessage'))
96
88
  const state = makeState()
97
89
  const deps = makeDeps(bot)
98
90
 
@@ -109,7 +101,8 @@ describe('stream-reply-handler × real retry × fake bot', () => {
109
101
  expect(result.status).toBe('finalized')
110
102
  expect(result.messageId).toBe(500)
111
103
  expect(bot.state.sent).toHaveLength(1)
112
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(2)
104
+ expect(bot.state.sent[0].rich).toBe(true)
105
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(2)
113
106
  })
114
107
 
115
108
  it('flood-wait on editMessageText is transparent to caller', async () => {
@@ -128,16 +121,12 @@ describe('stream-reply-handler × real retry × fake bot', () => {
128
121
  const r = await pending
129
122
 
130
123
  expect(r.status).toBe('updated')
131
- expect(bot.state.currentText.get(500)).toBe(realMarkdownToHtml('v2'))
124
+ // Raw markdown is the stored body — no HTML render.
125
+ expect(bot.state.currentText.get(500)).toBe('v2')
132
126
  })
133
127
  })
134
128
 
135
129
  describe('editMessageText transient failures (swallowed by retry)', () => {
136
- // These tests use REAL timers because draft-stream's throttling
137
- // sequence across two updates is hard to deterministically pump with
138
- // fake timers (the setTimeout(0) schedule is inside a promise chain
139
- // that re-enters after the first flush completes). Real timers +
140
- // a tiny throttle keeps the test fast and reliable.
141
130
  beforeEach(() => vi.useRealTimers())
142
131
 
143
132
  it('retry returns undefined for "message is not modified"; stream continues', async () => {
@@ -170,17 +159,8 @@ describe('stream-reply-handler × real retry × fake bot', () => {
170
159
  })
171
160
 
172
161
  describe('non-retryable errors (current behaviour: swallowed by draft-stream)', () => {
173
- // The following tests document the current swallow-all contract in
174
- // draft-stream.ts. When a send fails with 403 / thread-not-found / a
175
- // retry-exhausted network error, draft-stream's catch-all at line 182
176
- // logs + continues. The HANDLER then sees getMessageId() == null on
177
- // finalize and throws the "finalized without sending" error.
178
- //
179
- // If draft-stream grows a lastError hook later, these assertions
180
- // should be tightened to match the real underlying error.
181
-
182
162
  it('403 forbidden surfaces as "finalized without sending" on done=true', async () => {
183
- bot.faults.next('sendMessage', errors.forbidden())
163
+ bot.faults.next('sendRichMessage', errors.forbidden('sendRichMessage'))
184
164
  const state = makeState()
185
165
  const deps = makeDeps(bot)
186
166
 
@@ -190,7 +170,7 @@ describe('stream-reply-handler × real retry × fake bot', () => {
190
170
  })
191
171
 
192
172
  it('thread-not-found surfaces as "finalized without sending" on done=true', async () => {
193
- bot.faults.next('sendMessage', errors.threadNotFound())
173
+ bot.faults.next('sendRichMessage', errors.threadNotFound('sendRichMessage'))
194
174
  const state = makeState()
195
175
  const deps = makeDeps(bot)
196
176
 
@@ -203,4 +183,29 @@ describe('stream-reply-handler × real retry × fake bot', () => {
203
183
  ).rejects.toThrowError(/finalized without sending/)
204
184
  })
205
185
  })
186
+
187
+ describe('parse-entities fallback (#657) end to end', () => {
188
+ it('first rich send rejected for bad markdown → retries SAME turn as plain text, one message', async () => {
189
+ bot.faults.next(
190
+ 'sendRichMessage',
191
+ errors.badRequest("can't parse entities: bad markdown", 'sendRichMessage'),
192
+ )
193
+ const state = makeState()
194
+ const deps = makeDeps(bot)
195
+
196
+ const pending = handleStreamReply(
197
+ { chat_id: 'c', text: '**broken _markdown', done: true },
198
+ state,
199
+ deps,
200
+ )
201
+ await settle(0)
202
+ const result = await pending
203
+
204
+ expect(result.status).toBe('finalized')
205
+ // Exactly one message — the plain-text recovery send.
206
+ expect(bot.state.sent).toHaveLength(1)
207
+ expect(bot.state.sent[0].rich).toBeFalsy()
208
+ expect(bot.state.sent[0].text).toBe('**broken _markdown')
209
+ })
210
+ })
206
211
  })