switchroom 0.16.22 → 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 (118) hide show
  1. package/dist/cli/self-improve-apply-guard-pretool.mjs +6 -0
  2. package/dist/cli/skill-validate-pretool.mjs +2865 -48
  3. package/dist/cli/switchroom.js +338 -92
  4. package/dist/host-control/main.js +1 -1
  5. package/package.json +5 -5
  6. package/telegram-plugin/answer-stream.ts +13 -43
  7. package/telegram-plugin/auth-snapshot-format.ts +27 -30
  8. package/telegram-plugin/auto-fallback-fleet.ts +6 -11
  9. package/telegram-plugin/card-format.ts +28 -25
  10. package/telegram-plugin/credits-watch.ts +5 -10
  11. package/telegram-plugin/dist/gateway/gateway.js +1670 -1892
  12. package/telegram-plugin/draft-stream.ts +4 -4
  13. package/telegram-plugin/format.ts +101 -662
  14. package/telegram-plugin/gateway/approval-callback.ts +2 -3
  15. package/telegram-plugin/gateway/approval-card.test.ts +17 -4
  16. package/telegram-plugin/gateway/approval-card.ts +16 -6
  17. package/telegram-plugin/gateway/approvals-commands.ts +18 -24
  18. package/telegram-plugin/gateway/auth-command.ts +74 -74
  19. package/telegram-plugin/gateway/auth-line.ts +5 -15
  20. package/telegram-plugin/gateway/boot-card.ts +20 -22
  21. package/telegram-plugin/gateway/boot-version.ts +3 -2
  22. package/telegram-plugin/gateway/config-approval-handler.test.ts +35 -33
  23. package/telegram-plugin/gateway/config-approval-handler.ts +24 -24
  24. package/telegram-plugin/gateway/config-snapshot.ts +9 -9
  25. package/telegram-plugin/gateway/diff-preview-card.test.ts +8 -8
  26. package/telegram-plugin/gateway/diff-preview-card.ts +2 -5
  27. package/telegram-plugin/gateway/disconnect-flush.ts +0 -4
  28. package/telegram-plugin/gateway/effort-command.ts +17 -17
  29. package/telegram-plugin/gateway/folder-picker-handler.test.ts +8 -2
  30. package/telegram-plugin/gateway/folder-picker-handler.ts +3 -4
  31. package/telegram-plugin/gateway/gateway.ts +751 -624
  32. package/telegram-plugin/gateway/inject-handler.test.ts +15 -13
  33. package/telegram-plugin/gateway/inject-handler.ts +5 -5
  34. package/telegram-plugin/gateway/ipc-protocol.ts +33 -1
  35. package/telegram-plugin/gateway/ipc-server.ts +28 -0
  36. package/telegram-plugin/gateway/linear-activity.ts +16 -14
  37. package/telegram-plugin/gateway/linear-setup.ts +1 -1
  38. package/telegram-plugin/gateway/model-command.ts +25 -25
  39. package/telegram-plugin/gateway/oversize-card-body.ts +6 -7
  40. package/telegram-plugin/gateway/skill-proposal-card.ts +167 -0
  41. package/telegram-plugin/inline-keyboard-callbacks.ts +19 -13
  42. package/telegram-plugin/issues-card.ts +6 -7
  43. package/telegram-plugin/model-unavailable.ts +8 -12
  44. package/telegram-plugin/operator-events-history.ts +1 -1
  45. package/telegram-plugin/operator-events.ts +24 -28
  46. package/telegram-plugin/package.json +1 -1
  47. package/telegram-plugin/pending-work-progress.ts +36 -36
  48. package/telegram-plugin/permission-title.ts +36 -20
  49. package/telegram-plugin/pty-partial-handler.ts +5 -13
  50. package/telegram-plugin/quota-check.ts +5 -5
  51. package/telegram-plugin/quota-watch.ts +13 -18
  52. package/telegram-plugin/recent-outbound-dedup.ts +5 -5
  53. package/telegram-plugin/registry/turns-schema.ts +44 -2
  54. package/telegram-plugin/retry-api-call.ts +15 -7
  55. package/telegram-plugin/rich-send.ts +57 -0
  56. package/telegram-plugin/secret-detect/vault-error.test.ts +6 -6
  57. package/telegram-plugin/secret-detect/vault-error.ts +29 -22
  58. package/telegram-plugin/shared/bot-runtime.ts +23 -6
  59. package/telegram-plugin/silence-poke.ts +26 -69
  60. package/telegram-plugin/slot-banner-driver.ts +9 -6
  61. package/telegram-plugin/slot-banner.ts +5 -8
  62. package/telegram-plugin/steering.ts +0 -4
  63. package/telegram-plugin/stream-controller.ts +59 -62
  64. package/telegram-plugin/stream-reply-handler.ts +40 -97
  65. package/telegram-plugin/subagent-watcher.ts +2 -2
  66. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +5 -2
  67. package/telegram-plugin/tests/answer-stream.test.ts +54 -63
  68. package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
  69. package/telegram-plugin/tests/auth-command-vernacular.test.ts +3 -2
  70. package/telegram-plugin/tests/auth-snapshot-format.test.ts +19 -18
  71. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +15 -13
  72. package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +27 -12
  73. package/telegram-plugin/tests/boot-card-render.test.ts +59 -48
  74. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  75. package/telegram-plugin/tests/bot-api.harness.ts +23 -1
  76. package/telegram-plugin/tests/bot-runtime.test.ts +23 -18
  77. package/telegram-plugin/tests/card-format.test.ts +6 -4
  78. package/telegram-plugin/tests/config-snapshot.test.ts +1 -1
  79. package/telegram-plugin/tests/credits-watch.test.ts +5 -5
  80. package/telegram-plugin/tests/fake-bot-api.ts +58 -4
  81. package/telegram-plugin/tests/finalize-callback.test.ts +11 -9
  82. package/telegram-plugin/tests/foreground-nesting.test.ts +1 -1
  83. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +3 -13
  84. package/telegram-plugin/tests/issues-card.test.ts +15 -12
  85. package/telegram-plugin/tests/linear-agent-activity.test.ts +8 -5
  86. package/telegram-plugin/tests/model-command.test.ts +2 -2
  87. package/telegram-plugin/tests/model-unavailable.test.ts +13 -13
  88. package/telegram-plugin/tests/multi-turn-continuity.test.ts +6 -10
  89. package/telegram-plugin/tests/operator-events.test.ts +7 -9
  90. package/telegram-plugin/tests/pending-work-progress.test.ts +20 -21
  91. package/telegram-plugin/tests/permission-title.test.ts +45 -41
  92. package/telegram-plugin/tests/pty-partial-handler.test.ts +8 -8
  93. package/telegram-plugin/tests/quota-check.test.ts +3 -3
  94. package/telegram-plugin/tests/quota-watch.test.ts +8 -4
  95. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +4 -3
  96. package/telegram-plugin/tests/silence-poke.test.ts +75 -112
  97. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +137 -0
  98. package/telegram-plugin/tests/skill-proposal-card.test.ts +103 -0
  99. package/telegram-plugin/tests/slot-banner-driver.e2e.test.ts +36 -24
  100. package/telegram-plugin/tests/slot-banner.test.ts +9 -6
  101. package/telegram-plugin/tests/status-accent.test.ts +29 -32
  102. package/telegram-plugin/tests/{stream-controller-html-fallback.test.ts → stream-controller-parse-fallback.test.ts} +40 -42
  103. package/telegram-plugin/tests/stream-controller.test.ts +63 -52
  104. package/telegram-plugin/tests/stream-reply-error-paths.test.ts +43 -38
  105. package/telegram-plugin/tests/stream-reply-handler.test.ts +122 -249
  106. package/telegram-plugin/tests/streaming-e2e.test.ts +35 -30
  107. package/telegram-plugin/tests/streaming-orchestration.test.ts +29 -28
  108. package/telegram-plugin/tests/telegram-format.test.ts +120 -1083
  109. package/telegram-plugin/tests/tool-activity-summary.test.ts +144 -145
  110. package/telegram-plugin/tests/welcome-text.test.ts +72 -65
  111. package/telegram-plugin/tests/worker-activity-feed.test.ts +119 -137
  112. package/telegram-plugin/text-voice-scrub.ts +8 -11
  113. package/telegram-plugin/tool-activity-summary.ts +29 -29
  114. package/telegram-plugin/welcome-text.ts +82 -83
  115. package/telegram-plugin/worker-activity-feed.ts +2 -3
  116. package/telegram-plugin/html-sanitize.ts +0 -244
  117. package/telegram-plugin/tests/html-sanitize.test.ts +0 -146
  118. package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -162
