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.
- package/dist/cli/switchroom.js +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +175 -220
- package/telegram-plugin/dist/server.js +6 -0
- package/telegram-plugin/format.ts +137 -213
- package/telegram-plugin/gateway/gateway.ts +40 -17
- package/telegram-plugin/gateway/outbound-send-path.ts +9 -7
- package/telegram-plugin/llm-error-present.ts +68 -30
- package/telegram-plugin/stream-reply-handler.ts +5 -14
- package/telegram-plugin/tests/format-consistency.test.ts +68 -53
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +5 -6
- package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +26 -0
- package/telegram-plugin/tests/llm-error-present.test.ts +110 -9
- package/telegram-plugin/tests/outbound-send-path.test.ts +4 -3
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +42 -100
- package/telegram-plugin/tests/stream-reply-handler.test.ts +9 -12
- package/telegram-plugin/tests/telegram-format.test.ts +86 -31
- package/telegram-plugin/tests/turn-flush-safety.test.ts +17 -21
- package/telegram-plugin/turn-flush-safety.ts +4 -3
|
@@ -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
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
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
|
|
317
|
+
const recommendation = buildRecommendation(parsed, tz)
|
|
318
|
+
if (recommendation) lines.push(recommendation)
|
|
280
319
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
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.
|
|
5
|
-
*
|
|
6
|
-
* blockquote/table boundaries), never
|
|
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('
|
|
104
|
-
|
|
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('
|
|
110
|
-
const out =
|
|
111
|
-
expect(out).toBe(
|
|
104
|
+
test('prose→prose 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('
|
|
115
|
-
|
|
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('
|
|
120
|
-
expect(
|
|
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('
|
|
132
|
-
|
|
133
|
-
expect(
|
|
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('
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
expect(
|
|
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(
|
|
132
|
+
expect(normalizeParagraphBreaks(input)).toBe(input)
|
|
152
133
|
})
|
|
153
134
|
|
|
154
|
-
test('full pipeline: mixed prose+list
|
|
155
|
-
const raw = 'Summary line.\n- item one\n- item two\nClosing prose.'
|
|
156
|
-
const out =
|
|
157
|
-
|
|
158
|
-
expect(out).
|
|
159
|
-
|
|
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
|
|
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${
|
|
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 ->
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
)
|
|
173
|
-
|
|
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,
|
|
59
|
-
|
|
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(
|