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.
- package/dist/cli/switchroom.js +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/cron-session.sh.hbs +5 -0
- package/profiles/_base/start.sh.hbs +11 -0
- package/telegram-plugin/dist/gateway/gateway.js +1382 -1032
- package/telegram-plugin/final-answer-detect.ts +23 -0
- package/telegram-plugin/gateway/gateway.ts +29 -36
- package/telegram-plugin/gateway/outbound-send-path.ts +109 -8
- package/telegram-plugin/gateway/outbox-sweep.ts +282 -0
- package/telegram-plugin/gateway/stream-render.ts +26 -50
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +94 -2
- package/telegram-plugin/hooks/silent-end-scan.mjs +279 -0
- package/telegram-plugin/outbox.ts +472 -0
- package/telegram-plugin/scripts/bun-test-ci.sh +91 -0
- package/telegram-plugin/temporal-normalize.ts +347 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +22 -12
- package/telegram-plugin/tests/outbound-send-path.test.ts +191 -0
- package/telegram-plugin/tests/outbox-capture-scan.test.ts +222 -0
- package/telegram-plugin/tests/outbox-delivery.test.ts +278 -0
- package/telegram-plugin/tests/outbox-hook-capture.test.ts +112 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +60 -63
- package/telegram-plugin/tests/silent-end.test.ts +21 -28
- package/telegram-plugin/tests/temporal-normalize.test.ts +222 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +23 -18
|
@@ -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(
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
expect(
|
|
130
|
-
expect(
|
|
131
|
-
|
|
132
|
-
|
|
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('
|
|
194
|
-
// Confirmed-incident shape: (1) a background-task notification
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
expect(
|
|
222
|
-
|
|
223
|
-
expect(
|
|
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 (
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
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
|
|
318
|
-
|
|
319
|
-
expect(
|
|
320
|
-
|
|
321
|
-
|
|
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 →
|
|
331
|
-
//
|
|
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(
|
|
339
|
-
|
|
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
|
|
340
|
+
it('retryCount>0 (prior state) + capturable prose → CAPTURE + ALLOW (nonce-scoped; the outbox journal handles redelivery, not the chat-global counter)', () => {
|
|
344
341
|
writeFreshHeartbeat()
|
|
345
|
-
//
|
|
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
|
-
|
|
358
|
-
|
|
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('
|
|
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
|
-
|
|
583
|
-
|
|
584
|
-
expect(
|
|
585
|
-
|
|
586
|
-
expect(
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
expect(
|
|
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
|
|
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).
|
|
700
|
-
//
|
|
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(
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
const
|
|
713
|
-
expect(
|
|
714
|
-
expect(
|
|
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:
|
|
306
|
-
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
expect(
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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('
|
|
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
|