switchroom 0.18.18 → 0.18.19

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.
@@ -34,7 +34,6 @@ import {
34
34
  } from './model-unavailable.js'
35
35
  import { classify429Detail } from './throttle-tier.js'
36
36
  import { classifyClaudeError } from './operator-events.js'
37
- import type { InlineKeyboardMarkup } from './operator-events.js'
38
37
  import { stripRawErrorBytes, extractRequestId } from './raw-error-scrub.js'
39
38
  import { fmtLocalClock, tzAbbrev } from './shared/local-time.js'
40
39
 
@@ -226,7 +225,6 @@ function buildCoreText(kind: LlmErrorKind, source: LlmErrorSource): string {
226
225
 
227
226
  export interface RenderedLlmError {
228
227
  text: string
229
- keyboard?: InlineKeyboardMarkup
230
228
  }
231
229
 
232
230
  /**
@@ -257,9 +255,49 @@ function formatRelativeTail(deltaMs: number): string {
257
255
  }
258
256
 
259
257
  /**
260
- * Render ONE clean card for a parsed LLM error. Action buttons for the
261
- * actionable kinds (auth Reauth, quota_wall → Wait/switch). `agent` is used
262
- * in the callback_data (URL-encoded) and the headline; `tz` localizes the reset.
258
+ * A short local-tz clock+abbrev for a reset instant (`4:52pm AEST`), or '' when
259
+ * there is no finite reset to name. Used to embed the reset in the recommendation
260
+ * line. Shares the same tz-formatting primitives as {@link formatResetLocal}, so
261
+ * an invalid IANA tz throws here too (guarded by {@link renderLlmErrorSafe}).
262
+ */
263
+ function formatResetClock(resetAt: Date | undefined, tz: string): string {
264
+ if (resetAt == null) return ''
265
+ const ms = resetAt.getTime()
266
+ if (!Number.isFinite(ms)) return ''
267
+ return `${fmtLocalClock(ms, tz)} ${tzAbbrev(ms, tz)}`
268
+ }
269
+
270
+ /**
271
+ * The plain-text "what to DO" line for the actionable error classes. Replaces
272
+ * the former dead action buttons (auth → Reauth, quota_wall → Wait): those
273
+ * inline_keyboard callbacks were only ever exercised in tests — the gateway
274
+ * wiring routes renderLlmError solely for the transient rate_limit/overload_529
275
+ * kinds, so the auth/quota buttons were unreachable in production (Ken, CPO,
276
+ * 2026-07: drop the buttons, recommend in text). Transient kinds return undefined
277
+ * — their coreText already says "retrying automatically", no operator action.
278
+ */
279
+ function buildRecommendation(parsed: ParsedLlmError, tz: string): string | undefined {
280
+ switch (parsed.kind) {
281
+ case 'auth':
282
+ return '→ Re-authenticate this account to continue.'
283
+ case 'quota_wall': {
284
+ const reset = formatResetClock(parsed.resetAt, tz)
285
+ return reset
286
+ ? `→ Switch to another account, or wait for the quota to reset at ${reset}.`
287
+ : '→ Switch to another account, or wait for the quota to reset.'
288
+ }
289
+ default:
290
+ return undefined
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Render ONE clean card for a parsed LLM error. No action buttons — the
296
+ * actionable kinds (auth, quota_wall) carry a plain-text recommendation line
297
+ * instead (see {@link buildRecommendation}). `agent` is the headline label; `tz`
298
+ * localizes the reset. Throws if `tz` is an invalid IANA zone AND a reset is
299
+ * present (Intl.DateTimeFormat rejects the zone at construction) — call
300
+ * {@link renderLlmErrorSafe} from any crash-sensitive surface.
263
301
  */
264
302
  export function renderLlmError(
265
303
  parsed: ParsedLlmError,
@@ -276,32 +314,32 @@ export function renderLlmError(
276
314
 
277
315
  if (parsed.model) lines.push(`_model: ${escapeAgent(parsed.model)}_`)
278
316
 
279
- const text = lines.join('\n')
317
+ const recommendation = buildRecommendation(parsed, tz)
318
+ if (recommendation) lines.push(recommendation)
280
319
 
281
- switch (parsed.kind) {
282
- case 'auth':
283
- return {
284
- text,
285
- keyboard: {
286
- inline_keyboard: [
287
- [
288
- { text: '🔐 Reauth now', callback_data: `op:reauth:${encodeURIComponent(agent)}` },
289
- { text: '❌ Dismiss', callback_data: `op:dismiss:${encodeURIComponent(agent)}` },
290
- ],
291
- ],
292
- },
293
- }
294
- case 'quota_wall':
295
- return {
296
- text,
297
- keyboard: {
298
- inline_keyboard: [
299
- [{ text: '⏳ Wait', callback_data: `op:dismiss:${encodeURIComponent(agent)}` }],
300
- ],
301
- },
302
- }
303
- default:
304
- return { text }
320
+ return { text: lines.join('\n') }
321
+ }
322
+
323
+ /**
324
+ * Crash-guarded wrapper around {@link renderLlmError}. An invalid IANA timezone
325
+ * makes Intl.DateTimeFormat throw a RangeError at construction time — the
326
+ * local-time.ts "never throws" contract does NOT cover construction-time zone
327
+ * validation, so a bad `SWITCHROOM_TIMEZONE`/`TZ` would otherwise crash the
328
+ * operator-event turn. On ANY formatting failure, degrade to a minimal, tz-free
329
+ * safe line built only from the JSON-stripped coreText + agent. Total — never
330
+ * throws.
331
+ */
332
+ export function renderLlmErrorSafe(
333
+ parsed: ParsedLlmError,
334
+ agent: string,
335
+ tz: string,
336
+ now: Date = new Date(),
337
+ ): RenderedLlmError {
338
+ try {
339
+ return renderLlmError(parsed, agent, tz, now)
340
+ } catch {
341
+ const safeAgent = escapeAgent(agent)
342
+ return { text: `${kindEmoji(parsed.kind)} ${parsed.coreText} (**${safeAgent}**)` }
305
343
  }
306
344
  }
307
345
 
@@ -171,14 +171,6 @@ export interface StreamReplyDeps {
171
171
  * after normalizePunctuation. Optional for backward compat.
172
172
  */
173
173
  stripExcessBold?: (text: string) => string
174
- /**
175
- * Insert a visible blank-line spacer into each prose `\n\n` gap so the rich
176
- * GFM renderer shows a real empty line between paragraphs (the rich engine
177
- * otherwise renders `\n\n` tight — the post-#2669 paragraph-spacing
178
- * regression). Applied only on the rich path (never on `format:'text'`).
179
- * Optional for backward compat; omitted → no spacers added.
180
- */
181
- addParagraphSpacers?: (text: string) => string
182
174
  /** Validates the chat id against the access list. Throws on deny. */
183
175
  assertAllowedChat: (chatId: string) => void
184
176
  /** Resolves the effective thread id (explicit, last-inbound, or undefined). */
@@ -362,12 +354,11 @@ export async function handleStreamReply(
362
354
  // markdown→HTML / MarkdownV2 rendering happens here anymore — the raw
363
355
  // text IS the wire payload.
364
356
  const literalText = format === 'text'
365
- // Paragraph-spacing fix (rich-message regression after #2669): inject a
366
- // visible blank-line spacer into prose `\n\n` gaps on the rich path so
367
- // multi-paragraph answers don't render jammed together. The literal
368
- // (`format:'text'`) path must stay byte-exact, so it is left untouched.
369
- let effectiveText: string =
370
- !literalText && deps.addParagraphSpacers ? deps.addParagraphSpacers(rawText) : rawText
357
+ // No paragraph-spacer pass: the NBSP spacer (#2669) was removed in the
358
+ // follow-up because the Bot API 10.1 rich GFM renderer already shows a `\n\n`
359
+ // gap as one blank line — the spacer double-gapped every paragraph. The raw
360
+ // text (already normalized upstream) is the effective text on both paths.
361
+ let effectiveText: string = rawText
371
362
 
372
363
  // Inline status-accent header (issue #320 fallback). Prepended so it
373
364
  // leads the body. Since stream_reply callers pass the full text snapshot
@@ -1,27 +1,25 @@
1
1
  /**
2
2
  * Tests for the fleet-wide consistent-formatting bundle:
3
3
  *
4
- * 1. addParagraphSpacers extension — a visible U+00A0 spacer line at EVERY
5
- * block transition (paragraph→list, list→paragraph, heading→anything,
6
- * blockquote/table boundaries), never inside a list/table interior.
4
+ * 1. paragraph gap spacing — a plain `\n\n` (one blank line) at EVERY block
5
+ * transition (paragraph→list, list→paragraph, heading→anything,
6
+ * blockquote/table boundaries), never a double blank line and never an
7
+ * NBSP spacer (the old spacer pass was removed in the #2669 follow-up).
7
8
  * 2. normalizePunctuation — em/en dashes → comma/hyphen, leading `•`/`·`
8
- * list markers → `- `, on code-masked text, idempotent.
9
+ * list markers → `- `, on code-masked text, idempotent; link hrefs are
10
+ * protected from dash rewriting.
9
11
  * 3. stripExcessBold — over-bold tripwire: >30% bold or fully-bolded
10
12
  * paragraphs/lists lose their bold markers; short messages exempt.
11
13
  */
12
14
  import { describe, test, expect } from 'vitest'
13
15
  import {
14
- addParagraphSpacers,
15
16
  normalizeParagraphBreaks,
16
17
  normalizePunctuation,
17
18
  stripExcessBold,
18
19
  splitMarkdownChunks,
19
20
  hardenCardBreaks,
20
- PARAGRAPH_SPACER,
21
21
  } from '../format.js'
22
22
 
23
- const SP = PARAGRAPH_SPACER // U+00A0
24
-
25
23
  describe('hardenCardBreaks — deterministic card line-break hardener', () => {
26
24
  test('promotes lone field breaks to GFM hard breaks (the blob fix)', () => {
27
25
  const out = hardenCardBreaks('Agent: assistant\nAuth: Max\nStatus: running')
@@ -100,74 +98,60 @@ describe('hardenCardBreaks — deterministic card line-break hardener', () => {
100
98
  })
101
99
  })
102
100
 
103
- describe('addParagraphSpacersuniform block spacing', () => {
104
- test('still spaces prose→prose (existing behaviour)', () => {
105
- const out = addParagraphSpacers('Alpha.\n\nBravo.')
106
- expect(out).toBe(`Alpha.\n\n${SP}\n\nBravo.`)
107
- })
101
+ describe('paragraph gap spacing plain single blank line, no NBSP', () => {
102
+ const NBSP = String.fromCharCode(0xa0)
108
103
 
109
- test('spaces paragraphlist transition', () => {
110
- const out = addParagraphSpacers('Intro.\n\n- one\n- two')
111
- expect(out).toBe(`Intro.\n\n${SP}\n\n- one\n- two`)
104
+ test('proseprose gap is one blank line, no NBSP', () => {
105
+ const out = normalizeParagraphBreaks('Alpha.\n\nBravo.')
106
+ expect(out).toBe('Alpha.\n\nBravo.')
107
+ expect(out).not.toContain(NBSP)
112
108
  })
113
109
 
114
- test('spaces list→paragraph transition', () => {
115
- const out = addParagraphSpacers('- one\n- two\n\nOutro.')
116
- expect(out).toBe(`- one\n- two\n\n${SP}\n\nOutro.`)
110
+ test('paragraph→list transition is a single `\\n\\n` boundary', () => {
111
+ expect(normalizeParagraphBreaks('Intro.\n\n- one\n- two')).toBe('Intro.\n\n- one\n- two')
117
112
  })
118
113
 
119
- test('spaces heading→anything', () => {
120
- expect(addParagraphSpacers('# Title\n\nBody.')).toBe(`# Title\n\n${SP}\n\nBody.`)
121
- expect(addParagraphSpacers('# Title\n\n- a\n- b')).toBe(`# Title\n\n${SP}\n\n- a\n- b`)
122
- })
123
-
124
- test('spaces blockquote and table boundaries', () => {
125
- expect(addParagraphSpacers('> quoted\n\nProse.')).toBe(`> quoted\n\n${SP}\n\nProse.`)
126
- const table = '| a | b |\n| --- | --- |\n| 1 | 2 |'
127
- expect(addParagraphSpacers(`Prose.\n\n${table}`)).toBe(`Prose.\n\n${SP}\n\n${table}`)
128
- expect(addParagraphSpacers(`${table}\n\nProse.`)).toBe(`${table}\n\n${SP}\n\nProse.`)
114
+ test('list→paragraph transition is a single `\\n\\n` boundary', () => {
115
+ expect(normalizeParagraphBreaks('- one\n- two\n\nOutro.')).toBe('- one\n- two\n\nOutro.')
129
116
  })
130
117
 
131
- test('does NOT space between items of the same loose list', () => {
132
- const input = '- one\n\n- two\n\n- three'
133
- expect(addParagraphSpacers(input)).toBe(input)
118
+ test('heading→anything is a single `\\n\\n` boundary', () => {
119
+ expect(normalizeParagraphBreaks('# Title\n\nBody.')).toBe('# Title\n\nBody.')
120
+ expect(normalizeParagraphBreaks('# Title\n\n- a\n- b')).toBe('# Title\n\n- a\n- b')
134
121
  })
135
122
 
136
- test('does NOT space inside a tight list or table interior (single \\n)', () => {
137
- const list = 'Intro.\n\n- a\n- b\n- c'
138
- expect(addParagraphSpacers(list)).toBe(`Intro.\n\n${SP}\n\n- a\n- b\n- c`)
139
- const table = '| a |\n| --- |\n| 1 |\n| 2 |'
140
- expect(addParagraphSpacers(table)).toBe(table)
141
- })
142
-
143
- test('idempotent across every transition kind', () => {
144
- const input = '# H\n\nProse one.\n\n- a\n- b\n\nProse two.\n\n> quote'
145
- const once = addParagraphSpacers(input)
146
- expect(addParagraphSpacers(once)).toBe(once)
123
+ test('blockquote and table boundaries are single `\\n\\n`', () => {
124
+ expect(normalizeParagraphBreaks('> quoted\n\nProse.')).toBe('> quoted\n\nProse.')
125
+ const table = '| a | b |\n| --- | --- |\n| 1 | 2 |'
126
+ expect(normalizeParagraphBreaks(`Prose.\n\n${table}`)).toBe(`Prose.\n\n${table}`)
127
+ expect(normalizeParagraphBreaks(`${table}\n\nProse.`)).toBe(`${table}\n\nProse.`)
147
128
  })
148
129
 
149
130
  test('never touches code fences', () => {
150
131
  const input = '```\nA\n\nB\n```\n\n```\nC\n```'
151
- expect(addParagraphSpacers(input)).toBe(input)
132
+ expect(normalizeParagraphBreaks(input)).toBe(input)
152
133
  })
153
134
 
154
- test('full pipeline: mixed prose+list+heading message gets uniform gaps', () => {
155
- const raw = 'Summary line.\n- item one\n- item two\nClosing prose.'
156
- const out = addParagraphSpacers(normalizeParagraphBreaks(raw))
157
- // Every block transition carries exactly one visible spacer line.
158
- expect(out).toBe(
159
- `Summary line.\n\n${SP}\n\n- item one\n- item two\n\n${SP}\n\nClosing prose.`,
160
- )
135
+ test('full pipeline: mixed prose+list message gets single-blank-line gaps, no NBSP, no double gap', () => {
136
+ const raw = 'Summary line.\n\n- item one\n- item two\n\nClosing prose.'
137
+ const out = normalizeParagraphBreaks(raw)
138
+ expect(out).toBe('Summary line.\n\n- item one\n- item two\n\nClosing prose.')
139
+ expect(out).not.toContain(NBSP)
140
+ expect(out).not.toMatch(/\n\n\n/)
161
141
  })
162
142
 
163
- test('chunk-boundary interaction: a cut in a spacer gap strips the spacer', () => {
143
+ test('chunk-boundary interaction: a cut in a `\\n\\n` gap leaves clean chunks (no stray blank lines)', () => {
164
144
  const a = 'A'.repeat(60)
165
145
  const b = 'B'.repeat(60)
166
- const text = `${a}\n\n${SP}\n\n${b}`
146
+ const text = `${a}\n\n${b}`
167
147
  const chunks = splitMarkdownChunks(text, 80)
168
148
  expect(chunks.length).toBe(2)
169
149
  expect(chunks[0]).toBe(a)
170
150
  expect(chunks[1]).toBe(b)
151
+ // Neither chunk opens or ends with a stray blank line.
152
+ for (const c of chunks) {
153
+ expect(c).toBe(c.replace(/^\n+|\n+$/g, ''))
154
+ }
171
155
  })
172
156
  })
173
157
 
@@ -210,6 +194,37 @@ describe('normalizePunctuation', () => {
210
194
  expect(normalizePunctuation(input)).toBe(input)
211
195
  })
212
196
 
197
+ test('does NOT rewrite a dash inside a markdown link href (#finding-2)', () => {
198
+ // An en-dash in a URL path must survive verbatim — rewriting it to `-`
199
+ // silently points at a different URL.
200
+ expect(normalizePunctuation('[a](https://x.com/foo–bar)')).toBe(
201
+ '[a](https://x.com/foo–bar)',
202
+ )
203
+ // An em-dash in a URL must NOT become `, ` — the injected space would
204
+ // TERMINATE the markdown link and leak the trailing text as prose.
205
+ expect(normalizePunctuation('[a](https://x.com/foo—bar)')).toBe(
206
+ '[a](https://x.com/foo—bar)',
207
+ )
208
+ // The visible LABEL still normalizes (dashes in label text are prose).
209
+ expect(normalizePunctuation('[foo—bar](https://x.com/path)')).toBe(
210
+ '[foo, bar](https://x.com/path)',
211
+ )
212
+ })
213
+
214
+ test('does NOT rewrite a dash inside a `<scheme:…>` autolink (#finding-2 sibling)', () => {
215
+ // An en-dash in an angle-bracket autolink URL must survive verbatim.
216
+ expect(normalizePunctuation('<https://x.com/foo–bar>')).toBe('<https://x.com/foo–bar>')
217
+ // An em-dash in an autolink must NOT become `, `.
218
+ expect(normalizePunctuation('<https://x.com/foo—bar>')).toBe('<https://x.com/foo—bar>')
219
+ // Autolink mid-prose: the surrounding prose still normalizes, the URL does not.
220
+ expect(normalizePunctuation('see <https://x.com/a–b> now — go')).toBe(
221
+ 'see <https://x.com/a–b> now, go',
222
+ )
223
+ // Arbitrary `<…>` prose (no scheme) is NOT treated as an autolink: a dash
224
+ // inside it still normalizes like ordinary text.
225
+ expect(normalizePunctuation('<not—a—url>')).toBe('<not, a, url>')
226
+ })
227
+
213
228
  test('idempotent', () => {
214
229
  const once = normalizePunctuation('a — b\n• c\nd—e\n1–2')
215
230
  expect(normalizePunctuation(once)).toBe(once)
@@ -17,9 +17,10 @@
17
17
  * fixture intended (bold/italic/code/pre/link at the right inner text).
18
18
  *
19
19
  * The pipeline mirrors `telegram-plugin/gateway/gateway.ts`:
20
- * repairEscapedWhitespace -> normalizeParagraphBreaks -> addParagraphSpacers
21
- * -> splitMarkdownChunks. (Card-surface fixtures also apply `normalizeDashes`
22
- * first that is where F1's voice scrub lives.)
20
+ * repairEscapedWhitespace -> normalizeParagraphBreaks -> splitMarkdownChunks.
21
+ * (No paragraph-spacer pass the NBSP spacer was removed in the #2669
22
+ * follow-up; gaps are plain `\n\n`. Card-surface fixtures also apply
23
+ * `normalizeDashes` first — that is where F1's voice scrub lives.)
23
24
  *
24
25
  * This suite is TEST-ONLY. It changes no production formatting behaviour. If a
25
26
  * fixture ever fails signal (a), it means the formatter emits markdown Telegram
@@ -30,7 +31,6 @@ import { describe, test, expect } from 'vitest'
30
31
  import {
31
32
  repairEscapedWhitespace,
32
33
  normalizeParagraphBreaks,
33
- addParagraphSpacers,
34
34
  splitMarkdownChunks,
35
35
  RICH_MESSAGE_MAX_CHARS,
36
36
  } from '../format.js'
@@ -57,8 +57,7 @@ function transform(input: string, opts: { cardSurfaceScrub?: boolean; cap?: numb
57
57
  } {
58
58
  let text = input
59
59
  if (opts.cardSurfaceScrub) text = normalizeDashes(text)
60
- text = normalizeParagraphBreaks(repairEscapedWhitespace(text))
61
- const body = addParagraphSpacers(text)
60
+ const body = normalizeParagraphBreaks(repairEscapedWhitespace(text))
62
61
  const chunks = splitMarkdownChunks(body, opts.cap ?? RICH_MESSAGE_MAX_CHARS)
63
62
  return { chunks, body }
64
63
  }
@@ -7,7 +7,7 @@
7
7
  * The `input` is the raw text as a model/card surface would author it — the
8
8
  * regression test runs it through the SAME outbound transform pipeline the
9
9
  * gateway uses (repairEscapedWhitespace -> normalizeParagraphBreaks ->
10
- * addParagraphSpacers -> splitMarkdownChunks) and then asserts:
10
+ * splitMarkdownChunks) and then asserts:
11
11
  *
12
12
  * (a) every emitted chunk is parse-accept valid (no rich-path 400), and
13
13
  * (b) the concatenated output parses into `expect` (entity structure).
@@ -134,6 +134,32 @@ describe('gateway outbound secret-scrub — structural wiring', () => {
134
134
  expect(streamIdx).toBeGreaterThan(redactIdx) // mask BEFORE the stream
135
135
  })
136
136
 
137
+ it('operator_event: scrubs event.detail at the top of emitGatewayOperatorEvent, BEFORE render/send', () => {
138
+ // #llm-error-surfacing FIX 2 — operator-event cards are sent via a raw
139
+ // bot.api.sendRichMessage that BYPASSES the normal outbound chokepoint, so a
140
+ // secret in an error `detail` (credentials-expired / credit-exhausted /
141
+ // unknown-4xx) could reach the card verbatim. The detail is redacted ONCE at
142
+ // the top of the function, through the shared redactOutboundText, and MUST
143
+ // run before the renderers escapeMarkdown it (redacting the already-escaped
144
+ // text lets url-query-param secrets slip past url-redact) and before the
145
+ // send. This is the sole regression guard for that wiring line — deleting it
146
+ // must turn this red.
147
+ const start = src.indexOf('function emitGatewayOperatorEvent(')
148
+ const redactIdx = src.indexOf(
149
+ `event = { ...event, detail: redactOutboundText(event.detail, 'operator_event') }`,
150
+ start,
151
+ )
152
+ // The two render surfaces + the wire send this must precede.
153
+ const renderOpIdx = src.indexOf('renderOperatorEvent(event)', start)
154
+ const renderLlmIdx = src.indexOf('renderLlmErrorSafe(parsed', start)
155
+ const sendIdx = src.indexOf('bot.api.sendRichMessage(chat_id, richMessage(renderedText)', start)
156
+ expect(start).toBeGreaterThan(0)
157
+ expect(redactIdx).toBeGreaterThan(start)
158
+ expect(renderOpIdx).toBeGreaterThan(redactIdx) // mask BEFORE the escapeMarkdown render
159
+ expect(renderLlmIdx).toBeGreaterThan(redactIdx) // mask BEFORE the humanized render
160
+ expect(sendIdx).toBeGreaterThan(redactIdx) // mask BEFORE the card hits the wire
161
+ })
162
+
137
163
  it('does not log the secret value when a mask fires', () => {
138
164
  const idx = src.indexOf('function redactOutboundText(')
139
165
  const body = src.slice(idx, idx + 400)
@@ -14,6 +14,7 @@ import { describe, it, expect, beforeEach } from 'vitest'
14
14
  import {
15
15
  parseLlmError,
16
16
  renderLlmError,
17
+ renderLlmErrorSafe,
17
18
  stripRawErrorBytes,
18
19
  extractRequestId,
19
20
  formatResetLocal,
@@ -27,6 +28,7 @@ import {
27
28
  import { truncateDetailPreservingRequestId } from '../raw-error-scrub.js'
28
29
  import { projectTranscriptLine, detectErrorInTranscriptLine } from '../session-tail.js'
29
30
  import { renderOperatorEvent, type OperatorEvent } from '../operator-events.js'
31
+ import { redact } from '../secret-detect/redact.js'
30
32
 
31
33
  // A raw byte-blob every surface must scrub.
32
34
  const RAW_BYTES = `b'{"type":"error","error":{"type":"rate_limit_error","message":"rate limit"},"request_id":"req_abc123"}'`
@@ -161,16 +163,115 @@ describe('renderLlmError / formatResetLocal — local-time rendering', () => {
161
163
  expect(text).toContain('AEST')
162
164
  })
163
165
 
164
- it('auth + quota_wall cards carry action buttons', () => {
166
+ // FIX 1 (Ken, CPO, 2026-07): the dead auth/quota action buttons are GONE
167
+ // renderLlmError never returns an inline_keyboard; the actionable classes
168
+ // carry a plain-text recommendation line instead.
169
+ it('auth card recommends re-authentication in text, with NO action buttons', () => {
165
170
  const auth = renderLlmError(parseLlmError('authentication_error: token expired'), 'a', tz, now)
166
- expect(auth.keyboard?.inline_keyboard.flat().some((b) => b.callback_data?.includes('reauth'))).toBe(true)
167
- const quota = renderLlmError(
168
- parseLlmError("You've hit your limit · resets 5pm"),
169
- 'a',
170
- tz,
171
- now,
172
- )
173
- expect(quota.keyboard?.inline_keyboard.flat().length).toBeGreaterThan(0)
171
+ expect((auth as { keyboard?: unknown }).keyboard).toBeUndefined()
172
+ expect(auth.text.toLowerCase()).toContain('re-authenticate')
173
+ })
174
+
175
+ it('quota_wall card recommends switch/wait in text (naming the reset), NO buttons', () => {
176
+ const quota = renderLlmError(parseLlmError("You've hit your limit · resets 5pm"), 'a', tz, now)
177
+ expect((quota as { keyboard?: unknown }).keyboard).toBeUndefined()
178
+ const lower = quota.text.toLowerCase()
179
+ expect(lower).toContain('switch to another account')
180
+ expect(lower).toContain('wait for the quota to reset')
181
+ // The reset instant is named in the recommendation line.
182
+ expect(quota.text).toContain('AEST')
183
+ })
184
+
185
+ it('transient (rate_limit) card has neither buttons nor an action recommendation', () => {
186
+ const rl = renderLlmError(parseLlmError('rate_limit_error: slow down'), 'a', tz, now)
187
+ expect((rl as { keyboard?: unknown }).keyboard).toBeUndefined()
188
+ expect(rl.text.toLowerCase()).not.toContain('re-authenticate')
189
+ expect(rl.text).not.toContain('→')
190
+ })
191
+
192
+ // FIX 3 (crash guard): an invalid IANA timezone throws a RangeError out of the
193
+ // raw renderer (local-time.ts's "never throws" claim is false for tz
194
+ // construction). renderLlmErrorSafe MUST swallow it and degrade — asserting the
195
+ // OUTCOME (no throw + a usable message), not just that the branch ran.
196
+ it('renderLlmError DOES throw on an invalid tz with a reset present (documents the hazard)', () => {
197
+ const parsed = parseLlmError("You've hit your limit · resets 5pm")
198
+ expect(parsed.resetAt).toBeDefined()
199
+ expect(() => renderLlmError(parsed, 'gymbro', 'Not/AZone', now)).toThrow()
200
+ })
201
+
202
+ it('renderLlmErrorSafe does NOT throw on an invalid tz and returns a usable line', () => {
203
+ const parsed = parseLlmError("You've hit your limit · resets 5pm")
204
+ let out: { text: string } | undefined
205
+ expect(() => {
206
+ out = renderLlmErrorSafe(parsed, 'gymbro', 'Not/AZone', now)
207
+ }).not.toThrow()
208
+ expect(out?.text).toContain('gymbro')
209
+ assertNoRawBytes(out!.text)
210
+ })
211
+ })
212
+
213
+ // ─── FIX 2: operator-card text is scrubbed by the REAL redactor ───────────────
214
+ //
215
+ // The gateway sends operator-event cards via a raw bot.api call that bypasses
216
+ // the normal outbound redact chokepoint; it now routes the rendered text through
217
+ // the same redact() the reply path uses. These tests assert the OUTCOME: a
218
+ // synthetic bearer token / sk- key / url-embedded credential planted in an error
219
+ // detail does NOT survive into the redacted card text. stripRawErrorBytes alone
220
+ // (a JSON-shape scrub) does NOT catch these — redact() is required.
221
+ describe('operator-card secret redaction (FIX 2)', () => {
222
+ const now = new Date('2026-07-13T06:14:00Z')
223
+ // Runtime-assembled so no contiguous Anthropic-token literal lands in source
224
+ // (check-no-pii-secrets discipline). Resolves to a real sk-ant-shaped key that
225
+ // redact()'s anthropic_api_key pattern masks.
226
+ const BEARER = ['sk', 'ant', 'api03-ABCDEF1234567890abcdefGHIJKLMN'].join('-')
227
+ const URL_SECRET = 'https://user:hunter2pass@api.anthropic.com/v1/x?api_key=abc123secretval456'
228
+
229
+ const mkEvent = (kind: OperatorEvent['kind'], detail: string): OperatorEvent => ({
230
+ agent: 'gymbro',
231
+ kind,
232
+ detail,
233
+ suggestedActions: [],
234
+ firstSeenAt: now,
235
+ })
236
+
237
+ // Mirrors emitGatewayOperatorEvent's transform: redact the DETAIL first (via
238
+ // the real redact()), THEN render — so the scrub happens BEFORE the renderer's
239
+ // escapeMarkdown, exactly as production now does. Redacting the already-escaped
240
+ // final text would let url-query-param secrets (`api_key=…`) slip past.
241
+ const renderCardAsSent = (ev: OperatorEvent): string =>
242
+ renderOperatorEvent({ ...ev, detail: redact(ev.detail) }).text
243
+
244
+ it('stripRawErrorBytes alone LEAKS a bearer/api-key (proves redact is needed)', () => {
245
+ // Guard test: the shape-scrub inside the renderer is JSON-shape-only. If this
246
+ // ever stops leaking, the shape-scrub grew secret awareness.
247
+ expect(stripRawErrorBytes(`auth failed: Bearer ${BEARER}`)).toContain(BEARER)
248
+ })
249
+
250
+ it('pre-fix render (no redact) LEAKS the bearer; the production transform scrubs it', () => {
251
+ const ev = mkEvent('credentials-expired', `token rejected: Bearer ${BEARER}`)
252
+ // Renderer alone (pre-fix path): secret survives.
253
+ expect(renderOperatorEvent(ev).text).toContain(BEARER)
254
+ // Production transform (redact detail → render): secret is gone.
255
+ expect(renderCardAsSent(ev)).not.toContain(BEARER)
256
+ })
257
+
258
+ it('masks a url-embedded credential in a credit-exhausted card', () => {
259
+ const ev = mkEvent('credit-exhausted', `billing check: ${URL_SECRET}`)
260
+ const sent = renderCardAsSent(ev)
261
+ expect(sent).not.toContain('hunter2pass')
262
+ expect(sent).not.toContain('abc123secretval456')
263
+ })
264
+
265
+ it('masks a bearer key in an unknown-4xx card', () => {
266
+ const ev = mkEvent('unknown-4xx', `API Error: 400 · x-api-key ${BEARER}`)
267
+ expect(renderCardAsSent(ev)).not.toContain(BEARER)
268
+ })
269
+
270
+ it('the humanized (renderLlmError) card never relays raw detail — no secret to leak', () => {
271
+ // The humanized card's coreText is a per-kind TEMPLATE, never the raw detail,
272
+ // so a secret in the detail cannot reach it even before redaction.
273
+ const parsed = parseLlmError(`rate_limit_error · Bearer ${BEARER}`)
274
+ expect(renderLlmError(parsed, 'gymbro', 'Australia/Melbourne', now).text).not.toContain(BEARER)
174
275
  })
175
276
  })
176
277
 
@@ -4,7 +4,6 @@ import {
4
4
  normalizeParagraphBreaks,
5
5
  normalizePunctuation,
6
6
  stripExcessBold,
7
- addParagraphSpacers,
8
7
  splitMarkdownChunks,
9
8
  hardSliceToCap,
10
9
  RICH_MESSAGE_MAX_CHARS,
@@ -55,8 +54,10 @@ function referenceNormalize(rawText: string): { text: string; voiceReplaced: num
55
54
  return { text, voiceReplaced }
56
55
  }
57
56
 
58
- function referenceEffectiveText(text: string, literalText: boolean): string {
59
- return literalText ? text : addParagraphSpacers(text)
57
+ function referenceEffectiveText(text: string, _literalText: boolean): string {
58
+ // The NBSP paragraph-spacer pass was removed in the #2669 follow-up; both
59
+ // paths now pass the normalized text through unchanged.
60
+ return text
60
61
  }
61
62
 
62
63
  function referenceChunks(