switchroom 0.16.23 → 0.16.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/dist/agent-scheduler/index.js +80 -80
  2. package/dist/auth-broker/index.js +80 -80
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -10
  5. package/dist/cli/notion-write-pretool.mjs +82 -82
  6. package/dist/cli/self-improve-apply-guard-pretool.mjs +6 -0
  7. package/dist/cli/skill-validate-pretool.mjs +2936 -119
  8. package/dist/cli/switchroom.js +671 -430
  9. package/dist/host-control/main.js +156 -156
  10. package/dist/vault/approvals/kernel-server.js +82 -82
  11. package/dist/vault/broker/server.js +83 -83
  12. package/package.json +5 -5
  13. package/telegram-plugin/answer-stream.ts +13 -43
  14. package/telegram-plugin/auth-snapshot-format.ts +27 -30
  15. package/telegram-plugin/auto-fallback-fleet.ts +6 -11
  16. package/telegram-plugin/card-format.ts +28 -25
  17. package/telegram-plugin/credits-watch.ts +5 -10
  18. package/telegram-plugin/dist/bridge/bridge.js +112 -112
  19. package/telegram-plugin/dist/gateway/gateway.js +1855 -2077
  20. package/telegram-plugin/dist/server.js +160 -160
  21. package/telegram-plugin/draft-stream.ts +4 -4
  22. package/telegram-plugin/format.ts +101 -662
  23. package/telegram-plugin/gateway/approval-callback.ts +2 -3
  24. package/telegram-plugin/gateway/approval-card.test.ts +17 -4
  25. package/telegram-plugin/gateway/approval-card.ts +16 -6
  26. package/telegram-plugin/gateway/approvals-commands.ts +18 -24
  27. package/telegram-plugin/gateway/auth-command.ts +74 -74
  28. package/telegram-plugin/gateway/auth-line.ts +5 -15
  29. package/telegram-plugin/gateway/boot-card.ts +20 -22
  30. package/telegram-plugin/gateway/boot-version.ts +3 -2
  31. package/telegram-plugin/gateway/config-approval-handler.test.ts +35 -33
  32. package/telegram-plugin/gateway/config-approval-handler.ts +24 -24
  33. package/telegram-plugin/gateway/config-snapshot.ts +9 -9
  34. package/telegram-plugin/gateway/diff-preview-card.test.ts +8 -8
  35. package/telegram-plugin/gateway/diff-preview-card.ts +2 -5
  36. package/telegram-plugin/gateway/disconnect-flush.ts +0 -4
  37. package/telegram-plugin/gateway/effort-command.ts +17 -17
  38. package/telegram-plugin/gateway/folder-picker-handler.test.ts +8 -2
  39. package/telegram-plugin/gateway/folder-picker-handler.ts +3 -4
  40. package/telegram-plugin/gateway/gateway.ts +751 -624
  41. package/telegram-plugin/gateway/inject-handler.test.ts +15 -13
  42. package/telegram-plugin/gateway/inject-handler.ts +5 -5
  43. package/telegram-plugin/gateway/ipc-protocol.ts +33 -1
  44. package/telegram-plugin/gateway/ipc-server.ts +28 -0
  45. package/telegram-plugin/gateway/linear-activity.ts +16 -14
  46. package/telegram-plugin/gateway/linear-setup.ts +1 -1
  47. package/telegram-plugin/gateway/model-command.ts +25 -25
  48. package/telegram-plugin/gateway/oversize-card-body.ts +6 -7
  49. package/telegram-plugin/gateway/skill-proposal-card.ts +167 -0
  50. package/telegram-plugin/inline-keyboard-callbacks.ts +19 -13
  51. package/telegram-plugin/issues-card.ts +6 -7
  52. package/telegram-plugin/model-unavailable.ts +8 -12
  53. package/telegram-plugin/operator-events-history.ts +1 -1
  54. package/telegram-plugin/operator-events.ts +24 -28
  55. package/telegram-plugin/package.json +1 -1
  56. package/telegram-plugin/pending-work-progress.ts +36 -36
  57. package/telegram-plugin/permission-title.ts +36 -20
  58. package/telegram-plugin/pty-partial-handler.ts +5 -13
  59. package/telegram-plugin/quota-check.ts +5 -5
  60. package/telegram-plugin/quota-watch.ts +13 -18
  61. package/telegram-plugin/recent-outbound-dedup.ts +5 -5
  62. package/telegram-plugin/registry/turns-schema.ts +43 -3
  63. package/telegram-plugin/retry-api-call.ts +15 -7
  64. package/telegram-plugin/rich-send.ts +57 -0
  65. package/telegram-plugin/secret-detect/vault-error.test.ts +6 -6
  66. package/telegram-plugin/secret-detect/vault-error.ts +29 -22
  67. package/telegram-plugin/shared/bot-runtime.ts +23 -6
  68. package/telegram-plugin/silence-poke.ts +26 -69
  69. package/telegram-plugin/slot-banner-driver.ts +9 -6
  70. package/telegram-plugin/slot-banner.ts +5 -8
  71. package/telegram-plugin/steering.ts +0 -4
  72. package/telegram-plugin/stream-controller.ts +59 -62
  73. package/telegram-plugin/stream-reply-handler.ts +40 -97
  74. package/telegram-plugin/subagent-watcher.ts +2 -2
  75. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +5 -2
  76. package/telegram-plugin/tests/answer-stream.test.ts +54 -63
  77. package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
  78. package/telegram-plugin/tests/auth-command-vernacular.test.ts +3 -2
  79. package/telegram-plugin/tests/auth-snapshot-format.test.ts +19 -18
  80. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +15 -13
  81. package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +27 -12
  82. package/telegram-plugin/tests/boot-card-render.test.ts +59 -48
  83. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  84. package/telegram-plugin/tests/bot-api.harness.ts +23 -1
  85. package/telegram-plugin/tests/bot-runtime.test.ts +23 -18
  86. package/telegram-plugin/tests/card-format.test.ts +6 -4
  87. package/telegram-plugin/tests/config-snapshot.test.ts +1 -1
  88. package/telegram-plugin/tests/credits-watch.test.ts +5 -5
  89. package/telegram-plugin/tests/fake-bot-api.ts +58 -4
  90. package/telegram-plugin/tests/finalize-callback.test.ts +11 -9
  91. package/telegram-plugin/tests/foreground-nesting.test.ts +1 -1
  92. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +3 -13
  93. package/telegram-plugin/tests/issues-card.test.ts +15 -12
  94. package/telegram-plugin/tests/linear-agent-activity.test.ts +8 -5
  95. package/telegram-plugin/tests/model-command.test.ts +2 -2
  96. package/telegram-plugin/tests/model-unavailable.test.ts +13 -13
  97. package/telegram-plugin/tests/multi-turn-continuity.test.ts +6 -10
  98. package/telegram-plugin/tests/operator-events.test.ts +7 -9
  99. package/telegram-plugin/tests/pending-work-progress.test.ts +20 -21
  100. package/telegram-plugin/tests/permission-title.test.ts +45 -41
  101. package/telegram-plugin/tests/pty-partial-handler.test.ts +8 -8
  102. package/telegram-plugin/tests/quota-check.test.ts +3 -3
  103. package/telegram-plugin/tests/quota-watch.test.ts +8 -4
  104. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +4 -3
  105. package/telegram-plugin/tests/silence-poke.test.ts +75 -112
  106. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +137 -0
  107. package/telegram-plugin/tests/skill-proposal-card.test.ts +103 -0
  108. package/telegram-plugin/tests/slot-banner-driver.e2e.test.ts +36 -24
  109. package/telegram-plugin/tests/slot-banner.test.ts +9 -6
  110. package/telegram-plugin/tests/status-accent.test.ts +29 -32
  111. package/telegram-plugin/tests/{stream-controller-html-fallback.test.ts → stream-controller-parse-fallback.test.ts} +40 -42
  112. package/telegram-plugin/tests/stream-controller.test.ts +63 -52
  113. package/telegram-plugin/tests/stream-reply-error-paths.test.ts +43 -38
  114. package/telegram-plugin/tests/stream-reply-handler.test.ts +122 -249
  115. package/telegram-plugin/tests/streaming-e2e.test.ts +35 -30
  116. package/telegram-plugin/tests/streaming-orchestration.test.ts +29 -28
  117. package/telegram-plugin/tests/telegram-format.test.ts +120 -1083
  118. package/telegram-plugin/tests/tool-activity-summary.test.ts +144 -145
  119. package/telegram-plugin/tests/welcome-text.test.ts +72 -65
  120. package/telegram-plugin/tests/worker-activity-feed.test.ts +119 -137
  121. package/telegram-plugin/text-voice-scrub.ts +8 -11
  122. package/telegram-plugin/tool-activity-summary.ts +29 -29
  123. package/telegram-plugin/welcome-text.ts +82 -83
  124. package/telegram-plugin/worker-activity-feed.ts +2 -3
  125. package/telegram-plugin/html-sanitize.ts +0 -244
  126. package/telegram-plugin/tests/html-sanitize.test.ts +0 -146
  127. package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -162
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Single-mode stream-reply behaviour (post-#2669).
3
+ *
4
+ * The parse-mode rotation this file used to cover is GONE: there is now
5
+ * exactly ONE rendering mode (raw GFM markdown via `sendRichMessage` /
6
+ * `editMessageText({ markdown })`). The successor contract is:
7
+ *
8
+ * - Every non-`text` format reuses the SAME draft stream for a
9
+ * chat+thread+lane — no finalize-and-recreate dance, because no baked
10
+ * parse_mode can ever go stale.
11
+ * - The only fork is the literal `format:'text'` path (`literalText`),
12
+ * which sends a plain string instead of a rich-markdown wrapper.
13
+ *
14
+ * This file is retained (rather than deleted) to lock in the
15
+ * single-mode-reuse invariant that replaced rotation, so a future
16
+ * regression that reintroduces per-mode stream churn is caught.
17
+ */
18
+
19
+ import { describe, it, expect, beforeEach } from 'vitest'
20
+ import { handleStreamReply, type StreamReplyDeps, type StreamReplyState } from '../stream-reply-handler.js'
21
+ import type { DraftStreamHandle } from '../draft-stream.js'
22
+ import { createFakeBotApi, type FakeBot } from './fake-bot-api.js'
23
+
24
+ function makeState(): StreamReplyState {
25
+ return {
26
+ activeDraftStreams: new Map<string, DraftStreamHandle>(),
27
+ }
28
+ }
29
+
30
+ function makeDeps(bot: FakeBot, overrides?: Partial<StreamReplyDeps>): StreamReplyDeps {
31
+ return {
32
+ bot: bot as unknown as StreamReplyDeps['bot'],
33
+ repairEscapedWhitespace: (t) => t,
34
+ assertAllowedChat: () => {},
35
+ resolveThreadId: (_, explicit) => (explicit != null ? Number(explicit) : undefined),
36
+ disableLinkPreview: true,
37
+ // Anything other than the literal 'text' is the rich-markdown path.
38
+ defaultFormat: 'markdown',
39
+ logStreamingEvent: () => {},
40
+ historyEnabled: false,
41
+ recordOutbound: () => {},
42
+ writeError: () => {},
43
+ throttleMs: 0,
44
+ ...overrides,
45
+ }
46
+ }
47
+
48
+ describe('stream-reply single-mode reuse (#2669)', () => {
49
+ let bot: FakeBot
50
+
51
+ beforeEach(() => {
52
+ bot = createFakeBotApi({ startMessageId: 500 })
53
+ })
54
+
55
+ it('same chat+thread+lane reuses the one stream — no recreate', async () => {
56
+ const state = makeState()
57
+ const deps = makeDeps(bot)
58
+
59
+ await handleStreamReply({ chat_id: 'c', text: 'v1' }, state, deps)
60
+ const streamBefore = state.activeDraftStreams.get('c:_')
61
+ await handleStreamReply({ chat_id: 'c', text: 'v2' }, state, deps)
62
+ const streamAfter = state.activeDraftStreams.get('c:_')
63
+
64
+ // Identity preserved — there is no rotation to churn the stream.
65
+ expect(streamBefore).toBe(streamAfter)
66
+ // First call sent a rich message; second edited it in place.
67
+ expect(bot.state.sent).toHaveLength(1)
68
+ expect(bot.state.sent[0].rich).toBe(true)
69
+ expect(bot.api.editMessageText).toHaveBeenCalled()
70
+ })
71
+
72
+ it('the rich path ships raw GFM markdown unescaped via sendRichMessage', async () => {
73
+ const state = makeState()
74
+ const deps = makeDeps(bot)
75
+
76
+ await handleStreamReply({ chat_id: 'c', text: '**bold** and _italic_' }, state, deps)
77
+
78
+ expect(bot.state.sent).toHaveLength(1)
79
+ expect(bot.state.sent[0].rich).toBe(true)
80
+ // Raw markdown is the wire payload — no HTML, no MarkdownV2 escaping.
81
+ expect(bot.state.sent[0].text).toBe('**bold** and _italic_')
82
+ expect(bot.state.sent[0].parse_mode).toBeUndefined()
83
+ })
84
+
85
+ it('format:"text" takes the literal plain path (no rich wrapper)', async () => {
86
+ const state = makeState()
87
+ const deps = makeDeps(bot)
88
+
89
+ await handleStreamReply({ chat_id: 'c', text: 'plain < text >', format: 'text' }, state, deps)
90
+
91
+ expect(bot.state.sent).toHaveLength(1)
92
+ expect(bot.state.sent[0].rich).toBeFalsy()
93
+ expect(bot.state.sent[0].text).toBe('plain < text >')
94
+ })
95
+
96
+ it('switching format on the same lane does NOT recreate the stream (single mode)', async () => {
97
+ const state = makeState()
98
+ const deps = makeDeps(bot)
99
+
100
+ // First call as the default rich path…
101
+ await handleStreamReply({ chat_id: 'c', text: 'rich first', format: 'markdown' }, state, deps)
102
+ const first = state.activeDraftStreams.get('c:_')
103
+
104
+ // …then a call nominally requesting a different (non-text) format.
105
+ // Pre-#2669 this rotated the stream; now there's nothing to rotate.
106
+ await handleStreamReply({ chat_id: 'c', text: 'rich second', format: 'gfm' }, state, deps)
107
+ const second = state.activeDraftStreams.get('c:_')
108
+
109
+ expect(second).toBe(first)
110
+ // Still a single message — edited in place, not a second send.
111
+ expect(bot.state.sent).toHaveLength(1)
112
+ })
113
+
114
+ it('distinct lanes get independent streams', async () => {
115
+ const state = makeState()
116
+ const deps = makeDeps(bot)
117
+
118
+ await handleStreamReply({ chat_id: 'c', text: 'main', lane: 'main' }, state, deps)
119
+ await handleStreamReply({ chat_id: 'c', text: 'activity', lane: 'activity' }, state, deps)
120
+
121
+ expect(state.activeDraftStreams.size).toBe(2)
122
+ expect(bot.state.sent).toHaveLength(2)
123
+ })
124
+
125
+ it('distinct threads get independent streams', async () => {
126
+ const state = makeState()
127
+ const deps = makeDeps(bot)
128
+
129
+ await handleStreamReply({ chat_id: 'c', text: 'thread-a', message_thread_id: '1' }, state, deps)
130
+ await handleStreamReply({ chat_id: 'c', text: 'thread-b', message_thread_id: '2' }, state, deps)
131
+
132
+ expect(state.activeDraftStreams.size).toBe(2)
133
+ expect(bot.state.sent).toHaveLength(2)
134
+ expect(bot.state.sent[0].message_thread_id).toBe(1)
135
+ expect(bot.state.sent[1].message_thread_id).toBe(2)
136
+ })
137
+ })
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Unit tests for telegram-plugin/gateway/skill-proposal-card.ts (#2670).
3
+ *
4
+ * Pure builders — no SQLite, no gateway. Contract under test:
5
+ * - parseSkillProposalCallback round-trips the keyboard's callback_data.
6
+ * - renderSkillProposalCard escapes HTML and shows lesson + steps.
7
+ * - buildSkillProposalApplyInbound pins source='skill_proposal_apply' +
8
+ * meta fields the bridge keys on.
9
+ */
10
+
11
+ import { describe, it, expect } from 'bun:test'
12
+ import {
13
+ parseSkillProposalCallback,
14
+ skillProposalKeyboard,
15
+ renderSkillProposalCard,
16
+ buildSkillProposalApplyInbound,
17
+ previewSteps,
18
+ SKILL_PROPOSAL_CALLBACK_PREFIX,
19
+ } from '../gateway/skill-proposal-card.js'
20
+
21
+ describe('skill-proposal callback round-trip', () => {
22
+ it('keyboard callback_data parses back to action + id', () => {
23
+ const kb = skillProposalKeyboard('abc-123')
24
+ const [approve, deny] = kb.inline_keyboard[0]!
25
+ expect(parseSkillProposalCallback(approve!.callback_data)).toEqual({
26
+ action: 'approve',
27
+ id: 'abc-123',
28
+ })
29
+ expect(parseSkillProposalCallback(deny!.callback_data)).toEqual({
30
+ action: 'deny',
31
+ id: 'abc-123',
32
+ })
33
+ })
34
+
35
+ it('rejects foreign / malformed callback_data', () => {
36
+ expect(parseSkillProposalCallback('vra:approve:x')).toBeNull()
37
+ expect(parseSkillProposalCallback(`${SKILL_PROPOSAL_CALLBACK_PREFIX}bogus:x`)).toBeNull()
38
+ expect(parseSkillProposalCallback(`${SKILL_PROPOSAL_CALLBACK_PREFIX}approve:`)).toBeNull()
39
+ })
40
+
41
+ it('callback_data fits Telegram 64-byte limit for a UUID id', () => {
42
+ const kb = skillProposalKeyboard('00000000-0000-0000-0000-000000000000')
43
+ for (const row of kb.inline_keyboard) {
44
+ for (const b of row) {
45
+ expect(Buffer.byteLength(b.callback_data, 'utf8')).toBeLessThanOrEqual(64)
46
+ }
47
+ }
48
+ })
49
+ })
50
+
51
+ describe('renderSkillProposalCard', () => {
52
+ it('shows lesson, evidence, and escapes HTML', () => {
53
+ const text = renderSkillProposalCard({
54
+ id: 'x',
55
+ skill_slug: 'deploy-checklist',
56
+ is_new: true,
57
+ lesson: 'Always run <smoke> first',
58
+ evidence: 'seen across 3 sessions',
59
+ skill_md: '---\nname: deploy-checklist\ndescription: d\n---\n\n1. step one\n2. step two',
60
+ })
61
+ expect(text).toContain('New personal skill')
62
+ expect(text).toContain('deploy-checklist')
63
+ expect(text).toContain('&lt;smoke&gt;') // escaped
64
+ expect(text).toContain('seen across 3 sessions')
65
+ expect(text).toContain('step one')
66
+ })
67
+
68
+ it('previewSteps drops frontmatter + headings', () => {
69
+ const steps = previewSteps('---\nname: x\n---\n\n# Title\n1. alpha\n- beta\n')
70
+ expect(steps).toEqual(['alpha', 'beta'])
71
+ })
72
+ })
73
+
74
+ describe('buildSkillProposalApplyInbound', () => {
75
+ it('pins source and meta for a new skill', () => {
76
+ const inb = buildSkillProposalApplyInbound({
77
+ ctx: { agent: 'klanker', chat_id: '12345' },
78
+ proposalId: 'p1',
79
+ skillSlug: 'deploy-checklist',
80
+ isNew: true,
81
+ operatorId: '999',
82
+ nowMs: 1000,
83
+ })
84
+ expect(inb.meta.source).toBe('skill_proposal_apply')
85
+ expect(inb.meta.proposal_id).toBe('p1')
86
+ expect(inb.meta.skill_slug).toBe('deploy-checklist')
87
+ expect(inb.meta.is_new).toBe('true')
88
+ expect(inb.chatId).toBe('12345')
89
+ expect(inb.text).toContain('skill_init_personal')
90
+ })
91
+
92
+ it('points at the edit tool when isNew=false', () => {
93
+ const inb = buildSkillProposalApplyInbound({
94
+ ctx: { agent: 'klanker', chat_id: '12345' },
95
+ proposalId: 'p2',
96
+ skillSlug: 'deploy-checklist',
97
+ isNew: false,
98
+ operatorId: '999',
99
+ })
100
+ expect(inb.text).toContain('skill_edit_personal')
101
+ expect(inb.meta.is_new).toBe('false')
102
+ })
103
+ })
@@ -65,13 +65,14 @@ describe('refreshBanner — transitions from clean state', () => {
65
65
  expect(next).not.toBeNull();
66
66
  expect(next?.slot).toBe('personal');
67
67
  expect(next?.messageId).toBe(100);
68
- // sendMessage + pinChatMessage called in that order.
69
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(1);
68
+ // sendRichMessage + pinChatMessage called in that order (#2669).
69
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1);
70
70
  expect(bot.api.pinChatMessage).toHaveBeenCalledTimes(1);
71
- const sendArgs = bot.api.sendMessage.mock.calls[0];
71
+ const sendArgs = bot.api.sendRichMessage.mock.calls[0];
72
72
  expect(sendArgs[0]).toBe(OWNER);
73
- expect(sendArgs[1]).toContain('clerk');
74
- expect(sendArgs[1]).toContain('personal');
73
+ const md = (sendArgs[1] as { markdown: string }).markdown;
74
+ expect(md).toContain('clerk');
75
+ expect(md).toContain('personal');
75
76
  // Pin call should disable notifications so users don't get pinged.
76
77
  const pinArgs = bot.api.pinChatMessage.mock.calls[0];
77
78
  expect(pinArgs[2]).toMatchObject({ disable_notification: true });
@@ -100,6 +101,7 @@ describe('refreshBanner — transitions from pinned state', () => {
100
101
  prior = seeded;
101
102
  // Reset the spy counts so the under-test transition starts from zero.
102
103
  bot.api.sendMessage.mockClear();
104
+ bot.api.sendRichMessage.mockClear();
103
105
  bot.api.editMessageText.mockClear();
104
106
  bot.api.pinChatMessage.mockClear();
105
107
  bot.api.unpinChatMessage.mockClear();
@@ -132,10 +134,12 @@ describe('refreshBanner — transitions from pinned state', () => {
132
134
  expect(next).toEqual({ messageId: prior.messageId, slot: 'work' });
133
135
  expect(bot.api.editMessageText).toHaveBeenCalledTimes(1);
134
136
  expect(bot.api.sendMessage).not.toHaveBeenCalled();
137
+ expect(bot.api.sendRichMessage).not.toHaveBeenCalled();
135
138
  const editArgs = bot.api.editMessageText.mock.calls[0];
136
139
  expect(editArgs[0]).toBe(OWNER);
137
140
  expect(editArgs[1]).toBe(prior.messageId);
138
- expect(editArgs[2]).toContain('work');
141
+ // #2669: the edit carries the rich { markdown } wrapper, not a string.
142
+ expect((editArgs[2] as { markdown: string }).markdown).toContain('work');
139
143
  });
140
144
 
141
145
  it('return to default slot unpins, clears state', async () => {
@@ -216,7 +220,7 @@ describe('refreshBanner — full lifecycle (sequenced)', () => {
216
220
  expect(state).toBeNull();
217
221
 
218
222
  // Final tally on the chat model.
219
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(1);
223
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1);
220
224
  expect(bot.api.editMessageText).toHaveBeenCalledTimes(1);
221
225
  expect(bot.api.unpinChatMessage).toHaveBeenCalledTimes(1);
222
226
  expect(bot.api.pinChatMessage).toHaveBeenCalledTimes(1);
@@ -241,13 +245,13 @@ describe('refreshBanner — error paths', () => {
241
245
  onError,
242
246
  });
243
247
  expect(next).toBeNull(); // prior state was null, preserved
244
- expect(bot.api.sendMessage).toHaveBeenCalledTimes(1);
248
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1);
245
249
  expect(bot.api.pinChatMessage).toHaveBeenCalledTimes(1);
246
250
  expect(onError).toHaveBeenCalledWith('pin', expect.any(Error));
247
251
  });
248
252
 
249
- it('sendMessage failure surfaces but does not throw', async () => {
250
- bot.faults.next('sendMessage', errors.forbidden('sendMessage'));
253
+ it('sendRichMessage failure surfaces but does not throw', async () => {
254
+ bot.faults.next('sendRichMessage', errors.forbidden('sendRichMessage'));
251
255
  const onError = vi.fn();
252
256
  const next = await refreshBanner({
253
257
  bot,
@@ -311,30 +315,33 @@ describe('refreshBanner — banner content', () => {
311
315
  defaultSlot: 'default',
312
316
  prevState: null,
313
317
  });
314
- const text = bot.api.sendMessage.mock.calls[0][1] as string;
318
+ const rich = bot.api.sendRichMessage.mock.calls[0][1] as { markdown: string };
319
+ const text = rich.markdown;
315
320
  expect(text).toContain('klanker');
316
321
  expect(text).toContain('backup');
317
322
  expect(text).toContain('default');
318
323
  expect(text.toLowerCase()).toMatch(/failover/);
319
324
  });
320
325
 
321
- it('escapes HTML in agent and slot names (no XSS via slot rename)', async () => {
326
+ it('markdown-escapes emphasis specials in agent name; slot names ride in code spans (#2669)', async () => {
322
327
  await refreshBanner({
323
328
  bot,
324
329
  ownerChatId: OWNER,
325
- agentName: '<bad>',
326
- currentSlot: '"hax"',
327
- defaultSlot: '&def',
330
+ agentName: 'a_b*c',
331
+ currentSlot: 'slot_1',
332
+ defaultSlot: 'def_2',
328
333
  prevState: null,
329
334
  });
330
- const text = bot.api.sendMessage.mock.calls[0][1] as string;
331
- expect(text).not.toContain('<bad>');
332
- expect(text).toContain('&lt;bad&gt;');
333
- expect(text).toContain('&quot;hax&quot;');
334
- expect(text).toContain('&amp;def');
335
+ const rich = bot.api.sendRichMessage.mock.calls[0][1] as { markdown: string };
336
+ const text = rich.markdown;
337
+ // The agent name is bolded — its emphasis specials are escaped.
338
+ expect(text).toContain('**a\\_b\\*c**');
339
+ // Slot names ride in `code spans` — literal, never escaped.
340
+ expect(text).toContain('`slot_1`');
341
+ expect(text).toContain('`def_2`');
335
342
  });
336
343
 
337
- it('uses HTML parse_mode + disables link preview', async () => {
344
+ it('sends via the rich path (no parse_mode, no link_preview_options) #2669', async () => {
338
345
  await refreshBanner({
339
346
  bot,
340
347
  ownerChatId: OWNER,
@@ -343,8 +350,13 @@ describe('refreshBanner — banner content', () => {
343
350
  defaultSlot: DEFAULT,
344
351
  prevState: null,
345
352
  });
346
- const opts = bot.api.sendMessage.mock.calls[0][2] as Record<string, unknown>;
347
- expect(opts.parse_mode).toBe('HTML');
348
- expect(opts.link_preview_options).toEqual({ is_disabled: true });
353
+ // The banner ships raw GFM markdown via sendRichMessage — no parse_mode,
354
+ // and sendRichMessage doesn't accept link_preview_options (it's omitted).
355
+ expect(bot.api.sendRichMessage).toHaveBeenCalledTimes(1);
356
+ expect(bot.api.sendMessage).not.toHaveBeenCalled();
357
+ const opts = bot.api.sendRichMessage.mock.calls[0][2] as Record<string, unknown>;
358
+ expect(opts.parse_mode).toBeUndefined();
359
+ expect(opts.link_preview_options).toBeUndefined();
360
+ expect(opts.disable_notification).toBe(true);
349
361
  });
350
362
  });
@@ -58,12 +58,15 @@ describe('decideBannerAction — non-default state', () => {
58
58
  });
59
59
 
60
60
  describe('formatBannerHtml', () => {
61
- it('escapes HTML in agent and slot names', () => {
62
- const text = formatBannerHtml('<bad>', '"hax"', '&def');
63
- expect(text).not.toContain('<bad>');
64
- expect(text).toContain('&lt;bad&gt;');
65
- expect(text).toContain('&quot;hax&quot;');
66
- expect(text).toContain('&amp;def');
61
+ it('markdown-escapes emphasis specials in the agent name (#2669)', () => {
62
+ // The agent name is interpolated into a **bold** run, so an emphasis
63
+ // special in it must be backslash-escaped or it would break the run.
64
+ // Slot names live in `code spans` (literal) and need no escaping.
65
+ const text = formatBannerHtml('a_b*c', 'slot_1', 'def_2');
66
+ expect(text).toContain('**a\\_b\\*c**');
67
+ // Slot names ride in code spans verbatim (literal inside backticks).
68
+ expect(text).toContain('`slot_1`');
69
+ expect(text).toContain('`def_2`');
67
70
  });
68
71
 
69
72
  it('mentions the failover-from default for context', () => {
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Tests for the inline status-accent header feature (issue #320 fallback).
3
3
  *
4
- * Covers both the pure `buildAccentHeader` helper and the integration paths
5
- * through `handleStreamReply` (stream_reply) and the server reply case
6
- * (exercised via the handler directly since server.ts wires it the same way).
4
+ * Post-#2669 the header is GFM markdown (every outbound goes through the
5
+ * rich-message path), and the body ships as raw markdown via
6
+ * `sendRichMessage` / `editMessageText({ markdown })`.
7
7
  */
8
8
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
9
9
  import { buildAccentHeader, handleStreamReply, type StreamReplyDeps, type StreamReplyState } from '../stream-reply-handler.js'
@@ -13,16 +13,16 @@ import { createMockBot, installBotResetHook, microtaskFlush } from './bot-api.ha
13
13
  // ─── buildAccentHeader unit tests ────────────────────────────────────────────
14
14
 
15
15
  describe('buildAccentHeader', () => {
16
- it("'in-progress' returns the blue circle header", () => {
17
- expect(buildAccentHeader('in-progress')).toBe('🔵 <i>In progress…</i>\n\n')
16
+ it("'in-progress' returns the blue circle markdown header", () => {
17
+ expect(buildAccentHeader('in-progress')).toBe('🔵 _In progress…_\n\n')
18
18
  })
19
19
 
20
- it("'done' returns the checkmark header", () => {
21
- expect(buildAccentHeader('done')).toBe('✅ <b>Done</b>\n\n')
20
+ it("'done' returns the checkmark markdown header", () => {
21
+ expect(buildAccentHeader('done')).toBe('✅ **Done**\n\n')
22
22
  })
23
23
 
24
- it("'issue' returns the warning header", () => {
25
- expect(buildAccentHeader('issue')).toBe('⚠️ <b>Issue</b>\n\n')
24
+ it("'issue' returns the warning markdown header", () => {
25
+ expect(buildAccentHeader('issue')).toBe('⚠️ **Issue**\n\n')
26
26
  })
27
27
 
28
28
  it('undefined returns empty string (no header)', () => {
@@ -41,7 +41,6 @@ describe('buildAccentHeader', () => {
41
41
  function makeState(): StreamReplyState {
42
42
  return {
43
43
  activeDraftStreams: new Map<string, DraftStreamHandle>(),
44
- activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
45
44
  }
46
45
  }
47
46
 
@@ -51,13 +50,11 @@ function makeDeps(
51
50
  ): StreamReplyDeps {
52
51
  return {
53
52
  bot,
54
- markdownToHtml: (t) => `<b>${t}</b>`,
55
- escapeMarkdownV2: (t) => `\\${t}\\`,
56
53
  repairEscapedWhitespace: (t) => t,
57
54
  assertAllowedChat: () => {},
58
55
  resolveThreadId: (_, explicit) => (explicit != null ? Number(explicit) : undefined),
59
56
  disableLinkPreview: true,
60
- defaultFormat: 'html',
57
+ defaultFormat: 'markdown',
61
58
  logStreamingEvent: () => {},
62
59
  historyEnabled: false,
63
60
  recordOutbound: () => {},
@@ -67,6 +64,13 @@ function makeDeps(
67
64
  }
68
65
  }
69
66
 
67
+ function sentMarkdown(bot: ReturnType<typeof createMockBot>, i = 0): string {
68
+ return (bot.api.sendRichMessage.mock.calls[i][1] as { markdown: string }).markdown
69
+ }
70
+ function editedMarkdown(bot: ReturnType<typeof createMockBot>, i = 0): string {
71
+ return (bot.api.editMessageText.mock.calls[i][2] as { markdown: string }).markdown
72
+ }
73
+
70
74
  describe('handleStreamReply accent integration', () => {
71
75
  const bot = createMockBot()
72
76
  installBotResetHook(bot)
@@ -74,7 +78,7 @@ describe('handleStreamReply accent integration', () => {
74
78
  beforeEach(() => vi.useFakeTimers())
75
79
  afterEach(() => vi.useRealTimers())
76
80
 
77
- it("accent='in-progress' prepends the blue-circle header before the body", async () => {
81
+ it("accent='in-progress' prepends the blue-circle markdown header before the body", async () => {
78
82
  const state = makeState()
79
83
  const deps = makeDeps(bot)
80
84
 
@@ -86,12 +90,12 @@ describe('handleStreamReply accent integration', () => {
86
90
  await microtaskFlush()
87
91
  await pending
88
92
 
89
- const sent = bot.api.sendMessage.mock.calls[0][1] as string
90
- expect(sent).toMatch(/^🔵 <i>In progress…<\/i>\n\n/)
91
- expect(sent).toBe('🔵 <i>In progress…</i>\n\n<b>Still working...</b>')
93
+ const sent = sentMarkdown(bot)
94
+ expect(sent).toMatch(/^🔵 _In progress…_\n\n/)
95
+ expect(sent).toBe('🔵 _In progress…_\n\nStill working...')
92
96
  })
93
97
 
94
- it("accent='done' prepends the checkmark header before the body", async () => {
98
+ it("accent='done' prepends the checkmark markdown header before the body", async () => {
95
99
  const state = makeState()
96
100
  const deps = makeDeps(bot)
97
101
 
@@ -103,11 +107,10 @@ describe('handleStreamReply accent integration', () => {
103
107
  await microtaskFlush()
104
108
  await pending
105
109
 
106
- const sent = bot.api.sendMessage.mock.calls[0][1] as string
107
- expect(sent).toBe('✅ <b>Done</b>\n\n<b>Task complete.</b>')
110
+ expect(sentMarkdown(bot)).toBe('✅ **Done**\n\nTask complete.')
108
111
  })
109
112
 
110
- it("accent='issue' prepends the warning header before the body", async () => {
113
+ it("accent='issue' prepends the warning markdown header before the body", async () => {
111
114
  const state = makeState()
112
115
  const deps = makeDeps(bot)
113
116
 
@@ -119,11 +122,10 @@ describe('handleStreamReply accent integration', () => {
119
122
  await microtaskFlush()
120
123
  await pending
121
124
 
122
- const sent = bot.api.sendMessage.mock.calls[0][1] as string
123
- expect(sent).toBe('⚠️ <b>Issue</b>\n\n<b>Blocked on X.</b>')
125
+ expect(sentMarkdown(bot)).toBe('⚠️ **Issue**\n\nBlocked on X.')
124
126
  })
125
127
 
126
- it('no accent — output is unchanged from today (regression guard)', async () => {
128
+ it('no accent — body ships as raw markdown unchanged (regression guard)', async () => {
127
129
  const state = makeState()
128
130
  const deps = makeDeps(bot)
129
131
 
@@ -135,8 +137,7 @@ describe('handleStreamReply accent integration', () => {
135
137
  await microtaskFlush()
136
138
  await pending
137
139
 
138
- const sent = bot.api.sendMessage.mock.calls[0][1] as string
139
- expect(sent).toBe('<b>Hello world</b>')
140
+ expect(sentMarkdown(bot)).toBe('Hello world')
140
141
  })
141
142
 
142
143
  it('invalid accent is silently ignored — output equals no-accent path', async () => {
@@ -151,15 +152,13 @@ describe('handleStreamReply accent integration', () => {
151
152
  await microtaskFlush()
152
153
  await pending
153
154
 
154
- const sent = bot.api.sendMessage.mock.calls[0][1] as string
155
- expect(sent).toBe('<b>Hello world</b>')
155
+ expect(sentMarkdown(bot)).toBe('Hello world')
156
156
  })
157
157
 
158
158
  it('accent header is included on every call that passes it (full-text replace model)', async () => {
159
159
  const state = makeState()
160
160
  const deps = makeDeps(bot)
161
161
 
162
- // First call
163
162
  const p1 = handleStreamReply(
164
163
  { chat_id: '1', text: 'Part one', accent: 'in-progress' },
165
164
  state,
@@ -168,7 +167,6 @@ describe('handleStreamReply accent integration', () => {
168
167
  await microtaskFlush()
169
168
  await p1
170
169
 
171
- // Second call — same turn, same accent
172
170
  vi.advanceTimersByTime(1000)
173
171
  const p2 = handleStreamReply(
174
172
  { chat_id: '1', text: 'Part one Part two', accent: 'in-progress' },
@@ -178,7 +176,6 @@ describe('handleStreamReply accent integration', () => {
178
176
  await microtaskFlush()
179
177
  await p2
180
178
 
181
- const edited = bot.api.editMessageText.mock.calls[0][2] as string
182
- expect(edited).toBe('🔵 <i>In progress…</i>\n\n<b>Part one Part two</b>')
179
+ expect(editedMarkdown(bot)).toBe('🔵 _In progress…_\n\nPart one Part two')
183
180
  })
184
181
  })