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.
@@ -24,12 +24,29 @@ import {
24
24
  mkdirSync,
25
25
  writeFileSync,
26
26
  readFileSync,
27
+ readdirSync,
27
28
  existsSync,
28
29
  rmSync,
29
30
  } from 'node:fs'
30
31
  import { tmpdir } from 'node:os'
31
32
  import { join, resolve } from 'node:path'
32
33
 
34
+ /**
35
+ * Read the single outbox record the guaranteed-delivery capture writes for a
36
+ * turn that ended with substantive undelivered prose. Returns null when none.
37
+ */
38
+ function readOutboxRecord(stateDir: string): { text: string; chatId: string | null; turnNonce: string } | null {
39
+ const dir = join(stateDir, 'outbox')
40
+ let files: string[]
41
+ try {
42
+ files = readdirSync(dir).filter((f) => f.endsWith('.json') && f !== 'delivered.jsonl')
43
+ } catch {
44
+ return null
45
+ }
46
+ if (files.length !== 1) return null
47
+ return JSON.parse(readFileSync(join(dir, files[0]), 'utf8'))
48
+ }
49
+
33
50
  const HOOK_PATH = resolve(
34
51
  __dirname,
35
52
  '..',
@@ -108,7 +125,12 @@ describe('silent-end-interrupt-stop.mjs — integration', () => {
108
125
  expect(existsSync(join(stateDir, 'silent-end-pending.json'))).toBe(false)
109
126
  })
110
127
 
111
- it("blocks + writes retryCount=1 when transcript shows ack-only (Ken's repro)", () => {
128
+ it('captures the trailing answer to the outbox + allows when transcript shows ack-only + undelivered verdict (Ken\'s repro, collapsed design)', () => {
129
+ // Collapsed guaranteed-delivery design: an ack followed by a substantive
130
+ // plain-text verdict is now CAPTURED to the durable outbox and the stop is
131
+ // ALLOWED — the gateway sweep is the single deterministic deliverer. The old
132
+ // block/re-prompt (which depended on the model re-replying) is superseded
133
+ // for capturable prose.
112
134
  const transcript = writeTranscript(tmp, [
113
135
  ENQUEUE,
114
136
  reply('on it — checking now', { disable_notification: true }),
@@ -124,23 +146,12 @@ describe('silent-end-interrupt-stop.mjs — integration', () => {
124
146
  stateDir,
125
147
  })
126
148
  expect(r.status).toBe(0)
127
- const out = JSON.parse(r.stdout)
128
- expect(out.decision).toBe('block')
129
- expect(out.reason).toMatch(/Send your final answer/)
130
- expect(out.reason).toMatch(/NO_REPLY/)
131
- // Retry-count file was written.
132
- const statePath = join(stateDir, 'silent-end-pending.json')
133
- expect(existsSync(statePath)).toBe(true)
134
- const state = JSON.parse(readFileSync(statePath, 'utf8'))
135
- expect(state.retryCount).toBe(1)
136
- // Reviewer-flagged regression: the hook's state-file write MUST
137
- // include turnKey + chatId derived from the enqueue envelope. Without
138
- // these, the gateway's later `recordSilentTurnEnd` write (~175ms after
139
- // the hook) sees a turnKey mismatch and resets retryCount to 0,
140
- // doubling the effective re-prompt budget. The shape here must match
141
- // `chatKey(chatId, threadId)` at telegram-plugin/gateway/chat-key.ts:46.
142
- expect(state.chatId).toBe('111')
143
- expect(state.turnKey).toBe('111:_')
149
+ expect(r.stdout.trim()).toBe('') // ALLOW — no block re-prompt
150
+ const rec = readOutboxRecord(stateDir)
151
+ expect(rec).not.toBeNull()
152
+ expect(rec!.text).toBe('A'.repeat(2237))
153
+ expect(rec!.chatId).toBe('111')
154
+ expect(rec!.turnNonce).toBe('111:_#42') // shared deriveTurnId nonce (H1)
144
155
  })
145
156
 
146
157
  it('preserves retryCount across the hook→gateway write order (reviewer regression)', () => {
@@ -190,17 +201,12 @@ describe('silent-end-interrupt-stop.mjs — integration', () => {
190
201
  expect(state.retryCount).toBe(SILENT_END_MAX_RETRIES)
191
202
  })
192
203
 
193
- it('blocks + writes retryCount=1 when an early qualifying reply is followed by an undelivered verdict (trailing-content bug repro)', () => {
194
- // Confirmed-incident shape: (1) a background-task notification
195
- // arrives, (2) the agent calls reply ONCE early with a stale,
196
- // notification-bearing ack ("running now" disable_notification
197
- // unset, so it satisfies isFinalAnswerReply regardless of length),
198
- // (3) the agent then does more work and writes a large substantive
199
- // verdict as plain assistant text with NO second reply call. Pre-fix,
200
- // the hook's "reply called at least once this turn" check allowed
201
- // this to slip through silently — the trailing verdict never reached
202
- // the user. Post-fix the hook must block on this shape exactly like
203
- // the zero-reply case.
204
+ it('captures the trailing verdict to the outbox + allows when an early qualifying reply is followed by an undelivered verdict (trailing-content bug repro, collapsed design)', () => {
205
+ // Confirmed-incident shape: (1) a background-task notification arrives,
206
+ // (2) the agent calls reply ONCE early with a notification-bearing ack,
207
+ // (3) it then writes a large substantive verdict as plain assistant text
208
+ // with NO second reply call. The trailing verdict is now CAPTURED to the
209
+ // outbox and the stop is ALLOWED the sweep delivers it deterministically.
204
210
  const transcript = writeTranscript(tmp, [
205
211
  ENQUEUE,
206
212
  reply('running now'),
@@ -216,15 +222,11 @@ describe('silent-end-interrupt-stop.mjs — integration', () => {
216
222
  stateDir,
217
223
  })
218
224
  expect(r.status).toBe(0)
219
- const out = JSON.parse(r.stdout)
220
- expect(out.decision).toBe('block')
221
- expect(out.reason).toMatch(/Send your final answer/)
222
- const statePath = join(stateDir, 'silent-end-pending.json')
223
- expect(existsSync(statePath)).toBe(true)
224
- const state = JSON.parse(readFileSync(statePath, 'utf8'))
225
- expect(state.retryCount).toBe(1)
226
- expect(state.chatId).toBe('111')
227
- expect(state.turnKey).toBe('111:_')
225
+ expect(r.stdout.trim()).toBe('')
226
+ const rec = readOutboxRecord(stateDir)
227
+ expect(rec).not.toBeNull()
228
+ expect(rec!.text).toBe('Here is the actual verdict: ' + 'X'.repeat(300))
229
+ expect(rec!.turnNonce).toBe('111:_#42')
228
230
  })
229
231
 
230
232
  it('does NOT false-positive on a normal single-reply turn ending on the reply tool_use', () => {
@@ -303,10 +305,10 @@ describe('silent-end-interrupt-stop.mjs — integration', () => {
303
305
  writeFileSync(join(stateDir, 'gateway-heartbeat'), String(Date.now()), 'utf8')
304
306
  }
305
307
 
306
- it('zero-reply ≥200 + FRESH gateway heartbeat → ALLOW (no block re-prompt), state file still written for the flush', () => {
307
- // The duplicate repro: today this BLOCKS *and* the gateway flush fires →
308
- // two messages. With a fresh heartbeat the election ALLOWS the stop so
309
- // the gateway flush is the single writer.
308
+ it('zero-reply ≥200 + FRESH gateway heartbeat → CAPTURE + ALLOW (sweep is the single writer)', () => {
309
+ // Collapsed design: the duplicate repro is killed structurally the
310
+ // trailing prose is captured to the outbox and the stop is allowed. The
311
+ // sweep is the single deterministic deliverer; no election, no re-prompt.
310
312
  writeFreshHeartbeat()
311
313
  const transcript = writeTranscript(tmp, [
312
314
  ENQUEUE,
@@ -314,35 +316,31 @@ describe('silent-end-interrupt-stop.mjs — integration', () => {
314
316
  ])
315
317
  const r = runHook({ event: { session_id: 's1', transcript_path: transcript }, stateDir })
316
318
  expect(r.status).toBe(0)
317
- // ALLOW → no block JSON on stdout.
318
- expect(r.stdout.trim()).toBe('')
319
- expect(r.stderr).toMatch(/single-writer election ALLOWED/)
320
- // State file IS written so the gateway's captured-prose bridge has its
321
- // input (turnKey/turnId/pendingText); retryCount stays 0 (not a re-prompt).
322
- const statePath = join(stateDir, 'silent-end-pending.json')
323
- expect(existsSync(statePath)).toBe(true)
324
- const state = JSON.parse(readFileSync(statePath, 'utf8'))
325
- expect(state.retryCount).toBe(0)
326
- expect(state.turnKey).toBe('111:_')
327
- expect(state.pendingText).toBe('A'.repeat(300))
319
+ expect(r.stdout.trim()).toBe('') // ALLOW
320
+ const rec = readOutboxRecord(stateDir)
321
+ expect(rec).not.toBeNull()
322
+ expect(rec!.text).toBe('A'.repeat(300))
323
+ expect(rec!.turnNonce).toBe('111:_#42')
328
324
  })
329
325
 
330
- it('zero-reply ≥200 + STALE/missing heartbeat → BLOCK (never allow into a possibly-dead gateway)', () => {
331
- // No heartbeat file the liveness gate forces today's BLOCK behaviour.
326
+ it('zero-reply ≥200 + STALE/missing heartbeat → STILL CAPTURE + ALLOW (record persists on disk; swept at next boot — strictly better than block-and-hope)', () => {
327
+ // Capture no longer depends on gateway liveness: even into a possibly-dead
328
+ // gateway the answer is durably captured and swept whenever the gateway is
329
+ // next alive — strictly stronger than the old block-and-hope re-prompt.
332
330
  const transcript = writeTranscript(tmp, [
333
331
  ENQUEUE,
334
332
  { type: 'assistant', message: { content: [{ type: 'text', text: 'A'.repeat(300) }] } },
335
333
  ])
336
334
  const r = runHook({ event: { session_id: 's1', transcript_path: transcript }, stateDir })
337
335
  expect(r.status).toBe(0)
338
- expect(JSON.parse(r.stdout).decision).toBe('block')
339
- const state = JSON.parse(readFileSync(join(stateDir, 'silent-end-pending.json'), 'utf8'))
340
- expect(state.retryCount).toBe(1)
336
+ expect(r.stdout.trim()).toBe('')
337
+ expect(readOutboxRecord(stateDir)!.text).toBe('A'.repeat(300))
341
338
  })
342
339
 
343
- it('retryCount>0 (prior failed delivery) + fresh heartbeatBLOCK (preserve #3228 send-failure net)', () => {
340
+ it('retryCount>0 (prior state) + capturable proseCAPTURE + ALLOW (nonce-scoped; the outbox journal handles redelivery, not the chat-global counter)', () => {
344
341
  writeFreshHeartbeat()
345
- // Seed a prior state file with retryCount=1 (a delivery already failed).
342
+ // A stale chat-global silent-end record must NOT poison the capture of a
343
+ // genuine trailing answer (Defect B fix — retry state no longer gates it).
346
344
  writeFileSync(
347
345
  join(stateDir, 'silent-end-pending.json'),
348
346
  JSON.stringify({ chatId: '111', threadId: null, turnKey: '111:_', retryCount: 1, timestamp: Date.now() }),
@@ -354,9 +352,8 @@ describe('silent-end-interrupt-stop.mjs — integration', () => {
354
352
  ])
355
353
  const r = runHook({ event: { session_id: 's1', transcript_path: transcript }, stateDir })
356
354
  expect(r.status).toBe(0)
357
- // retryCount was 1 → not the exhaustion boundary (MAX=2) → still blocks,
358
- // and the election does NOT allow (retry-ladder-in-flight).
359
- expect(JSON.parse(r.stdout).decision).toBe('block')
355
+ expect(r.stdout.trim()).toBe('')
356
+ expect(readOutboxRecord(stateDir)!.text).toBe('A'.repeat(300))
360
357
  })
361
358
  })
362
359
  })
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
- import { mkdtempSync, readFileSync, existsSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'
2
+ import { mkdtempSync, readFileSync, readdirSync, existsSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
  import { execFileSync } from 'node:child_process'
@@ -570,7 +570,7 @@ describe('silent-end-interrupt-stop hook — integration (#1775: transcript-scan
570
570
  expect(r.stdout.trim()).toBe('')
571
571
  })
572
572
 
573
- it('blocks the stop when transcript shows ack-only (no final reply) — Ken-2026-05-25 repro', () => {
573
+ it('captures the trailing answer to the outbox + allows on ack-only (no final reply) — Ken-2026-05-25 repro, collapsed design', () => {
574
574
  const transcript = writeTranscript([
575
575
  ENQUEUE,
576
576
  replyToolUse('on it — checking now', { disable_notification: true }),
@@ -579,14 +579,14 @@ describe('silent-end-interrupt-stop hook — integration (#1775: transcript-scan
579
579
  ])
580
580
  const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
581
581
  expect(r.exit).toBe(0)
582
- const out = JSON.parse(r.stdout.trim())
583
- expect(out.decision).toBe('block')
584
- expect(out.reason).toContain('reply')
585
- // #1664 the re-prompt must offer the NO_REPLY escape hatch.
586
- expect(out.reason).toContain('NO_REPLY')
587
- // retryCount incremented to 1 (the budget bookkeeping still
588
- // uses the state file).
589
- expect(readSilentEndState()!.retryCount).toBe(1)
582
+ // Collapsed design: the substantive trailing answer is CAPTURED to the
583
+ // outbox and the stop is ALLOWED (the sweep is the single deliverer).
584
+ expect(r.stdout.trim()).toBe('')
585
+ const recs = readdirSync(join(stateDir, 'outbox')).filter((f) => f.endsWith('.json') && f !== 'delivered.jsonl')
586
+ expect(recs).toHaveLength(1)
587
+ const rec = JSON.parse(readFileSync(join(stateDir, 'outbox', recs[0]), 'utf8'))
588
+ expect(rec.text).toBe('A'.repeat(2237))
589
+ expect(rec.chatId).toBe('c')
590
590
  })
591
591
 
592
592
  it('allows the stop when retryCount >= MAX_RETRIES, even if transcript still shows no reply', () => {
@@ -694,10 +694,12 @@ describe('silent-end-interrupt-stop hook — integration (#1775: transcript-scan
694
694
  describe('captured-prose delivery bridge (#3227)', () => {
695
695
  const ANSWER = 'That was actually your FY25 NOA, not Bloomfield. ' + 'A'.repeat(2200)
696
696
 
697
- it('FIRST silent-end with a genuine plain-text answer → hook persists pendingText and decide deliver', () => {
697
+ it('FIRST silent-end with a genuine plain-text answer → hook captures it to the outbox + allows (collapsed design supersedes the pendingText bridge)', () => {
698
698
  // A turn that ended with a substantive answer written as plain text
699
- // (never sent via the reply tool). The Stop hook scans, blocks, and now
700
- // persists the answer prose into silent-end-pending.json.
699
+ // (never sent via the reply tool). Under the collapsed guaranteed-delivery
700
+ // design the Stop hook captures the answer to the durable outbox and
701
+ // allows the stop — the sweep delivers it. This supersedes the legacy
702
+ // silent-end-pending.json pendingText bridge for capturable prose.
701
703
  const transcript = writeTranscript([
702
704
  ENQUEUE,
703
705
  replyToolUse('on it — checking now', { disable_notification: true }),
@@ -706,21 +708,12 @@ describe('silent-end-interrupt-stop hook — integration (#1775: transcript-scan
706
708
  ])
707
709
  const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
708
710
  expect(r.exit).toBe(0)
709
- expect(JSON.parse(r.stdout.trim()).decision).toBe('block')
710
-
711
- // The bridge: the hook wrote the answer prose into the state file...
712
- const state = readSilentEndState()!
713
- expect(state.pendingText).toBe(ANSWER)
714
- expect(state.turnKey).toBe('c:_')
715
-
716
- // ...and the gateway's pure decision core reads it back and decides to
717
- // deliver directly on this FIRST silent-end (retryCount just went to 1).
718
- const decision = decideCapturedProseDelivery({ turnKey: 'c:_' })
719
- expect(decision.deliver).toBe(true)
720
- expect(decision.text).toBe(ANSWER)
721
- expect(decision.reason).toBe('captured-prose')
722
- // FIRST silent-end: this is the retry-budget's first block, not exhaustion.
723
- expect(state.retryCount).toBe(1)
711
+ expect(r.stdout.trim()).toBe('') // ALLOW
712
+ const recs = readdirSync(join(stateDir, 'outbox')).filter((f) => f.endsWith('.json') && f !== 'delivered.jsonl')
713
+ expect(recs).toHaveLength(1)
714
+ const rec = JSON.parse(readFileSync(join(stateDir, 'outbox', recs[0]), 'utf8'))
715
+ expect(rec.text).toBe(ANSWER)
716
+ expect(rec.chatId).toBe('c')
724
717
  })
725
718
 
726
719
  it('a turn that DID call a final reply → no state, no pendingText, decide → no synthetic send', () => {
@@ -0,0 +1,222 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ normalizeTemporal,
4
+ normalizeUtcDatetimes,
5
+ normalizeRelativeDays,
6
+ } from '../temporal-normalize.js'
7
+
8
+ // All cases run in the agent's configured tz with a FIXED nowMs — the pure
9
+ // signature means no env / clock mocking is needed.
10
+ const TZ = 'Australia/Melbourne'
11
+ // Midday Melbourne on Thursday 2026-07-23 (AEST, UTC+10): 02:00Z = 12:00 local.
12
+ const NOW_THU_23_JUL = Date.UTC(2026, 6, 23, 2, 0, 0)
13
+
14
+ describe('normalizeRelativeDays — relative-day accuracy (#3501)', () => {
15
+ it('1. stale-forward: "closed tomorrow (Thu 23 Jul)" on Thu 23 Jul → "today"', () => {
16
+ // The reported incident.
17
+ const out = normalizeRelativeDays('closed tomorrow (Thu 23 Jul)', TZ, NOW_THU_23_JUL)
18
+ expect(out).toBe('closed today (Thu 23 Jul)')
19
+ })
20
+
21
+ it('2. stale-backward: "today (Wed 22 Jul)" on Thu 23 Jul → "yesterday"', () => {
22
+ const out = normalizeRelativeDays('sent today (Wed 22 Jul)', TZ, NOW_THU_23_JUL)
23
+ expect(out).toBe('sent yesterday (Wed 22 Jul)')
24
+ })
25
+
26
+ it('3. "yesterday" wrong forward: "(Fri 24 Jul)" → "tomorrow"', () => {
27
+ const out = normalizeRelativeDays('due yesterday (Fri 24 Jul)', TZ, NOW_THU_23_JUL)
28
+ expect(out).toBe('due tomorrow (Fri 24 Jul)')
29
+ })
30
+
31
+ it('4. correct word untouched — byte-identical', () => {
32
+ const src = 'ships today (Thu 23 Jul)'
33
+ expect(normalizeRelativeDays(src, TZ, NOW_THU_23_JUL)).toBe(src)
34
+ })
35
+
36
+ it('5. relative word with NO adjacent date — untouched', () => {
37
+ const src = "I'll get to it today, promise."
38
+ expect(normalizeRelativeDays(src, TZ, NOW_THU_23_JUL)).toBe(src)
39
+ })
40
+
41
+ it('6. multi-date message — each pair resolved independently', () => {
42
+ const out = normalizeRelativeDays(
43
+ 'opened tomorrow (Thu 23 Jul), closes tomorrow (Fri 24 Jul)',
44
+ TZ,
45
+ NOW_THU_23_JUL,
46
+ )
47
+ // First pair is actually today; second is genuinely tomorrow.
48
+ expect(out).toBe('opened today (Thu 23 Jul), closes tomorrow (Fri 24 Jul)')
49
+ })
50
+
51
+ it('7. midnight boundary: UTC date (22nd) disagrees with Melbourne date (23rd)', () => {
52
+ // 2026-07-22T23:00Z is already Thu 23 Jul 09:00 in Melbourne.
53
+ const now = Date.UTC(2026, 6, 22, 23, 0, 0)
54
+ const out = normalizeRelativeDays('reminder tomorrow (Thu 23 Jul)', TZ, now)
55
+ expect(out).toBe('reminder today (Thu 23 Jul)')
56
+ })
57
+
58
+ it('16. weekday repair: "(Wed 23 Jul)" when 23 Jul is a Thursday → "(Thu 23 Jul)"', () => {
59
+ const out = normalizeRelativeDays('meeting today (Wed 23 Jul)', TZ, NOW_THU_23_JUL)
60
+ expect(out).toBe('meeting today (Thu 23 Jul)')
61
+ })
62
+
63
+ it('numeric date form 23/07 (DD/MM) resolves like a named date', () => {
64
+ const out = normalizeRelativeDays('billed tomorrow (23/07)', TZ, NOW_THU_23_JUL)
65
+ expect(out).toBe('billed today (23/07)')
66
+ })
67
+
68
+ it('ISO yyyy-mm-dd adjacent date resolves', () => {
69
+ const out = normalizeRelativeDays('expires tomorrow 2026-07-22', TZ, NOW_THU_23_JUL)
70
+ expect(out).toBe('expires yesterday 2026-07-22')
71
+ })
72
+
73
+ it('time-of-day word with diff==0 (today) is kept as-is', () => {
74
+ const src = 'closing tonight (Thu 23 Jul)'
75
+ expect(normalizeRelativeDays(src, TZ, NOW_THU_23_JUL)).toBe(src)
76
+ })
77
+
78
+ it('REVIEW FIX 2 — time-of-day word with diff≠0 is left UNTOUCHED (never remapped)', () => {
79
+ // "tonight" next to tomorrow's date must NOT become "tomorrow" — it is left
80
+ // exactly as written.
81
+ const src = 'party tonight (Fri 24 Jul)'
82
+ expect(normalizeRelativeDays(src, TZ, NOW_THU_23_JUL)).toBe(src)
83
+ })
84
+
85
+ it('out-of-range relative word (diff > 1) is left untouched, NOT auto-deleted', () => {
86
+ const src = 'launch tomorrow (Mon 3 Aug)'
87
+ expect(normalizeRelativeDays(src, TZ, NOW_THU_23_JUL)).toBe(src)
88
+ })
89
+
90
+ it('preserves original casing on rewrite', () => {
91
+ const out = normalizeRelativeDays('Tomorrow (Thu 23 Jul) we ship', TZ, NOW_THU_23_JUL)
92
+ expect(out).toBe('Today (Thu 23 Jul) we ship')
93
+ })
94
+
95
+ it('excludes blockquote (>) lines from rewriting (quoted third-party text)', () => {
96
+ const src = '> forwarded: closed tomorrow (Thu 23 Jul)'
97
+ expect(normalizeRelativeDays(src, TZ, NOW_THU_23_JUL)).toBe(src)
98
+ })
99
+ })
100
+
101
+ describe('normalizeUtcDatetimes — no raw UTC outbound (#3501)', () => {
102
+ it('8. ISO-Z → local: 2026-07-23T09:15:00Z → Thu 23 Jul 7:15 pm AEST', () => {
103
+ const out = normalizeUtcDatetimes('window opens 2026-07-23T09:15:00Z', TZ, NOW_THU_23_JUL)
104
+ expect(out).toBe('window opens Thu 23 Jul 7:15 pm AEST')
105
+ })
106
+
107
+ it('9a. +00:00 offset variant', () => {
108
+ const out = normalizeUtcDatetimes('at 2026-07-23T09:15:00+00:00 sharp', TZ, NOW_THU_23_JUL)
109
+ expect(out).toBe('at Thu 23 Jul 7:15 pm AEST sharp')
110
+ })
111
+
112
+ it('9b. no-seconds and fractional-seconds variants', () => {
113
+ expect(normalizeUtcDatetimes('t=2026-07-23T09:15Z', TZ, NOW_THU_23_JUL)).toBe('t=Thu 23 Jul 7:15 pm AEST')
114
+ expect(normalizeUtcDatetimes('t=2026-07-23T09:15:00.500Z', TZ, NOW_THU_23_JUL)).toBe(
115
+ 't=Thu 23 Jul 7:15 pm AEST',
116
+ )
117
+ })
118
+
119
+ it('10. labelled "14:00 UTC" → local time-only', () => {
120
+ // 14:00Z on the 23rd is 00:00 (midnight) of the 24th in Melbourne.
121
+ const out = normalizeUtcDatetimes('cutoff 14:00 UTC', TZ, NOW_THU_23_JUL)
122
+ expect(out).toBe('cutoff 12:00 am AEST')
123
+ })
124
+
125
+ it('10b. labelled "2 pm GMT" → local', () => {
126
+ const out = normalizeUtcDatetimes('call at 2 pm GMT', TZ, NOW_THU_23_JUL)
127
+ expect(out).toBe('call at 12:00 am AEST')
128
+ })
129
+
130
+ it('11. DST: an AEDT-side instant renders AEDT wall clock', () => {
131
+ // 2026-01-15T09:15:00Z, Melbourne is on AEDT (UTC+11).
132
+ const summerNow = Date.UTC(2026, 0, 15, 2, 0, 0)
133
+ const out = normalizeUtcDatetimes('summer 2026-01-15T09:15:00Z', TZ, summerNow)
134
+ expect(out).toBe('summer Thu 15 Jan 8:15 pm AEDT')
135
+ })
136
+
137
+ it('12. already-local text ("7:15 pm AEST") — untouched', () => {
138
+ const src = 'opens 7:15 pm AEST'
139
+ expect(normalizeUtcDatetimes(src, TZ, NOW_THU_23_JUL)).toBe(src)
140
+ })
141
+
142
+ it('REVIEW — "9 to 5 GMT" (bare labelled hour, no minutes/ampm) is NOT converted', () => {
143
+ const src = 'hours are 9 to 5 GMT'
144
+ expect(normalizeUtcDatetimes(src, TZ, NOW_THU_23_JUL)).toBe(src)
145
+ })
146
+ })
147
+
148
+ describe('temporal masking — false-positive guards (#3501)', () => {
149
+ it('13a. ISO inside an inline code span — untouched', () => {
150
+ const src = 'log line `2026-07-23T09:15:00Z` seen'
151
+ expect(normalizeTemporal(src, TZ, NOW_THU_23_JUL)).toBe(src)
152
+ })
153
+
154
+ it('13b. ISO inside a fenced code block — untouched', () => {
155
+ const src = 'trace:\n```\nts=2026-07-23T09:15:00Z\n```\nend'
156
+ expect(normalizeTemporal(src, TZ, NOW_THU_23_JUL)).toBe(src)
157
+ })
158
+
159
+ it('REVIEW FIX 1 (failing-first) — ISO inside a markdown link href is untouched', () => {
160
+ const src = 'see [log](https://x/2026-07-23T09:15:00Z) for detail'
161
+ expect(normalizeTemporal(src, TZ, NOW_THU_23_JUL)).toBe(src)
162
+ })
163
+
164
+ it('REVIEW FIX 1 — ISO inside an angle-bracket autolink is untouched', () => {
165
+ const src = 'ref <https://x/2026-07-23T09:15:00Z> here'
166
+ expect(normalizeTemporal(src, TZ, NOW_THU_23_JUL)).toBe(src)
167
+ })
168
+
169
+ it('but a bare ISO in prose alongside a masked one IS still converted', () => {
170
+ const out = normalizeTemporal(
171
+ 'live 2026-07-23T09:15:00Z, code `2026-07-23T09:15:00Z`',
172
+ TZ,
173
+ NOW_THU_23_JUL,
174
+ )
175
+ expect(out).toBe('live Thu 23 Jul 7:15 pm AEST, code `2026-07-23T09:15:00Z`')
176
+ })
177
+ })
178
+
179
+ describe('normalizeTemporal — composition, idempotency, robustness (#3501)', () => {
180
+ it('runs UTC→local first, then relative-day on the produced date', () => {
181
+ // The UTC pass yields "Thu 23 Jul …"; the relative pass then sees that date
182
+ // adjacent to "tomorrow" and corrects it to "today".
183
+ const out = normalizeTemporal('tomorrow 2026-07-22T09:15:00Z', TZ, NOW_THU_23_JUL)
184
+ // 2026-07-22T09:15Z → Wed 22 Jul 7:15 pm AEST; 22 Jul is yesterday.
185
+ expect(out).toBe('yesterday Wed 22 Jul 7:15 pm AEST')
186
+ })
187
+
188
+ it('14. idempotent: f(f(x)) === f(x) for every representative fixture', () => {
189
+ const fixtures = [
190
+ 'closed tomorrow (Thu 23 Jul)',
191
+ 'window opens 2026-07-23T09:15:00Z',
192
+ 'cutoff 14:00 UTC',
193
+ 'meeting today (Wed 23 Jul)',
194
+ 'plain prose with no temporal content at all',
195
+ 'see [log](https://x/2026-07-23T09:15:00Z)',
196
+ ]
197
+ for (const f of fixtures) {
198
+ const once = normalizeTemporal(f, TZ, NOW_THU_23_JUL)
199
+ const twice = normalizeTemporal(once, TZ, NOW_THU_23_JUL)
200
+ expect(twice).toBe(once)
201
+ }
202
+ })
203
+
204
+ it('15. invalid tz — no-op, never throws (both passes)', () => {
205
+ const src = 'closed tomorrow (Thu 23 Jul), at 2026-07-23T09:15:00Z'
206
+ expect(() => normalizeTemporal(src, 'Not/AZone', NOW_THU_23_JUL)).not.toThrow()
207
+ expect(normalizeTemporal(src, 'Not/AZone', NOW_THU_23_JUL)).toBe(src)
208
+ })
209
+
210
+ it('empty / whitespace input is returned unchanged', () => {
211
+ expect(normalizeTemporal('', TZ, NOW_THU_23_JUL)).toBe('')
212
+ expect(normalizeUtcDatetimes('', TZ, NOW_THU_23_JUL)).toBe('')
213
+ expect(normalizeRelativeDays('', TZ, NOW_THU_23_JUL)).toBe('')
214
+ })
215
+
216
+ it('reproduces the cron/mail-watcher incident end-to-end', () => {
217
+ // The exact shape of the reported cron notification.
218
+ const incident = 'Heads up: the office is closed tomorrow (Thu 23 Jul).'
219
+ const out = normalizeTemporal(incident, TZ, NOW_THU_23_JUL)
220
+ expect(out).toBe('Heads up: the office is closed today (Thu 23 Jul).')
221
+ })
222
+ })
@@ -287,7 +287,7 @@ describe('#2798 turn-flush punctuation/bold parity with reply', () => {
287
287
  // #2996 P2: the reply orchestration itself now lives in the module
288
288
  // (`sendReply`); the delegation line is pinned there.
289
289
  const replyDelegates = moduleSrc.indexOf(
290
- `normalizeOutboundBody(rawText, 'reply', redactOutboundText)`,
290
+ `normalizeOutboundBody(rawText, 'reply', redactOutboundText`,
291
291
  moduleSrc.indexOf('export async function sendReply('),
292
292
  )
293
293
  expect(replyDelegates).toBeGreaterThan(0)
@@ -302,24 +302,29 @@ describe('#2798 turn-flush punctuation/bold parity with reply', () => {
302
302
  expect(scrubIdx).toBeGreaterThan(normIdx) // ...and BEFORE the voice scrub
303
303
  })
304
304
 
305
- it('turn-flush backstop: applies the SAME normalization in the SAME slot (REDS if the line is removed)', () => {
306
- const redactIdx = streamSrc.indexOf(`redactOutboundText(capturedText, 'turn_flush')`)
307
- // The normalization call the reply path uses, verbatim, on the turn_flush
308
- // variable. This indexOf is what returns -1 (→ assertion fails) if the
309
- // `stripExcessBold(normalizePunctuation(capturedText))` line is deleted
310
- // from the turn_flush branch.
311
- const normIdx = streamSrc.indexOf('stripExcessBold(normalizePunctuation(capturedText))', redactIdx)
312
- const scrubIdx = streamSrc.indexOf('scrubVoice(capturedText)', redactIdx)
313
- expect(redactIdx).toBeGreaterThan(0)
314
- expect(normIdx).toBeGreaterThan(redactIdx) // normalize AFTER the turn_flush redact
315
- expect(scrubIdx).toBeGreaterThan(normIdx) // ...and BEFORE the voice scrub — mirrors reply
316
- })
317
-
318
- it('both send sites share the identical `stripExcessBold(normalizePunctuation(` wrapper', () => {
319
- // Parity, structurally: the exact normalization wrapper the reply path uses
320
- // is the one the turn_flush branch uses — same call, not a lookalike.
305
+ it('turn-flush backstop: routes through the shared normalizeOutboundBody seam (#3501)', () => {
306
+ // #3501 consolidation: the turn_flush branch no longer hand-mirrors the
307
+ // reply pipeline inline it calls the SINGLE shared seam
308
+ // `normalizeOutboundBody(flushDecision.text, 'turn_flush', redactOutboundText)`.
309
+ // That call IS the redact→normalize→scrub pipeline (pinned above in the
310
+ // module source), so the same-slot ordering guarantee now lives in ONE
311
+ // place. This indexOf returns -1 (→ assertion fails) if the seam call is
312
+ // deleted from the turn_flush branch.
313
+ const seamIdx = streamSrc.indexOf(`normalizeOutboundBody(`)
314
+ const siteIdx = streamSrc.indexOf(`'turn_flush',`, seamIdx)
315
+ expect(seamIdx).toBeGreaterThan(0)
316
+ expect(siteIdx).toBeGreaterThan(seamIdx) // the seam is invoked with the turn_flush site
317
+ // The former inline mirror must be GONE — no hand-rolled copy left to drift.
318
+ expect(streamSrc).not.toContain('stripExcessBold(normalizePunctuation(capturedText))')
319
+ })
320
+
321
+ it('both send sites share the identical normalizeOutboundBody seam (#3501)', () => {
322
+ // Parity, structurally: after consolidation the reply and turn_flush sites
323
+ // share ONE normalization implementation — the punctuation/bold wrapper
324
+ // lives only inside normalizeOutboundBody, and both sites invoke it.
321
325
  expect(replyModuleSrc).toContain('stripExcessBold(normalizePunctuation(text))')
322
- expect(streamSrc).toContain('stripExcessBold(normalizePunctuation(capturedText))')
326
+ expect(streamSrc).toContain('normalizeOutboundBody(')
327
+ expect(streamSrc).toContain(`'turn_flush',`)
323
328
  })
324
329
 
325
330
  // Behavioural coverage (kept from the original suite): reconstruct the