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
@@ -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.
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { describe, it, expect } from "vitest";
9
9
  import { validateClientMessage } from "../gateway/ipc-server.js";
10
+ import { RICH_MESSAGE_MAX_CHARS } from "../format.js";
10
11
 
11
12
  const base = { type: "send_outbound", agentName: "clerk", chatId: "12345", text: "Daily heartbeat" };
12
13
 
@@ -38,8 +39,11 @@ describe("validateClientMessage — send_outbound", () => {
38
39
  expect(validateClientMessage({ ...base, text: undefined })).toBe(false);
39
40
  expect(validateClientMessage({ ...base, text: "" })).toBe(false);
40
41
  expect(validateClientMessage({ ...base, text: 5 })).toBe(false);
41
- expect(validateClientMessage({ ...base, text: "x".repeat(4096) })).toBe(true); // at the cap
42
- expect(validateClientMessage({ ...base, text: "x".repeat(4097) })).toBe(false); // over Telegram's limit
42
+ // send_outbound posts via sendRichMessage (rich path), so the cap is the
43
+ // rich-message wire limit (RICH_MESSAGE_MAX_CHARS, 32768) post-#2669, not
44
+ // the legacy 4096 plain-text limit.
45
+ expect(validateClientMessage({ ...base, text: "x".repeat(RICH_MESSAGE_MAX_CHARS) })).toBe(true); // at the cap
46
+ expect(validateClientMessage({ ...base, text: "x".repeat(RICH_MESSAGE_MAX_CHARS + 1) })).toBe(false); // over the cap
43
47
  });
44
48
 
