switchroom 0.19.12 → 0.19.13

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.
@@ -0,0 +1,347 @@
1
+ /**
2
+ * temporal-normalize.ts — deterministic outbound temporal normalization (#3501).
3
+ *
4
+ * Two independent, idempotent rewrites applied to outbound Telegram prose, in
5
+ * the shared `normalizeOutboundBody` seam (after redact/punctuation/bold,
6
+ * before the voice scrub):
7
+ *
8
+ * 1. UTC/Zulu datetimes → the agent's LOCAL wall clock. A raw
9
+ * `2026-07-23T09:15:00Z` (or `…+00:00`, or a `14:00 UTC` / `2 pm GMT`
10
+ * label) is rewritten to `Thu 23 Jul 7:15 pm AEST` — DST-correct by
11
+ * construction (Intl resolves the per-instant offset) — so the operator
12
+ * never has to translate UTC in their head, and the model can never re-read
13
+ * its own outbound as "now in UTC".
14
+ *
15
+ * 2. Relative-day accuracy. A relative day word (today / tomorrow / yesterday /
16
+ * tonight / this morning|afternoon|evening) written ADJACENT to an absolute
17
+ * date is checked against the current date in the agent's configured TZ and
18
+ * corrected if wrong. The incident: a cron notification said "closed
19
+ * tomorrow (Thu 23 Jul)" when today WAS Thursday 23 Jul — it should read
20
+ * "closed today (Thu 23 Jul)". A relative word with NO adjacent absolute
21
+ * date is left untouched.
22
+ *
23
+ * Design constraints (all enforced here):
24
+ * - Pure over its arguments — `tz` and `nowMs` are passed in (callers use
25
+ * `resolveEnvTimezone()` and `Date.now()`); NO env / clock read here.
26
+ * - Never-throw: a bad/unresolvable IANA `tz` degrades to the INPUT unchanged
27
+ * rather than crashing a turn (mirrors `fmtLocalStamp`'s contract).
28
+ * - Intl-only: ZERO date libraries — reuses `localDay` / `tzAbbrev` from
29
+ * shared/local-time.ts.
30
+ * - False-positive guard: code fences, inline code, markdown link hrefs, and
31
+ * angle-bracket autolinks are masked before either pass, so an ISO string in
32
+ * a log / URL / code span is never rewritten. Blockquote (`>`) lines are
33
+ * excluded too — forwarded/quoted third-party text is left verbatim.
34
+ * - Idempotent: the UTC output carries no Z/UTC/GMT token (the detector can't
35
+ * re-match it); the relative-day output is a fixed point once the word and
36
+ * date agree.
37
+ */
38
+
39
+ import { localDay, tzAbbrev } from './shared/local-time.js'
40
+
41
+ // ── Combined false-positive masker ─────────────────────────────────────────
42
+ // Covers the SAME regions normalizePunctuation protects (fenced blocks, inline
43
+ // code, `](href)` link destinations, `<scheme:…>` autolinks) — lifted here as a
44
+ // single shared masker because `maskCodeRegions` (format.ts) alone does NOT
45
+ // cover link hrefs / autolinks (that logic lives inline inside normalizePunctuation).
46
+ interface MaskedRegions {
47
+ masked: string
48
+ restore: (s: string) => string
49
+ }
50
+
51
+ function maskTemporalRegions(text: string): MaskedRegions {
52
+ const nonce = Math.random().toString(36).slice(2)
53
+ const PH = `\x00TN${nonce}_`
54
+ const masks: string[] = []
55
+ const push = (s: string): string => {
56
+ masks.push(s)
57
+ return `${PH}${masks.length - 1}\x00`
58
+ }
59
+ const masked = text
60
+ // Fenced blocks first, only when CLOSED (matching ```), so an unclosed
61
+ // fence is left intact rather than misparsed by the inline pass.
62
+ .replace(/```[\s\S]*?```/g, (m) => push(m))
63
+ // Inline code spans.
64
+ .replace(/`[^`\n]+`/g, (m) => push(m))
65
+ // Markdown inline-link DESTINATIONS `](href)` — only the href inside the
66
+ // parens is masked; the visible label still normalizes like prose.
67
+ .replace(/(\]\()([^)\n]*)(\))/g, (_m, open: string, href: string, close: string) =>
68
+ `${open}${push(href)}${close}`,
69
+ )
70
+ // GFM angle-bracket autolink destinations `<scheme:…>`.
71
+ .replace(/(<)([a-zA-Z][a-zA-Z0-9+.-]*:[^>\s]*)(>)/g, (_m, lt: string, uri: string, gt: string) =>
72
+ `${lt}${push(uri)}${gt}`,
73
+ )
74
+ const esc = nonce.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
75
+ const re = new RegExp(`\x00TN${esc}_(\\d+)\x00`, 'g')
76
+ const restore = (s: string): string => s.replace(re, (_m, idx: string) => masks[Number(idx)] ?? _m)
77
+ return { masked, restore }
78
+ }
79
+
80
+ /** A blockquote line (mirrors format.ts `isBlockquoteLine`). */
81
+ function isBlockquoteLine(line: string): boolean {
82
+ return line.trimStart().startsWith('>')
83
+ }
84
+
85
+ /** Run a per-line transform on masked text, leaving blockquote lines verbatim. */
86
+ function perNonQuoteLine(masked: string, fn: (line: string) => string): string {
87
+ return masked
88
+ .split('\n')
89
+ .map((line) => (isBlockquoteLine(line) ? line : fn(line)))
90
+ .join('\n')
91
+ }
92
+
93
+ // ── Local rendering ─────────────────────────────────────────────────────────
94
+
95
+ /** `Thu 23 Jul` — weekday + day + short month in `tz`, no locale punctuation. */
96
+ function renderLocalDate(ms: number, tz: string): string {
97
+ const parts = new Intl.DateTimeFormat('en-US', {
98
+ timeZone: tz,
99
+ weekday: 'short',
100
+ day: 'numeric',
101
+ month: 'short',
102
+ }).formatToParts(new Date(ms))
103
+ const get = (t: string): string => parts.find((p) => p.type === t)?.value ?? ''
104
+ return `${get('weekday')} ${get('day')} ${get('month')}`
105
+ }
106
+
107
+ /** `7:15 pm` — spaced, lowercased am/pm wall clock in `tz`. */
108
+ function renderLocalClock(ms: number, tz: string): string {
109
+ return new Intl.DateTimeFormat('en-US', {
110
+ timeZone: tz,
111
+ hour: 'numeric',
112
+ minute: '2-digit',
113
+ hour12: true,
114
+ })
115
+ .format(new Date(ms))
116
+ .replace(/\s([AP])M$/, (_m, p: string) => ` ${p.toLowerCase()}m`)
117
+ }
118
+
119
+ /** `Thu 23 Jul 7:15 pm AEST` — full local datetime for a converted instant. */
120
+ function renderLocalDatetime(ms: number, tz: string): string {
121
+ return `${renderLocalDate(ms, tz)} ${renderLocalClock(ms, tz)} ${tzAbbrev(ms, tz)}`
122
+ }
123
+
124
+ // ── Pass 1: UTC / Zulu datetime → local ─────────────────────────────────────
125
+
126
+ // Full ISO-8601 with a Z or +00:00 zero-offset. Seconds + fractional seconds
127
+ // optional. The trailing marker guarantees the output (which carries none)
128
+ // cannot re-match — idempotent by construction.
129
+ const ISO_UTC_RE = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|\+00:00)/g
130
+
131
+ // Labelled clock time tagged UTC/GMT. The minutes / am|pm are OPTIONAL in the
132
+ // pattern but the replacer REQUIRES at least one of them (colon-minutes OR
133
+ // am/pm) before converting — so a bare "5 GMT" (as in "9 to 5 GMT") is left
134
+ // untouched, matching the tightened grammar.
135
+ const LABELLED_UTC_RE = /\b(\d{1,2})(?::(\d{2}))?\s?(am|pm)?\s?(UTC|GMT)\b/gi
136
+
137
+ function convertUtcInLine(line: string, tz: string, nowMs: number): string {
138
+ let out = line.replace(ISO_UTC_RE, (m) => {
139
+ const ms = Date.parse(m)
140
+ if (Number.isNaN(ms)) return m
141
+ return renderLocalDatetime(ms, tz)
142
+ })
143
+ out = out.replace(LABELLED_UTC_RE, (m, hh: string, mm: string | undefined, ampm: string | undefined) => {
144
+ // Tightened grammar: require colon-minutes OR am/pm — reject bare "5 GMT".
145
+ if (mm == null && ampm == null) return m
146
+ let h = Number(hh)
147
+ const pm = ampm != null && /pm/i.test(ampm)
148
+ if (ampm != null) {
149
+ if (h > 12) return m // "14 pm" is nonsensical — leave untouched
150
+ if (h === 12) h = pm ? 12 : 0
151
+ else if (pm) h += 12
152
+ }
153
+ if (h > 23) return m
154
+ const minute = mm != null ? Number(mm) : 0
155
+ if (minute > 59) return m
156
+ const now = new Date(nowMs)
157
+ // Interpret the labelled time as UTC on nowMs's UTC calendar date, then
158
+ // render the LOCAL wall clock. No date is emitted (the source carried none).
159
+ const ms = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), h, minute)
160
+ return `${renderLocalClock(ms, tz)} ${tzAbbrev(ms, tz)}`
161
+ })
162
+ return out
163
+ }
164
+
165
+ /**
166
+ * Rewrite UTC/Zulu datetimes in `text` to the local wall clock in `tz`.
167
+ * Pure / never-throws: an invalid `tz` (or any internal failure) returns the
168
+ * input unchanged.
169
+ */
170
+ export function normalizeUtcDatetimes(text: string, tz: string, nowMs: number): string {
171
+ if (!text) return text
172
+ try {
173
+ // Fast-path: nothing that could be a UTC datetime.
174
+ if (!/\d{4}-\d{2}-\d{2}T|\b(?:UTC|GMT)\b/i.test(text)) return text
175
+ const { masked, restore } = maskTemporalRegions(text)
176
+ const out = perNonQuoteLine(masked, (line) => convertUtcInLine(line, tz, nowMs))
177
+ return restore(out)
178
+ } catch {
179
+ return text
180
+ }
181
+ }
182
+
183
+ // ── Pass 2: relative-day accuracy ───────────────────────────────────────────
184
+
185
+ const REL = '(today|tonight|this\\s+morning|this\\s+afternoon|this\\s+evening|tomorrow|yesterday)'
186
+ // A small, strict separator set: whitespace / commas, an optional "on ", and an
187
+ // optional opening paren. Bounded (only spaces/commas), so the effective window
188
+ // stays small — no `.{0,15}` wildcard that could swallow arbitrary prose.
189
+ const SEP = '([\\s,]*(?:on\\s+)?\\(?\\s*)'
190
+ const WD = '(?:mon|tue|wed|thu|fri|sat|sun)[a-z]*'
191
+ const MON = '(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*'
192
+ // One optional weekday token (captured, with its trailing separator), then one
193
+ // absolute-date core (captured whole; re-parsed in the replacer).
194
+ const WD_GROUP = `((?:${WD})[\\s,]+)?`
195
+ const DATE_CORE =
196
+ `((?:\\d{1,2}\\s+${MON})|(?:${MON}\\s+\\d{1,2})|(?:\\d{4}-\\d{2}-\\d{2})|(?:\\d{1,2}\\/\\d{2}))`
197
+
198
+ const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
199
+ const WEEKDAY_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
200
+
201
+ function buildRelRegex(): RegExp {
202
+ return new RegExp(REL + SEP + WD_GROUP + DATE_CORE, 'gi')
203
+ }
204
+
205
+ interface ParsedDate {
206
+ year: number | null
207
+ month: number // 1-12
208
+ day: number
209
+ }
210
+
211
+ /** Parse a matched absolute-date core into {year?, month, day}, or null. */
212
+ function parseDateCore(core: string): ParsedDate | null {
213
+ let m: RegExpExecArray | null
214
+ if ((m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(core))) {
215
+ return { year: Number(m[1]), month: Number(m[2]), day: Number(m[3]) }
216
+ }
217
+ if ((m = /^(\d{1,2})\/(\d{2})$/.exec(core))) {
218
+ // DD/MM (fleet convention — matches the "23/07" nice-to-have).
219
+ return { year: null, month: Number(m[2]), day: Number(m[1]) }
220
+ }
221
+ if ((m = /^(\d{1,2})\s+([a-z]+)$/i.exec(core))) {
222
+ const mon = MONTHS.indexOf(m[2].slice(0, 3).toLowerCase())
223
+ if (mon < 0) return null
224
+ return { year: null, month: mon + 1, day: Number(m[1]) }
225
+ }
226
+ if ((m = /^([a-z]+)\s+(\d{1,2})$/i.exec(core))) {
227
+ const mon = MONTHS.indexOf(m[1].slice(0, 3).toLowerCase())
228
+ if (mon < 0) return null
229
+ return { year: null, month: mon + 1, day: Number(m[2]) }
230
+ }
231
+ return null
232
+ }
233
+
234
+ /** UTC-midnight day index for a Y-M-D (calendar-day math, DST-irrelevant). */
235
+ function dayIndex(y: number, mo1: number, d: number): number {
236
+ return Math.floor(Date.UTC(y, mo1 - 1, d) / 86_400_000)
237
+ }
238
+
239
+ /** Resolve a possibly-yearless date to the occurrence nearest today-in-tz. */
240
+ function resolveYear(parsed: ParsedDate, todayY: number, todayIdx: number): number {
241
+ if (parsed.year != null) return parsed.year
242
+ let best = todayY
243
+ let bestAbs = Infinity
244
+ for (const y of [todayY - 1, todayY, todayY + 1]) {
245
+ const diff = Math.abs(dayIndex(y, parsed.month, parsed.day) - todayIdx)
246
+ if (diff < bestAbs) {
247
+ bestAbs = diff
248
+ best = y
249
+ }
250
+ }
251
+ return best
252
+ }
253
+
254
+ /** Preserve the original casing pattern of a rewritten day word. */
255
+ function matchCase(sample: string, replacement: string): string {
256
+ if (sample.length > 0 && sample[0] === sample[0].toUpperCase() && sample[0] !== sample[0].toLowerCase()) {
257
+ return replacement.charAt(0).toUpperCase() + replacement.slice(1)
258
+ }
259
+ return replacement
260
+ }
261
+
262
+ const TIME_OF_DAY_RE = /^(tonight|this\s+)/i
263
+
264
+ function rewriteRelativeInLine(line: string, tz: string, nowMs: number): string {
265
+ const today = localDay(nowMs, tz) // YYYY-MM-DD (throws first on a bad tz)
266
+ const [ty, tm, td] = today.split('-').map(Number)
267
+ const todayIdx = dayIndex(ty, tm, td)
268
+
269
+ return line.replace(buildRelRegex(), (full, rel: string, sep: string, wdGroup: string | undefined, core: string) => {
270
+ const parsed = parseDateCore(core)
271
+ if (parsed == null) return full
272
+ const year = resolveYear(parsed, ty, todayIdx)
273
+ const targetIdx = dayIndex(year, parsed.month, parsed.day)
274
+ const diff = targetIdx - todayIdx
275
+
276
+ // Out-of-range (|diff| > 1): leave the ENTIRE match untouched — do NOT
277
+ // invent a word and do NOT auto-delete (open question 3 resolution).
278
+ if (diff < -1 || diff > 1) return full
279
+
280
+ const isTimeOfDay = TIME_OF_DAY_RE.test(rel)
281
+
282
+ // Determine the corrected relative word.
283
+ let newRel = rel
284
+ if (isTimeOfDay) {
285
+ // tonight / this morning|afternoon|evening are a correct SUBSET of today.
286
+ // Only valid when the date resolves to today (diff==0); never remap them
287
+ // to bare tomorrow/yesterday. Otherwise leave the whole match untouched.
288
+ if (diff !== 0) return full
289
+ // diff==0: the word is already correct — keep it verbatim.
290
+ } else {
291
+ const want = diff === 1 ? 'tomorrow' : diff === 0 ? 'today' : 'yesterday'
292
+ newRel = matchCase(rel, want)
293
+ }
294
+
295
+ // Weekday repair: if a weekday token precedes the date and disagrees with
296
+ // the true weekday of the resolved date in-tz, correct it.
297
+ let newWdGroup = wdGroup
298
+ if (wdGroup != null) {
299
+ const trueDow = new Date(Date.UTC(year, parsed.month - 1, parsed.day)).getUTCDay()
300
+ const trueShort = WEEKDAY_SHORT[trueDow]
301
+ // Preserve the original separator tail after the weekday token.
302
+ const wdMatch = /^([a-z]+)([\s,]+)$/i.exec(wdGroup)
303
+ if (wdMatch != null) {
304
+ const writtenShort = wdMatch[1].slice(0, 3).toLowerCase()
305
+ if (writtenShort !== trueShort.toLowerCase()) {
306
+ const wasLong = wdMatch[1].length > 3
307
+ const corrected = wasLong ? longWeekday(trueDow) : trueShort
308
+ newWdGroup = matchCase(wdMatch[1], corrected) + wdMatch[2]
309
+ }
310
+ }
311
+ }
312
+
313
+ return `${newRel}${sep}${newWdGroup ?? ''}${core}`
314
+ })
315
+ }
316
+
317
+ const WEEKDAY_LONG = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
318
+ function longWeekday(dow: number): string {
319
+ return WEEKDAY_LONG[dow]
320
+ }
321
+
322
+ /**
323
+ * Correct relative-day words that are wrong for the absolute date they sit next
324
+ * to, resolved against `nowMs` in `tz`. Pure / never-throws.
325
+ */
326
+ export function normalizeRelativeDays(text: string, tz: string, nowMs: number): string {
327
+ if (!text) return text
328
+ try {
329
+ if (!/\b(today|tonight|this\s+(?:morning|afternoon|evening)|tomorrow|yesterday)\b/i.test(text)) {
330
+ return text
331
+ }
332
+ const { masked, restore } = maskTemporalRegions(text)
333
+ const out = perNonQuoteLine(masked, (line) => rewriteRelativeInLine(line, tz, nowMs))
334
+ return restore(out)
335
+ } catch {
336
+ return text
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Convenience composition: UTC→local FIRST (so it produces absolute local
342
+ * datetimes the relative-day pass can then check), THEN relative-day accuracy.
343
+ * Pure / never-throws — each sub-pass degrades to its input on failure.
344
+ */
345
+ export function normalizeTemporal(text: string, tz: string, nowMs: number): string {
346
+ return normalizeRelativeDays(normalizeUtcDatetimes(text, tz, nowMs), tz, nowMs)
347
+ }
@@ -53,29 +53,39 @@ describe('gateway outbound secret-scrub — structural wiring', () => {
53
53
  // delegates via `normalizeOutboundBody(rawText, 'reply', redactOutboundText)`
54
54
  // — the injected redactor still runs at entry, before the stderr preview.
55
55
  const start = sendPathSrc.indexOf('export async function sendReply(')
56
- const redactIdx = sendPathSrc.indexOf(`normalizeOutboundBody(rawText, 'reply', redactOutboundText)`, start)
56
+ const redactIdx = sendPathSrc.indexOf(`normalizeOutboundBody(rawText, 'reply', redactOutboundText`, start)
57
57
  const previewIdx = sendPathSrc.indexOf('reply: invoked chatId=', start)
58
58
  expect(start).toBeGreaterThan(0)
59
59
  expect(redactIdx).toBeGreaterThan(start)
60
60
  expect(previewIdx).toBeGreaterThan(redactIdx) // mask BEFORE the preview is logged
61
61
  })
62
62
 
63
- it('edit_message: scrubs at entry, before the voice scrub + send', () => {
63
+ it('edit_message: scrubs via the shared seam at entry, before the send (#3501)', () => {
64
+ // #3501: edit_message routes through normalizeOutboundBody, passing the
65
+ // injected redactor with the 'edit_message' site. The seam runs redact
66
+ // internally (pinned in outbound-send-path.ts), so the mask still fires at
67
+ // entry — before the editMessageText send below.
64
68
  const start = src.indexOf('async function executeEditMessage(')
65
- const redactIdx = src.indexOf(`redactOutboundText(editRawText, 'edit_message')`, start)
66
- const scrubIdx = src.indexOf(`site: 'edit_message'`, start)
69
+ const seamIdx = src.indexOf(`'edit_message',`, start)
70
+ const redactArgIdx = src.indexOf('redactOutboundText,', seamIdx)
71
+ const sendIdx = src.indexOf('editMessageText(', start)
67
72
  expect(start).toBeGreaterThan(0)
68
- expect(redactIdx).toBeGreaterThan(start)
69
- expect(scrubIdx).toBeGreaterThan(redactIdx)
73
+ expect(seamIdx).toBeGreaterThan(start)
74
+ expect(redactArgIdx).toBeGreaterThan(seamIdx) // redactor passed into the seam
75
+ expect(sendIdx).toBeGreaterThan(redactArgIdx) // mask BEFORE the send
70
76
  })
71
77
 
72
- it('turn-flush backstop: scrubs the model terminal prose before send', () => {
78
+ it('turn-flush backstop: scrubs the model terminal prose before send (#3501)', () => {
73
79
  // Turn-flush delivers the model's answer when it skipped reply/stream_reply
74
- // — arbitrary agent free-text that hits the wire + stderr preview.
75
- const redactIdx = streamSrc.indexOf(`redactOutboundText(capturedText, 'turn_flush')`)
76
- const scrubSiteIdx = streamSrc.indexOf(`site: 'turn_flush'`)
77
- expect(redactIdx).toBeGreaterThan(0)
78
- expect(scrubSiteIdx).toBeGreaterThan(redactIdx) // mask BEFORE the voice scrub + send
80
+ // — arbitrary agent free-text that hits the wire + stderr preview. #3501:
81
+ // it routes through normalizeOutboundBody('turn_flush', redactOutboundText),
82
+ // which redacts internally before the voice-scrub + send.
83
+ const seamIdx = streamSrc.indexOf('normalizeOutboundBody(')
84
+ const siteIdx = streamSrc.indexOf(`'turn_flush',`, seamIdx)
85
+ const redactArgIdx = streamSrc.indexOf('redactOutboundText,', seamIdx)
86
+ expect(seamIdx).toBeGreaterThan(0)
87
+ expect(siteIdx).toBeGreaterThan(seamIdx) // seam invoked with the turn_flush site
88
+ expect(redactArgIdx).toBeGreaterThan(seamIdx) // redactor passed into the seam
79
89
  })
80
90
 
81
91
  it('progress_update: scrubs at entry, BEFORE the 300-char truncation', () => {
@@ -79,6 +79,46 @@ function referenceResplit(chunk: string): string[] {
79
79
 
80
80
  const injectedRedact = (text: string, _site: string): string => redact(text)
81
81
 
82
+ // ── Verbatim inline reference for the FORMER edit_message pipeline ──────────
83
+ // (executeEditMessage, pre-#3501): repair → [paragraph-break if !literal] →
84
+ // redact → [addParagraphSpacers(stripExcessBold(normalizePunctuation)) if
85
+ // !literal] → voice scrub. Consolidated into normalizeOutboundBody's
86
+ // {literalText, addSpacers} options; this reference pins byte-equivalence.
87
+ function referenceEditNormalize(
88
+ rawText: string,
89
+ literalText: boolean,
90
+ ): { text: string; voiceReplaced: number } {
91
+ let editRawText = repairEscapedWhitespace(rawText)
92
+ if (!literalText) editRawText = normalizeParagraphBreaks(editRawText)
93
+ editRawText = redact(editRawText)
94
+ if (!literalText) editRawText = addParagraphSpacers(stripExcessBold(normalizePunctuation(editRawText)))
95
+ let voiceReplaced = 0
96
+ const scrub = scrubVoice(editRawText)
97
+ if (scrub.replaced > 0) {
98
+ editRawText = scrub.scrubbed
99
+ voiceReplaced = scrub.replaced
100
+ }
101
+ return { text: editRawText, voiceReplaced }
102
+ }
103
+
104
+ // ── Verbatim inline reference for the FORMER turn-flush pipeline ────────────
105
+ // (stream-render.ts turn-flush branch, pre-#3501): normalizeParagraphBreaks(
106
+ // repairEscapedWhitespace) → redact('turn_flush') → stripExcessBold(
107
+ // normalizePunctuation) → voice scrub. This is normalizeOutboundBody's DEFAULT
108
+ // shape — identical to the reply pipeline. Consolidated to a single call.
109
+ function referenceTurnFlushNormalize(rawText: string): { text: string; voiceReplaced: number } {
110
+ let text = normalizeParagraphBreaks(repairEscapedWhitespace(rawText))
111
+ text = redact(text)
112
+ text = stripExcessBold(normalizePunctuation(text))
113
+ let voiceReplaced = 0
114
+ const scrub = scrubVoice(text)
115
+ if (scrub.replaced > 0) {
116
+ text = scrub.scrubbed
117
+ voiceReplaced = scrub.replaced
118
+ }
119
+ return { text, voiceReplaced }
120
+ }
121
+
82
122
  // ── Representative outbound fixtures ────────────────────────────────────────
83
123
 
84
124
  const FIXTURES: Record<string, string> = {
@@ -121,6 +161,73 @@ describe('outbound-send-path — normalizeOutboundBody parity with inline pipeli
121
161
  })
122
162
  })
123
163
 
164
+ // #3501 — the edit_message and turn-flush sites were hand-mirrored inline
165
+ // pipelines that now route through normalizeOutboundBody. These pin that the
166
+ // {literalText, addSpacers} options reproduce the former inline output
167
+ // byte-for-byte, so the consolidation is provably behaviour-neutral.
168
+ describe('outbound-send-path — edit_message option parity (#3501)', () => {
169
+ for (const [name, raw] of Object.entries(FIXTURES)) {
170
+ it(`rich edit (addSpacers) byte-identical: ${name}`, () => {
171
+ const ref = referenceEditNormalize(raw, false)
172
+ const got = normalizeOutboundBody(raw, 'edit_message', injectedRedact, {
173
+ literalText: false,
174
+ addSpacers: true,
175
+ })
176
+ expect(got.text).toBe(ref.text)
177
+ expect(got.voiceReplaced).toBe(ref.voiceReplaced)
178
+ })
179
+
180
+ it(`literal edit (literalText) byte-identical: ${name}`, () => {
181
+ const ref = referenceEditNormalize(raw, true)
182
+ const got = normalizeOutboundBody(raw, 'edit_message', injectedRedact, {
183
+ literalText: true,
184
+ addSpacers: false,
185
+ })
186
+ expect(got.text).toBe(ref.text)
187
+ expect(got.voiceReplaced).toBe(ref.voiceReplaced)
188
+ })
189
+ }
190
+
191
+ it('literal edit skips paragraph/punctuation/bold/spacer formatting', () => {
192
+ // A literal edit must NOT promote lone newlines, strip bold, or add
193
+ // spacers — only repair + redact + voice scrub run.
194
+ const raw = 'First line.\nSecond line with **bold** kept.'
195
+ const got = normalizeOutboundBody(raw, 'edit_message', injectedRedact, {
196
+ literalText: true,
197
+ })
198
+ expect(got.text).toBe(raw) // no transform touched it
199
+ })
200
+
201
+ it('rich edit DOES add the U+00A0 paragraph spacer that reply omits', () => {
202
+ const raw = FIXTURES.multiParagraph
203
+ const richEdit = normalizeOutboundBody(raw, 'edit_message', injectedRedact, {
204
+ addSpacers: true,
205
+ }).text
206
+ const reply = normalizeOutboundBody(raw, 'reply', injectedRedact).text
207
+ // The edit path folds addParagraphSpacers in; the reply path does not.
208
+ expect(richEdit).toBe(addParagraphSpacers(reply))
209
+ expect(richEdit).not.toBe(reply)
210
+ })
211
+ })
212
+
213
+ describe('outbound-send-path — turn_flush option parity (#3501)', () => {
214
+ for (const [name, raw] of Object.entries(FIXTURES)) {
215
+ it(`turn-flush default byte-identical: ${name}`, () => {
216
+ const ref = referenceTurnFlushNormalize(raw)
217
+ const got = normalizeOutboundBody(raw, 'turn_flush', injectedRedact)
218
+ expect(got.text).toBe(ref.text)
219
+ expect(got.voiceReplaced).toBe(ref.voiceReplaced)
220
+ })
221
+ }
222
+
223
+ it('turn-flush default equals the reply pipeline (same shape)', () => {
224
+ const raw = FIXTURES.emDashes
225
+ const flush = normalizeOutboundBody(raw, 'turn_flush', injectedRedact).text
226
+ const reply = normalizeOutboundBody(raw, 'reply', injectedRedact).text
227
+ expect(flush).toBe(reply)
228
+ })
229
+ })
230
+
124
231
  describe('outbound-send-path — effective text + chunk parity', () => {
125
232
  for (const [name, raw] of Object.entries(FIXTURES)) {
126
233
  for (const literalText of [true, false]) {
@@ -222,3 +329,87 @@ describe('outbound-send-path — cross-surface dedup suppression', () => {
222
329
  expect(dedup.check(chatId, undefined, text, t0 + 500, 'turn-2')).toBeNull()
223
330
  })
224
331
  })
332
+
333
+ // ── #3501 temporal-normalization wiring into the shared seam ────────────────
334
+ import { readFileSync } from 'node:fs'
335
+ import { normalizeTemporal } from '../temporal-normalize.js'
336
+
337
+ const TZ_MEL = 'Australia/Melbourne'
338
+ const NOW_THU = Date.UTC(2026, 6, 23, 2, 0, 0) // Thu 2026-07-23 midday Melbourne
339
+
340
+ describe('outbound-send-path — temporal pass wiring (#3501)', () => {
341
+ it('fires when tz+nowMs are supplied — fixes the cron/mail-watcher incident', () => {
342
+ const incident = 'Heads up: the office is closed tomorrow (Thu 23 Jul).'
343
+ const got = normalizeOutboundBody(incident, 'turn_flush', injectedRedact, {
344
+ tz: TZ_MEL,
345
+ nowMs: NOW_THU,
346
+ })
347
+ expect(got.text).toBe('Heads up: the office is closed today (Thu 23 Jul).')
348
+ })
349
+
350
+ it('is a NO-OP when tz is omitted (keeps the seam pure/clock-free by default)', () => {
351
+ const incident = 'closed tomorrow (Thu 23 Jul)'
352
+ // No tz → temporal must not fire; output is the plain formatted pipeline.
353
+ const got = normalizeOutboundBody(incident, 'reply', injectedRedact)
354
+ expect(got.text).toBe(incident)
355
+ })
356
+
357
+ it('REVIEW FIX 3 — a literal edit does NOT run the temporal pass', () => {
358
+ const incident = 'closed tomorrow (Thu 23 Jul)'
359
+ const got = normalizeOutboundBody(incident, 'edit_message', injectedRedact, {
360
+ literalText: true,
361
+ tz: TZ_MEL,
362
+ nowMs: NOW_THU,
363
+ })
364
+ // Literal edit lands byte-for-byte — the stale relative word is preserved.
365
+ expect(got.text).toBe(incident)
366
+ })
367
+
368
+ it('golden pipeline ORDER — temporal output is not mangled by the voice scrub', () => {
369
+ // A UTC datetime is rewritten to "Thu 23 Jul 7:15 pm AEST"; the voice scrub
370
+ // (em/en dash → comma) that runs AFTER must not touch it.
371
+ const raw = 'window 2026-07-23T09:15:00Z opens'
372
+ const got = normalizeOutboundBody(raw, 'reply', injectedRedact, {
373
+ tz: TZ_MEL,
374
+ nowMs: NOW_THU,
375
+ })
376
+ expect(got.text).toBe('window Thu 23 Jul 7:15 pm AEST opens')
377
+ })
378
+
379
+ it('temporal runs AFTER punctuation/bold and BEFORE scrubVoice (structural pin)', () => {
380
+ const src = readFileSync(new URL('../gateway/outbound-send-path.ts', import.meta.url), 'utf8')
381
+ const start = src.indexOf('export function normalizeOutboundBody(')
382
+ const boldIdx = src.indexOf('stripExcessBold(normalizePunctuation(text))', start)
383
+ const temporalIdx = src.indexOf('normalizeTemporal(text, tz, nowMs)', start)
384
+ const scrubIdx = src.indexOf('scrubVoice(text)', start)
385
+ expect(boldIdx).toBeGreaterThan(start)
386
+ expect(temporalIdx).toBeGreaterThan(boldIdx) // AFTER punctuation/bold
387
+ expect(scrubIdx).toBeGreaterThan(temporalIdx) // BEFORE the voice scrub
388
+ })
389
+
390
+ it('per-site wiring pin — all three prose sends pass tz+nowMs into the seam', () => {
391
+ const sendPath = readFileSync(new URL('../gateway/outbound-send-path.ts', import.meta.url), 'utf8')
392
+ const gateway = readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8')
393
+ const streamRender = readFileSync(new URL('../gateway/stream-render.ts', import.meta.url), 'utf8')
394
+ // Reply site (sendReply) passes tz+nowMs.
395
+ const replyStart = sendPath.indexOf(`normalizeOutboundBody(rawText, 'reply', redactOutboundText`)
396
+ expect(replyStart).toBeGreaterThan(0)
397
+ expect(sendPath.indexOf('tz: resolveEnvTimezone()', replyStart)).toBeGreaterThan(replyStart)
398
+ // edit_message site passes tz+nowMs.
399
+ const editStart = gateway.indexOf(`'edit_message',`)
400
+ expect(gateway.indexOf('tz: resolveEnvTimezone()', editStart)).toBeGreaterThan(editStart)
401
+ // turn-flush site passes tz+nowMs.
402
+ const flushStart = streamRender.indexOf(`'turn_flush',`)
403
+ expect(streamRender.indexOf('tz: resolveEnvTimezone()', flushStart)).toBeGreaterThan(flushStart)
404
+ })
405
+
406
+ it('temporal wiring matches the standalone module output (no drift)', () => {
407
+ const raw = 'ships tomorrow (Thu 23 Jul), at 2026-07-23T09:15:00Z'
408
+ const viaSeam = normalizeOutboundBody(raw, 'reply', injectedRedact, {
409
+ tz: TZ_MEL,
410
+ nowMs: NOW_THU,
411
+ }).text
412
+ const viaModule = normalizeTemporal(raw, TZ_MEL, NOW_THU)
413
+ expect(viaSeam).toBe(viaModule)
414
+ })
415
+ })