@@ -62,10 +62,9 @@ function makeDeps(agentName: string | null) {
62
62
  ['chat1:thr1:r1', { isFinal: () => false, finalize: finalizeA }],
63
63
  ['chat2:thr2:r2', { isFinal: () => true, finalize: finalizeB }],
64
64
  ])
65
- const activeDraftParseModes = new Map<string, 'HTML' | 'MarkdownV2' | undefined>([
66
- ['chat1:thr1:r1', 'HTML'],
67
- ['chat2:thr2:r2', undefined],
68
- ])
65
+ // #2669: the activeDraftParseModes map was retired with parse-mode rotation
66
+ // (single rich-markdown path now), so the disconnect-flush deps no longer
67
+ // carry or clear it.
69
68
 
70
69
  return {
71
70
  spies: { setDoneA, setDoneB, finalizeA, finalizeB, clearActiveReactions, disposeProgressDriver, log },
@@ -76,7 +75,6 @@ function makeDeps(agentName: string | null) {
76
75
  activeTurnStartedAt,
77
76
  claudeBusyKeys,
78
77
  activeDraftStreams,
79
- activeDraftParseModes,
80
78
  clearActiveReactions,
81
79
  disposeProgressDriver,
82
80
  log,
@@ -103,7 +101,6 @@ describe('flushOnAgentDisconnect — anonymous clients (the regression scenario)
103
101
  expect(deps.activeReactionMsgIds.size).toBe(2)
104
102
  expect(deps.activeTurnStartedAt.size).toBe(2)
105
103
  expect(deps.activeDraftStreams.size).toBe(2)
106
- expect(deps.activeDraftParseModes.size).toBe(2)
107
104
 
108
105
  // But it should log so the operator can correlate the no-op decision.
109
106
  expect(spies.log).toHaveBeenCalledTimes(1)
@@ -150,7 +147,6 @@ describe('flushOnAgentDisconnect — registered agent disconnects (existing beha
150
147
  expect(spies.finalizeB).not.toHaveBeenCalled()
151
148
  // Both stream maps cleared regardless.
152
149
  expect(deps.activeDraftStreams.size).toBe(0)
153
- expect(deps.activeDraftParseModes.size).toBe(0)
154
150
  })
155
151
  })