45
49
  it("rejects a non-integer threadId", () => {
@@ -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
 
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Tests for the over-length error classification (task E of the Telegram-
3
+ * formatting bundle) and the hardSliceToCap last-resort slicer.
4
+ *
5
+ * The rich path rejects a 32769+ char body with `RICH_MESSAGE_TEXT_TOO_LONG`
6
+ * (and the legacy plain path with `MESSAGE_TOO_LONG`). That is a LENGTH error,
7
+ * not a markdown-parse error — it must be classified so the caller re-splits
8
+ * the body rather than (a) misclassifying it as a parse-reject and resending
9
+ * the same oversized payload as plain text, or (b) surfacing the raw 400.
10
+ */
11
+ import { describe, test, expect } from 'vitest'
12
+ import { GrammyError } from 'grammy'
13
+ import { isLengthError, isParseEntitiesError } from '../rich-send.js'
14
+ import { isMessageTooLongError, isHtmlParseRejectError } from '../retry-api-call.js'
15
+ import { hardSliceToCap, splitMarkdownChunks, RICH_MESSAGE_MAX_CHARS } from '../format.js'
16
+
17
+ // Build a GrammyError with a given 400 description. GrammyError's constructor
18
+ // takes (message, payload, method, parameters) in grammy 1.44.
19
+ function grammy400(description: string): GrammyError {
20
+ return new GrammyError(
21
+ `Call to 'sendRichMessage' failed! (400: ${description})`,
22
+ { ok: false, error_code: 400, description },
23
+ 'sendRichMessage',
24
+ {},
25
+ )
26
+ }
27
+
28
+ describe('length-error classification (rich-send.isLengthError)', () => {
29
+ test('RICH_MESSAGE_TEXT_TOO_LONG is a length error', () => {
30
+ const err = grammy400('RICH_MESSAGE_TEXT_TOO_LONG')
31
+ expect(isLengthError(err)).toBe(true)
32
+ })
33
+
34
+ test('legacy MESSAGE_TOO_LONG / "message is too long" are length errors', () => {
35
+ expect(isLengthError(grammy400('MESSAGE_TOO_LONG'))).toBe(true)
36
+ expect(isLengthError(grammy400('Bad Request: message is too long'))).toBe(true)
37
+ })
38
+
39
+ test('a parse-entities error is NOT a length error', () => {
40
+ expect(isLengthError(grammy400("can't parse entities: bad offset"))).toBe(false)
41
+ })
42
+
43
+ test('a length error is NOT misclassified as a parse-entities error', () => {
44
+ const err = grammy400('RICH_MESSAGE_TEXT_TOO_LONG')
45
+ expect(isParseEntitiesError(err)).toBe(false)
46
+ })
47
+
48
+ test('non-GrammyError and non-400 are neither', () => {
49
+ expect(isLengthError(new Error('boom'))).toBe(false)
50
+ const err403 = new GrammyError(
51
+ 'forbidden',
52
+ { ok: false, error_code: 403, description: 'Forbidden: bot was blocked' },
53
+ 'sendRichMessage',
54
+ {},
55
+ )
56
+ expect(isLengthError(err403)).toBe(false)
57
+ })
58
+ })
59
+
60
+ describe('length-error classification (retry-api-call.isMessageTooLongError)', () => {
61
+ test('RICH_MESSAGE_TEXT_TOO_LONG is a length error', () => {
62
+ expect(isMessageTooLongError(grammy400('RICH_MESSAGE_TEXT_TOO_LONG'))).toBe(true)
63
+ })
64
+
65
+ test('a length error is NOT misclassified as an html-parse-reject', () => {
66
+ const err = grammy400('RICH_MESSAGE_TEXT_TOO_LONG')
67
+ expect(isHtmlParseRejectError(err)).toBe(false)
68
+ })
69
+
70
+ test('a parse-reject error still classifies as a parse-reject (not length)', () => {
71
+ const err = grammy400("can't parse entities: unexpected end")
72
+ expect(isHtmlParseRejectError(err)).toBe(true)
73
+ expect(isMessageTooLongError(err)).toBe(false)
74
+ })
75
+ })
76
+
77
+ describe('hardSliceToCap', () => {
78
+ test('returns the input as one piece when it already fits', () => {
79
+ expect(hardSliceToCap('short', 100)).toEqual(['short'])
80
+ })
81
+
82
+ test('slices an oversized body into pieces each <= cap', () => {
83
+ const body = 'x'.repeat(250)
84
+ const pieces = hardSliceToCap(body, 100)
85
+ expect(pieces).toHaveLength(3)
86
+ for (const p of pieces) expect(p.length).toBeLessThanOrEqual(100)
87
+ expect(pieces.join('')).toBe(body)
88
+ })
89
+
90
+ test('defaults the cap to RICH_MESSAGE_MAX_CHARS', () => {
91
+ const body = 'y'.repeat(RICH_MESSAGE_MAX_CHARS + 5)
92
+ const pieces = hardSliceToCap(body)
93
+ expect(pieces).toHaveLength(2)
94
+ expect(pieces[0].length).toBe(RICH_MESSAGE_MAX_CHARS)
95
+ expect(pieces[1].length).toBe(5)
96
+ })
97
+ })
98
+
99
+ // The gateway length-error recovery (gateway.ts sendChunkResplit, ~8758) re-splits
100
+ // an oversized chunk with `splitMarkdownChunks` and falls back to `hardSliceToCap`
101
+ // when the block is indivisible. These tests pin that seam at the function level:
102
+ // a >RICH_MESSAGE_MAX_CHARS body is ALWAYS chunked into >=2 sends (each <= cap),
103
+ // never silently dropped or resent whole on a RICH_MESSAGE_TEXT_TOO_LONG reject.
104
+ describe('resplit-on-reject: oversized body chunks into >=2 sends', () => {
105
+ test('a >cap body with prose boundaries re-splits into >=2 chunks each <= cap', () => {
106
+ // A paragraph-separated body twice the cap: splitMarkdownChunks finds the
107
+ // `\n\n` boundaries and yields multiple chunks.
108
+ const para = 'x'.repeat(4000)
109
+ const body = Array.from({ length: 20 }, () => para).join('\n\n')
110
+ expect(body.length).toBeGreaterThan(RICH_MESSAGE_MAX_CHARS)
111
+ const pieces = splitMarkdownChunks(body, RICH_MESSAGE_MAX_CHARS)
112
+ expect(pieces.length).toBeGreaterThanOrEqual(2)
113
+ for (const p of pieces) expect(p.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS)
114
+ })
115
+
116
+ test('an indivisible >cap blob falls back through hardSliceToCap to >=2 chunks each <= cap', () => {
117
+ // A single boundary-free blob larger than the cap: splitMarkdownChunks
118
+ // cannot break it and emits it whole (length > cap), so the recovery path
119
+ // hands it to hardSliceToCap — mirroring sendChunkResplit's fallback.
120
+ const blob = 'z'.repeat(RICH_MESSAGE_MAX_CHARS + 5000)
121
+ const subPieces = splitMarkdownChunks(blob, RICH_MESSAGE_MAX_CHARS)
122
+ const pieces =
123
+ subPieces.length > 1 && subPieces.every((p) => p.length <= RICH_MESSAGE_MAX_CHARS)
124
+ ? subPieces
125
+ : hardSliceToCap(blob, RICH_MESSAGE_MAX_CHARS)
126
+ expect(pieces.length).toBeGreaterThanOrEqual(2)
127
+ for (const p of pieces) expect(p.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS)
128
+ // Nothing is dropped — the pieces reconstitute the original body.
129
+ expect(pieces.join('')).toBe(blob)
130
+ })
131
+ })
@@ -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