156
152
 
@@ -180,7 +176,6 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
180
176
  activeTurnStartedAt: new Map<string, number>([['ghost:thr:msg', 100]]),
181
177
  claudeBusyKeys: new Set<string>(['ghost:thr:msg']),
182
178
  activeDraftStreams: new Map<string, FakeStream>(),
183
- activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
184
179
  clearActiveReactions,
185
180
  disposeProgressDriver,
186
181
  onDanglingTurnsSwept,
@@ -238,7 +233,6 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
238
233
  activeTurnStartedAt: new Map<string, number>([['real-turn:thr:msg', 100]]),
239
234
  claudeBusyKeys: new Set<string>(['real-turn:thr:msg']),
240
235
  activeDraftStreams: new Map<string, FakeStream>(),
241
- activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
242
236
  clearActiveReactions: vi.fn(),
243
237
  disposeProgressDriver: vi.fn(),
244
238
  onDanglingTurnsSwept,
@@ -273,7 +267,6 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
273
267
  // handleInbound's fresh-turn branch.
274
268
  claudeBusyKeys: new Set<string>(['cron-only-key:_']),
275
269
  activeDraftStreams: new Map<string, FakeStream>(),
276
- activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
277
270
  clearActiveReactions: vi.fn(),
278
271
  disposeProgressDriver: vi.fn(),
279
272
  onDanglingTurnsSwept,
@@ -306,7 +299,6 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
306
299
  activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
307
300
  activeTurnStartedAt: new Map<string, number>(),
308
301
  activeDraftStreams: new Map<string, FakeStream>(),
309
- activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
310
302
  clearActiveReactions: vi.fn(),
311
303
  disposeProgressDriver: vi.fn(),
312
304
  }
@@ -342,7 +334,6 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
342
334
  activeTurnStartedAt: new Map<string, number>(),
343
335
  claudeBusyKeys: new Set<string>(),
344
336
  activeDraftStreams: new Map<string, FakeStream>(),
345
- activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
346
337
  clearActiveReactions: vi.fn(),
347
338
  disposeProgressDriver: vi.fn(),
348
339
  log,
@@ -362,7 +353,6 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
362
353
  activeTurnStartedAt: new Map<string, number>([['ghost:thr:msg', 100]]),
363
354
  claudeBusyKeys: new Set<string>(['ghost:thr:msg']),
364
355
  activeDraftStreams: new Map<string, FakeStream>(),
365
- activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
366
356
  clearActiveReactions: vi.fn(),
367
357
  disposeProgressDriver: vi.fn(),
368
358
  // onDanglingTurnsSwept intentionally omitted.
@@ -40,7 +40,7 @@ describe("renderIssuesCard", () => {
40
40
  makeEvent({ fingerprint: "a::3", code: "3", severity: "info" }),
41
41
  ];
42
42
  const out = renderIssuesCard({ agentName: "klanker", events });
43
- expect(out).toMatch(/^🚨 <b>klanker<\/b>/);
43
+ expect(out).toMatch(/^🚨 \*\*klanker\*\*/);
44
44
  expect(out).toContain("3 issues");
45
45
  });
46
46
 
@@ -54,7 +54,7 @@ describe("renderIssuesCard", () => {
54
54
  }),
55
55
  ];
56
56
  const out = renderIssuesCard({ agentName: "kengpt", events });
57
- expect(out).toContain("→ <i>switchroom vault unlock</i>");
57
+ expect(out).toContain("→ _switchroom vault unlock_");
58
58
  // Summary still rendered above the remediation.
59
59
  expect(out).toContain("Morning brief skipped");
60
60
  });
@@ -66,7 +66,7 @@ describe("renderIssuesCard", () => {
66
66
  }),
67
67
  ];
68
68
  const out = renderIssuesCard({ agentName: "kengpt", events });
69
- expect(out).toMatch(/→ <i>run.*auth heal/);
69
+ expect(out).toMatch(/→ _run.*auth heal/);
70
70
  });
71
71
 
72
72
  it("ignores multi-line detail (likely stderr tail, not remediation)", () => {
@@ -98,13 +98,12 @@ describe("renderIssuesCard", () => {
98
98
  expect((out ?? "").split("\n").every((line) => line.length < 600)).toBe(true);
99
99
  });
100
100
 
101
- it("escapes HTML in remediation hints", () => {
101
+ it("passes < > literally in remediation hints (markdown, #2669)", () => {
102
102
  const events = [
103
103
  makeEvent({ detail: "Fix: <script>alert('xss')</script>" }),
104
104
  ];
105
105
  const out = renderIssuesCard({ agentName: "kengpt", events });
106
- expect(out).not.toContain("<script>");
107
- expect(out).toContain("&lt;script&gt;");
106
+ expect(out).toContain("<script>alert('xss')</script>");
108
107
  });
109
108
  });
110
109
 
@@ -158,7 +157,7 @@ describe("renderIssuesCard", () => {
158
157
  expect(out).toContain("+10 more not shown");
159
158
  });
160
159
 
161
- it("HTML-escapes user-supplied fields", () => {
160
+ it("passes < > & literally in user-supplied fields (markdown, #2669)", () => {
162
161
  const events = [
163
162
  makeEvent({
164
163
  agent: "<script>",
@@ -169,10 +168,12 @@ describe("renderIssuesCard", () => {
169
168
  }),
170
169
  ];
171
170
  const out = renderIssuesCard({ agentName: "<script>", events });
172
- expect(out).not.toContain("<script>");
173
- expect(out).toContain("&lt;script&gt;");
174
- expect(out).toContain("&lt;x&gt;::&lt;y&gt;");
175
- expect(out).toContain("&lt;dangerous &amp; stuff&gt;");
171
+ // < > & are literal in rich markdown — no HTML entities. The fingerprint
172
+ // rides in a `code span` (also literal) and the summary is escaped only
173
+ // for emphasis specials.
174
+ expect(out).toContain("<script>");
175
+ expect(out).toContain("`<x>::<y>`");
176
+ expect(out).toContain("<dangerous & stuff>");
176
177
  });
177
178
  });
178
179
 
@@ -233,7 +234,9 @@ describe("createIssuesCardHandle", () => {
233
234
  });
234
235
  await handle.refresh([makeEvent({})]);
235
236
  expect(bot.sent).toHaveLength(1);
236
- expect(bot.sent[0].opts?.parse_mode).toBe("HTML");
237
+ // #2669: the card body is raw GFM markdown sent through the rich path
238
+ // (the gateway wires sendMessage → sendRichMessage). No parse_mode.
239
+ expect(bot.sent[0].opts?.parse_mode).toBeUndefined();
237
240
  expect(handle.messageId()).toBe(1000);
238
241
  });
239
242
 
@@ -247,7 +247,7 @@ describe('emitLinearAgentActivity — operator alert when auth is unrecoverable
247
247
  it('buildLinearAuthDeadMessage: no_bundle names the missing oauth key + re-auth command', () => {
248
248
  const msg = buildLinearAuthDeadMessage('clerk', 'no_bundle')
249
249
  expect(msg).toMatch(/Linear auth needs you/)
250
- expect(msg).toContain('<code>linear/clerk/oauth</code>')
250
+ expect(msg).toContain('`linear/clerk/oauth`')
251
251
  expect(msg).toContain('switchroom linear-agent setup --agent clerk')
252
252
  })
253
253
 
@@ -257,10 +257,13 @@ describe('emitLinearAgentActivity — operator alert when auth is unrecoverable
257
257
  expect(msg).not.toContain('/oauth</code> is missing')
258
258
  })
259
259
 
260
- it('buildLinearAuthDeadMessage: HTML-escapes a hostile agent slug', () => {
261
- const msg = buildLinearAuthDeadMessage('a<b>&c', 'no_bundle')
262
- expect(msg).toContain('a&lt;b&gt;&amp;c')
263
- expect(msg).not.toContain('a<b>&c')
260
+ it('buildLinearAuthDeadMessage: markdown-escapes a hostile agent slug in prose (#2669)', () => {
261
+ // < > & are literal in rich markdown; an emphasis special is escaped so it
262
+ // can't break out of the **…** bold run.
263
+ const msg = buildLinearAuthDeadMessage('a_b*c', 'no_bundle')
264
+ expect(msg).toContain('**a\\_b\\*c**')
265
+ // Inside the `code span`, the slug is literal (no escaping).
266
+ expect(msg).toContain('`linear/a_b*c/oauth`')
264
267
  })
265
268
 
266
269
  it('successful auto-refresh does NOT page the operator', async () => {
@@ -172,7 +172,7 @@ describe("handleModelCommand — show / help never inject (picker-wedge guard)",
172
172
  const { deps, calls } = makeDeps({ getConfiguredModel: () => null });
173
173
  const reply = await handleModelCommand({ kind: "show" }, deps);
174
174
  expect(calls.length).toBe(0);
175
- expect(reply.text).toContain("<code>default</code>");
175
+ expect(reply.text).toContain("`default`");
176
176
  });
177
177
 
178
178
  it("help never injects", async () => {
@@ -445,7 +445,7 @@ describe("buildModelMenu", () => {
445
445
  const { deps, calls } = makeMenuDeps();
446
446
  const menu = await buildModelMenu(deps);
447
447
  expect(calls.discover).toBe(1);
448
- expect(menu.text).toContain("<b>Sonnet</b>");
448
+ expect(menu.text).toContain("**Sonnet**");
449
449
  expect(menu.text).toContain("29% / 5h · 33% / 7d");
450
450
  expect(menu.keyboard).toBeDefined();
451
451
  // 3 option rows + refresh row
@@ -288,13 +288,13 @@ describe('formatModelUnavailableCard — actionable card', () => {
288
288
  { now: NOW },
289
289
  )
290
290
  expect(card).toMatchInlineSnapshot(`
291
- "⚠️ <b>Model unavailable</b> on agent <b>gymbro</b>
291
+ "⚠️ **Model unavailable** on agent **gymbro**
292
292
  Reason: quota exhausted (resets in 5h)
293
293
 
294
- <b>What to try</b>
295
- • <code>/auth use &lt;label&gt;</code> — switch the fleet to a healthy account
296
- • <code>/auth add</code> — attach another subscription
297
- • <code>/usage</code> — show quota breakdown"
294
+ **What to try**
295
+ • \`/auth use <label>\` — switch the fleet to a healthy account
296
+ • \`/auth add\` — attach another subscription
297
+ • \`/usage\` — show quota breakdown"
298
298
  `)
299
299
  })
300
300
 
@@ -330,9 +330,9 @@ describe('formatModelUnavailableCard — actionable card', () => {
330
330
 
331
331
  it('default (no autoFallback) variant includes the actionable suggestions', () => {
332
332
  const card = formatModelUnavailableCard(detection('quota_exhausted'), 'gymbro', { now: NOW })
333
- expect(card).toContain('<code>/auth use')
334
- expect(card).toContain('<code>/auth add</code>')
335
- expect(card).toContain('<code>/usage</code>')
333
+ expect(card).toContain('`/auth use')
334
+ expect(card).toContain('`/auth add`')
335
+ expect(card).toContain('`/usage`')
336
336
  // Regression — `/authfallback` is no longer a verb (post-RFC-H);
337
337
  // pre-fix the card lied by suggesting it.
338
338
  expect(card).not.toContain('/authfallback')
@@ -343,17 +343,17 @@ describe('formatModelUnavailableCard — actionable card', () => {
343
343
  slot: 'pro-1',
344
344
  now: NOW,
345
345
  })
346
- expect(card).toContain('slot <b>pro-1</b>')
346
+ expect(card).toContain('slot **pro-1**')
347
347
  })
348
348
 
349
- it('escapes HTML in agent and slot names', () => {
349
+ it('passes < > literally in agent and slot names (markdown, #2669)', () => {
350
+ // < > " are literal in rich markdown; emphasis specials are escaped.
350
351
  const card = formatModelUnavailableCard(detection('overload'), '<evil>', {
351
352
  slot: '"injected"',
352
353
  now: NOW,
353
354
  })
354
- expect(card).toContain('&lt;evil&gt;')
355
- expect(card).toContain('&quot;injected&quot;')
356
- expect(card).not.toContain('<evil>')
355
+ expect(card).toContain('<evil>')
356
+ expect(card).toContain('"injected"')
357
357
  })
358
358
  })
359
359
 
@@ -2,9 +2,10 @@
2
2
  * Integration tests for multi-turn state isolation.
3
3
  *
4
4
  * Verifies that state carried across turns in the same chat+thread
5
- * (activeDraftStreams, activeDraftParseModes, suppressPtyPreview)
6
- * resets cleanly when a new turn starts — i.e. the previous turn's
7
- * stream handle is finalized/discarded before the next begins.
5
+ * (activeDraftStreams, suppressPtyPreview) resets cleanly when a new turn
6
+ * starts — i.e. the previous turn's stream handle is finalized/discarded
7
+ * before the next begins. (The activeDraftParseModes map was retired with
8
+ * parse-mode rotation — there is one rich-markdown path now, #2669.)
8
9
  *
9
10
  * This is one of the "per-chat continuity" gaps the plan called out:
10
11
  * previously tested only through the full server.ts orchestration in
@@ -14,13 +15,11 @@
14
15
  import { describe, it, expect, beforeEach } from 'vitest'
15
16
  import { handleStreamReply, type StreamReplyDeps, type StreamReplyState } from '../stream-reply-handler.js'
16
17
  import type { DraftStreamHandle } from '../draft-stream.js'
17
- import { markdownToHtml as realMarkdownToHtml } from '../format.js'
18
18
  import { createFakeBotApi, type FakeBot } from './fake-bot-api.js'
19
19
 
20
20
  function makeState(): StreamReplyState {
21
21
  return {
22
22
  activeDraftStreams: new Map<string, DraftStreamHandle>(),
23
- activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
24
23
  suppressPtyPreview: new Set<string>(),
25
24
  }
26
25
  }
@@ -28,13 +27,11 @@ function makeState(): StreamReplyState {
28
27
  function makeDeps(bot: FakeBot, overrides?: Partial<StreamReplyDeps>): StreamReplyDeps {
29
28
  return {
30
29
  bot: bot as unknown as StreamReplyDeps['bot'],
31
- markdownToHtml: (t) => realMarkdownToHtml(t),
32
- escapeMarkdownV2: (t) => t,
33
30
  repairEscapedWhitespace: (t) => t,
34
31
  assertAllowedChat: () => {},
35
32
  resolveThreadId: (_, explicit) => (explicit != null ? Number(explicit) : undefined),
36
33
  disableLinkPreview: true,
37
- defaultFormat: 'html',
34
+ defaultFormat: 'markdown',
38
35
  logStreamingEvent: () => {},
39
36
  historyEnabled: false,
40
37
  recordOutbound: () => {},
@@ -51,13 +48,12 @@ describe('multi-turn continuity', () => {
51
48
  bot = createFakeBotApi({ startMessageId: 500 })
52
49
  })
53
50
 
54
- it('done=true clears the stream entry and parse-mode map', async () => {
51
+ it('done=true clears the stream entry', async () => {
55
52
  const state = makeState()
56
53
  const deps = makeDeps(bot)
57
54
 
58
55
  await handleStreamReply({ chat_id: 'c', text: 'turn-1', done: true }, state, deps)
59
56
  expect(state.activeDraftStreams.has('c:_')).toBe(false)
60
- expect(state.activeDraftParseModes.has('c:_')).toBe(false)
61
57
  })
62
58
 
63
59
  it('two sequential turns on the same chat each get their own message', async () => {
@@ -158,7 +158,7 @@ describe('renderOperatorEvent — credentials-expired', () => {
158
158
  it('produces reauth button', () => {
159
159
  const { text, keyboard } = renderOperatorEvent(makeEvent('credentials-expired'))
160
160
  expect(text).toContain('expired')
161
- expect(text).toContain('<b>gymbro</b>')
161
+ expect(text).toContain('**gymbro**')
162
162
  expect(keyboard.inline_keyboard.flat().some(b => b.callback_data?.includes('reauth'))).toBe(true)
163
163
  })
164
164
 
@@ -193,7 +193,7 @@ describe('renderOperatorEvent — quota-exhausted', () => {
193
193
  it('renders quota text with /auth use hint and no stub buttons (E5)', () => {
194
194
  const { text, keyboard } = renderOperatorEvent(makeEvent('quota-exhausted'))
195
195
  expect(text).toContain('Quota exhausted')
196
- expect(text).toContain('<b>gymbro</b>')
196
+ expect(text).toContain('**gymbro**')
197
197
  expect(text).toContain('/auth use')
198
198
  const buttons = keyboard.inline_keyboard.flat()
199
199
  // swap-slot and add-slot buttons removed (E5 — they redirected to terminal)
@@ -253,17 +253,15 @@ describe('renderOperatorEvent — unknown-5xx', () => {
253
253
  })
254
254
  })
255
255
 
256
- describe('renderOperatorEvent — HTML escaping', () => {
257
- it('escapes agent name with special chars', () => {
256
+ describe('renderOperatorEvent — markdown escaping (#2669)', () => {
257
+ it('passes < > literally in agent name (markdown, #2669)', () => {
258
258
  const { text } = renderOperatorEvent(makeEvent('unknown-4xx', { agent: '<evil>' }))
259
- expect(text).toContain('&lt;evil&gt;')
260
- expect(text).not.toContain('<evil>')
259
+ expect(text).toContain('<evil>')
261
260
  })
262
261
 
263
- it('escapes detail with special chars', () => {
262
+ it('passes < > literally in detail (markdown, #2669)', () => {
264
263
  const { text } = renderOperatorEvent(makeEvent('credentials-expired', { detail: '<script>alert(1)</script>' }))
265
- expect(text).toContain('&lt;script&gt;')
266
- expect(text).not.toContain('<script>')
264
+ expect(text).toContain('<script>alert(1)</script>')
267
265
  })
268
266
  })
269
267
 
@@ -139,7 +139,7 @@ describe('pending-work-progress', () => {
139
139
  expect(cap.edits).toHaveLength(1)
140
140
  expect(cap.edits[0].messageId).toBe(100)
141
141
  expect(cap.edits[0].newText).toBe(
142
- "Background sleep running; awaiting completion.\n\n— still working (1m) · message me anytime, I'll keep you posted",
142
+ "Background sleep running; awaiting completion.\n\n_still working (1m) · message me anytime, I'll keep you posted_",
143
143
  )
144
144
 
145
145
  // Tick at 3 intervals total — second edit, "3m".
@@ -148,7 +148,7 @@ describe('pending-work-progress', () => {
148
148
  await flush()
149
149
  expect(cap.edits).toHaveLength(2)
150
150
  expect(cap.edits[1].newText).toBe(
151
- "Background sleep running; awaiting completion.\n\n— still working (3m) · message me anytime, I'll keep you posted",
151
+ "Background sleep running; awaiting completion.\n\n_still working (3m) · message me anytime, I'll keep you posted_",
152
152
  )
153
153
  })
154
154
 
@@ -168,7 +168,7 @@ describe('pending-work-progress', () => {
168
168
  await flush()
169
169
  // The new edit should be based on 'worker dispatched' alone.
170
170
  expect(cap.edits[0].newText).toBe(
171
- "worker dispatched\n\n— still working (1m) · message me anytime, I'll keep you posted",
171
+ "worker dispatched\n\n_still working (1m) · message me anytime, I'll keep you posted_",
172
172
  )
173
173
  })
174
174
 
@@ -186,7 +186,7 @@ describe('pending-work-progress', () => {
186
186
  __tickForTests(cap.now)
187
187
  await flush()
188
188
  expect(cap.edits[0].newText).toBe(
189
- "worker dispatched\n\n— still working (1m) · message me anytime, I'll keep you posted",
189
+ "worker dispatched\n\n_still working (1m) · message me anytime, I'll keep you posted_",
190
190
  )
191
191
  })
192
192
 
@@ -336,17 +336,17 @@ describe('pending-work-progress', () => {
336
336
  expect(cap.edits).toHaveLength(0)
337
337
  })
338
338
 
339
- // ─── #1698 regression — preserve parse_mode on the cross-turn edit ───
340
- it("preserves the anchor's parse_mode on every edit (#1698)", async () => {
339
+ // ─── #1698 successor — preserve the anchor's send shape (literalText) on
340
+ // every cross-turn edit (#2669 single-rich-path contract) ───
341
+ it("preserves the anchor's rich shape on every edit (default literalText=false)", async () => {
341
342
  const cap = setup()
342
343
  startTurn(KEY)
343
344
  noteAsyncDispatch(KEY)
344
- // Anchor was sent through the reply tool with format='html', so
345
- // the captured text is already rendered Telegram HTML.
345
+ // Anchor was sent via the rich-markdown path (the default), so the
346
+ // captured text is raw GFM markdown and the edit re-sends it richly.
346
347
  noteOutbound(KEY, {
347
348
  messageId: 100,
348
- text: '<b>Worker back.</b> Both blockers fixed.',
349
- parseMode: 'HTML',
349
+ text: '**Worker back.** Both blockers fixed.',
350
350
  })
351
351
  cap.now = 0
352
352
  noteTurnEnd(KEY)
@@ -354,42 +354,41 @@ describe('pending-work-progress', () => {
354
354
  __tickForTests(cap.now)
355
355
  await flush()
356
356
  expect(cap.edits).toHaveLength(1)
357
- expect(cap.edits[0].parseMode).toBe('HTML')
357
+ expect(cap.edits[0].literalText).toBe(false)
358
358
  expect(cap.edits[0].newText).toBe(
359
- "<b>Worker back.</b> Both blockers fixed.\n\n— still working (1m) · message me anytime, I'll keep you posted",
359
+ "**Worker back.** Both blockers fixed.\n\n_still working (1m) · message me anytime, I'll keep you posted_",
360
360
  )
361
361
  })
362
362
 
363
- it('passes undefined parseMode through when the anchor was plain text', async () => {
363
+ it('threads literalText=true through when the anchor was a literal format:text send', async () => {
364
364
  const cap = setup()
365
365
  startTurn(KEY)
366
366
  noteAsyncDispatch(KEY)
367
- // format: 'text' path — anchor was sent without parse_mode.
367
+ // format: 'text' path — the anchor was a literal plain send.
368
368
  noteOutbound(KEY, {
369
369
  messageId: 100,
370
370
  text: 'plain text reply',
371
- parseMode: undefined,
371
+ literalText: true,
372
372
  })
373
373
  noteTurnEnd(KEY)
374
374
  cap.now = EDIT_INTERVAL_MS
375
375
  __tickForTests(cap.now)
376
376
  await flush()
377
- expect(cap.edits[0].parseMode).toBeUndefined()
377
+ expect(cap.edits[0].literalText).toBe(true)
378
378
  })
379
379
 
380
- it('defaults parseMode to undefined when caller omits it (test ergonomics)', async () => {
380
+ it('defaults literalText to false when caller omits it (rich path, test ergonomics)', async () => {
381
381
  const cap = setup()
382
382
  startTurn(KEY)
383
383
  noteAsyncDispatch(KEY)
384
- // Callsite that hasn't been updated for the new field — must not
385
- // typecheck-fail nor crash. The edit goes out parse_mode-less,
386
- // matching the pre-#1698 behaviour for legacy callers.
384
+ // Callsite that omits the field — must not typecheck-fail nor crash.
385
+ // The edit goes out on the rich path (literalText=false).
387
386
  noteOutbound(KEY, { messageId: 100, text: 'wd' })
388
387
  noteTurnEnd(KEY)
389
388
  cap.now = EDIT_INTERVAL_MS
390
389
  __tickForTests(cap.now)
391
390
  await flush()
392
- expect(cap.edits[0].parseMode).toBeUndefined()
391
+ expect(cap.edits[0].literalText).toBe(false)
393
392
  })
394
393
 
395
394
  it('multiple chats — independent state', async () => {