switchroom 0.18.28 → 0.18.29

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.
@@ -79,6 +79,51 @@ export function backstopSendOutcome(args: {
79
79
  return 'delivered'
80
80
  }
81
81
 
82
+ /**
83
+ * Resolve a backstop send's outcome using the #3276 RECEIPT gate: success
84
+ * requires at least one FRESH, non-card chat message id. This supersedes the
85
+ * naive chunk-count comparison for the turn-flush backstop, where a delivery
86
+ * that landed only onto the (soon-swept) progress card must count as a failure,
87
+ * not `complete`.
88
+ *
89
+ * threw → failed
90
+ * no fresh non-card id delivered → failed (card-only "success")
91
+ * fewer fresh ids than chunks split → failed (partial delivery)
92
+ * >=1 fresh id AND all chunks landed → delivered
93
+ *
94
+ * `cardMessageId` is the taken-over progress-card id (or null); any id equal to
95
+ * it is excluded from the delivered set before counting.
96
+ */
97
+ export function backstopSendOutcomeGated(args: {
98
+ threw: boolean
99
+ sentIds: readonly number[]
100
+ chunkCount: number
101
+ cardMessageId: number | null
102
+ }): DeliveryOutcome {
103
+ if (args.threw) return 'failed'
104
+ if (args.chunkCount === 0) return 'failed'
105
+ const freshCount = args.sentIds.filter(
106
+ id => args.cardMessageId == null || id !== args.cardMessageId,
107
+ ).length
108
+ // Guard 7: a card-only delivery (zero fresh ids) is a hard failure.
109
+ if (freshCount === 0) return 'failed'
110
+ if (freshCount < args.chunkCount) return 'failed'
111
+ return 'delivered'
112
+ }
113
+
114
+ /**
115
+ * Stamp a turn's `deliveryOutcome` from a resolved backstop send using the
116
+ * #3276 receipt gate (fresh non-card ids only). Mutates and returns the outcome.
117
+ */
118
+ export function finalizeBackstopSendGated(
119
+ turn: { deliveryOutcome?: DeliveryOutcome },
120
+ send: { threw: boolean; sentIds: readonly number[]; chunkCount: number; cardMessageId: number | null },
121
+ ): DeliveryOutcome {
122
+ const outcome = backstopSendOutcomeGated(send)
123
+ turn.deliveryOutcome = outcome
124
+ return outcome
125
+ }
126
+
82
127
  /**
83
128
  * Stamp a turn's `deliveryOutcome` from a resolved backstop send. This is the
84
129
  * exact accounting the turn-flush IIFE's `finally` performs — factored here so
@@ -142,6 +142,40 @@ const MAX_LIMIT = 50
142
142
  let db: SqliteDatabase | null = null
143
143
  let dbPath: string | null = null
144
144
 
145
+ /**
146
+ * Loud, unconditional failure logging for the history writer.
147
+ *
148
+ * Recording bugs are silent by construction: every gateway call site wraps
149
+ * `recordOutbound` / `recordInbound` in a `try { … } catch {}` (or a catch that
150
+ * logs a caller-shaped message), and several of those catches are EMPTY. When a
151
+ * write throws or drops a row, the caller's empty catch hides it — the exact
152
+ * failure mode behind the 2026-07-16 incident (turn-flush deliveries 18944 /
153
+ * 18958 delivered to Telegram but absent from history.db, blinding
154
+ * `getRecentOutboundCount` / `hasOutboundDeliveredSince`). We therefore log HERE,
155
+ * inside the writer, BEFORE any throw — so even a caller's `catch {}` cannot
156
+ * suppress the diagnostic. Deterministic surfacing, not prompt discipline.
157
+ */
158
+ function warnHistory(msg: string): void {
159
+ try {
160
+ process.stderr.write(`telegram history: ${msg}\n`)
161
+ } catch {
162
+ /* stderr write must never itself break the record path */
163
+ }
164
+ }
165
+
166
+ /**
167
+ * A Telegram message_id is a positive 32-bit-ish integer. A null / undefined /
168
+ * NaN / non-integer id can only arrive from a malformed send result (e.g. an
169
+ * API wrapper that resolved without a real message object). Inserting it either
170
+ * violates the NOT NULL PRIMARY KEY (throws → swallowed by an empty caller
171
+ * catch) or silently corrupts the key space. We filter such ids out and log
172
+ * loudly instead — a delivered-but-unrecorded row is a durability defect the
173
+ * operator must see, not a silent drop.
174
+ */
175
+ function isValidMessageId(id: unknown): id is number {
176
+ return typeof id === 'number' && Number.isInteger(id) && id > 0
177
+ }
178
+
145
179
  /**
146
180
  * Open (or create) the history DB and run migrations + retention sweep.
147
181
  * Idempotent — safe to call once at server startup.
@@ -271,6 +305,72 @@ export function initHistory(stateDir: string, retentionDays = 30): void {
271
305
  const cutoff = Math.floor(Date.now() / 1000) - retentionDays * 86400
272
306
  db.prepare('DELETE FROM messages WHERE ts < ?').run(cutoff)
273
307
  }
308
+
309
+ // Boot-time writer self-check (2026-07-16 incident hardening). "history
310
+ // capture enabled" was logged at every boot — including the 02:17 and 04:58
311
+ // restarts around the incident — yet a whole class of deliveries never
312
+ // reached the DB. A successful `new Database(...)` + schema DDL does NOT prove
313
+ // the row-insert path is functional (a read-only mount, a full disk, an
314
+ // orphaned inode after an in-place dir replace, or a corrupt page all pass DDL
315
+ // but fail INSERT). So we prove it with a real INSERT + SELECT + DELETE
316
+ // round-trip on a sentinel row, and log LOUDLY if it fails. This turns a
317
+ // silent, hours-later-discovered writer outage into a deterministic boot
318
+ // signal the operator can see immediately.
319
+ const check = verifyHistoryWritable()
320
+ if (!check.ok) {
321
+ warnHistory(
322
+ `WRITER SELF-CHECK FAILED at boot (path=${path}): ${check.error ?? 'unknown'} — ` +
323
+ `history recording is NOT durable; get_recent_messages recovery and the ` +
324
+ `reply-backstop already-replied suppression will be blind. Investigate the ` +
325
+ `DB path/mount/permissions before trusting delivery accounting.`,
326
+ )
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Prove the history writer's INSERT path actually works, not just that the DB
332
+ * opened and the schema DDL ran. Performs a real INSERT + SELECT + DELETE of a
333
+ * sentinel row keyed on a reserved chat_id that no live chat can collide with,
334
+ * and cleans it up unconditionally. Returns `{ ok:false, error }` (never throws)
335
+ * so the boot path and an operator self-check tool can both call it safely.
336
+ *
337
+ * This is the deterministic mechanism the 2026-07-16 incident lacked: a
338
+ * writer that opens fine but cannot persist rows (read-only mount, full disk,
339
+ * orphaned inode, corruption) is caught HERE at boot instead of being inferred
340
+ * hours later from missing rows.
341
+ *
342
+ * No-op safe: returns `{ ok:false }` with an explanatory error if
343
+ * `initHistory` was never called.
344
+ */
345
+ export function verifyHistoryWritable(): { ok: boolean; error?: string } {
346
+ if (db == null) return { ok: false, error: 'initHistory() not called' }
347
+ const SENTINEL_CHAT = '__history_selfcheck__'
348
+ const sentinelId = Date.now()
349
+ try {
350
+ // Clear any stale sentinel from a prior crashed self-check first.
351
+ db.prepare('DELETE FROM messages WHERE chat_id = ?').run(SENTINEL_CHAT)
352
+ db.prepare(
353
+ `INSERT OR REPLACE INTO messages
354
+ (chat_id, thread_id, message_id, role, ts, text)
355
+ VALUES (?, NULL, ?, 'assistant', ?, ?)`,
356
+ ).run(SENTINEL_CHAT, sentinelId, Math.floor(Date.now() / 1000), 'selfcheck')
357
+ const row = db
358
+ .prepare('SELECT text FROM messages WHERE chat_id = ? AND message_id = ?')
359
+ .get(SENTINEL_CHAT, sentinelId) as { text?: string } | undefined
360
+ if (row?.text !== 'selfcheck') {
361
+ return { ok: false, error: 'sentinel row not read back after insert' }
362
+ }
363
+ return { ok: true }
364
+ } catch (err) {
365
+ return { ok: false, error: err instanceof Error ? err.message : String(err) }
366
+ } finally {
367
+ // Never leave the sentinel behind, even if the SELECT/assert path threw.
368
+ try {
369
+ db.prepare('DELETE FROM messages WHERE chat_id = ?').run(SENTINEL_CHAT)
370
+ } catch {
371
+ /* best-effort cleanup */
372
+ }
373
+ }
274
374
  }
275
375
 
276
376
  /**
@@ -404,6 +504,13 @@ interface RecordInboundArgs {
404
504
  */
405
505
  export function recordInbound(args: RecordInboundArgs): void {
406
506
  if (args.message_id == null) return
507
+ if (!isValidMessageId(args.message_id)) {
508
+ warnHistory(
509
+ `recordInbound: dropping row with invalid message_id=${String(args.message_id)} ` +
510
+ `(chat=${args.chat_id}) — a delivered inbound will be absent from history`,
511
+ )
512
+ return
513
+ }
407
514
  const stmt = requireDb().prepare(`
408
515
  INSERT OR REPLACE INTO messages
409
516
  (chat_id, thread_id, message_id, role, user, user_id, ts, text, attachment_kind, group_id, reply_to_message_id, reply_to_text, forwarded_from, forwarded_from_type, forwarded_from_id, forwarded_date, forwarded_message_id)
@@ -453,7 +560,35 @@ interface RecordOutboundArgs {
453
560
  export function recordOutbound(args: RecordOutboundArgs): void {
454
561
  if (args.message_ids.length === 0) return
455
562
  const ts = args.ts ?? Math.floor(Date.now() / 1000)
456
- const groupId = args.message_ids[0]!
563
+ // Filter out invalid ids (null/undefined/NaN/non-positive) BEFORE the insert.
564
+ // A malformed send result (an API wrapper that resolved without a real message
565
+ // object) would otherwise inject a NULL/NaN message_id: the NOT NULL PRIMARY
566
+ // KEY throws, and the caller's `catch {}` swallows it — the delivered reply is
567
+ // then absent from history AND the failure is invisible. This was the shape of
568
+ // the 2026-07-16 turn-flush loss (18944/18958 delivered, never recorded). We
569
+ // log loudly and record only the valid rows instead of losing them silently.
570
+ const validRows: Array<{ id: number; text: string; attachKind: string | null }> = []
571
+ for (let i = 0; i < args.message_ids.length; i++) {
572
+ const id = args.message_ids[i]
573
+ if (!isValidMessageId(id)) {
574
+ warnHistory(
575
+ `recordOutbound: dropping chunk ${i} with invalid message_id=${String(id)} ` +
576
+ `(chat=${args.chat_id}) — a delivered outbound will be absent from history, ` +
577
+ `blinding the reply-backstop already-replied suppression`,
578
+ )
579
+ continue
580
+ }
581
+ validRows.push({
582
+ id,
583
+ // Outbound redaction: the agent→user direction has no other secret scrub,
584
+ // so this is the chokepoint that keeps an agent-echoed secret out of the
585
+ // message store. Masks the secret bytes in place; surrounding text kept.
586
+ text: redact(args.texts[i] ?? ''),
587
+ attachKind: args.attachment_kinds?.[i] ?? null,
588
+ })
589
+ }
590
+ if (validRows.length === 0) return
591
+ const groupId = validRows[0]!.id
457
592
  const stmt = requireDb().prepare(`
458
593
  INSERT OR REPLACE INTO messages
459
594
  (chat_id, thread_id, message_id, role, user, user_id, ts, text, attachment_kind, group_id)
@@ -463,30 +598,25 @@ export function recordOutbound(args: RecordOutboundArgs): void {
463
598
  // writes if the process dies mid-loop. The transaction signature is
464
599
  // typed as variadic-unknown for genericity; cast the typed callback
465
600
  // through the wider shape.
466
- const tx = requireDb().transaction(((rows: Array<[number, string, string | null]>) => {
467
- for (const [msgId, text, attachKind] of rows) {
468
- stmt.run(
469
- args.chat_id,
470
- args.thread_id ?? null,
471
- msgId,
472
- ts,
473
- text,
474
- attachKind,
475
- groupId,
476
- )
601
+ const tx = requireDb().transaction(((rows: Array<{ id: number; text: string; attachKind: string | null }>) => {
602
+ for (const r of rows) {
603
+ stmt.run(args.chat_id, args.thread_id ?? null, r.id, ts, r.text, r.attachKind, groupId)
477
604
  }
478
605
  }) as (...args: unknown[]) => unknown)
479
- // Outbound redaction: the agent→user direction has no other secret
480
- // scrub, so this is the chokepoint that keeps an agent-echoed secret out
481
- // of the message store (e.g. an agent quoting a token it read from a file
482
- // or a not-yet-vaulted value). Masks the secret bytes in place; the
483
- // surrounding reply text is preserved.
484
- const rows: Array<[number, string, string | null]> = args.message_ids.map((id, i) => [
485
- id,
486
- redact(args.texts[i] ?? ''),
487
- args.attachment_kinds?.[i] ?? null,
488
- ])
489
- tx(rows)
606
+ // Surface a write failure LOUDLY before rethrowing. Callers wrap this in a
607
+ // `catch {}` / caller-shaped catch; without this log an insert failure (disk
608
+ // full, read-only mount, lock exhaustion) would be completely invisible
609
+ // exactly the diagnostic gap the 2026-07-16 incident exposed. Rethrow so the
610
+ // caller's existing control flow is unchanged.
611
+ try {
612
+ tx(validRows)
613
+ } catch (err) {
614
+ warnHistory(
615
+ `recordOutbound: INSERT failed (chat=${args.chat_id} ids=[${validRows.map((r) => r.id).join(',')}]): ` +
616
+ `${err instanceof Error ? err.message : String(err)} — this outbound will be absent from history`,
617
+ )
618
+ throw err
619
+ }
490
620
  }
491
621
 
492
622
  interface RecordEditArgs {
@@ -67,3 +67,59 @@ export function tzAbbrev(ms: number, tz: string): string {
67
67
  .find((p) => p.type === 'timeZoneName')?.value ?? tz
68
68
  )
69
69
  }
70
+
71
+ /**
72
+ * Resolve the agent's configured timezone from the process environment, the
73
+ * SAME cascade `bin/timezone-hook.sh` and the config resolver use:
74
+ * `SWITCHROOM_TIMEZONE` → `TZ` → `UTC`. Centralised so the inbound-tag,
75
+ * forwarded_date, and recent-buffer callsites can't drift.
76
+ */
77
+ export function resolveEnvTimezone(env: NodeJS.ProcessEnv = process.env): string {
78
+ return env.SWITCHROOM_TIMEZONE ?? env.TZ ?? 'UTC'
79
+ }
80
+
81
+ /**
82
+ * Full model-facing local wall-clock stamp, e.g.
83
+ * `Thursday 2026-07-16 04:09 PM AEST`.
84
+ *
85
+ * The deterministic replacement for the UTC ISO strings the model used to see
86
+ * on inbound channel tags (`ts="…Z"`), `forwarded_date`, and the
87
+ * `get_recent_messages` buffer. am/pm form in the agent's CONFIGURED timezone,
88
+ * with NO "UTC" / trailing-Z — so the LLM can never read one of these as UTC
89
+ * "now" and reason an offset wrong.
90
+ *
91
+ * Format matches the CORE of `bin/timezone-hook.sh`'s stamp —
92
+ * `%A %Y-%m-%d %I:%M %p %Z` (weekday, ISO date, am/pm, zone abbrev) — so the
93
+ * inbound-tag time and the UserPromptSubmit local-time hint read the same way.
94
+ * The hook additionally appends a ` (UTC±HH:MM)` numeric-offset LABEL that this
95
+ * helper deliberately omits: the abbrev (AEST/EDT) already disambiguates, and
96
+ * keeping the output free of any "UTC" substring makes the deterministic
97
+ * no-UTC-current-time guard trivially strict for every callsite. So it is NOT
98
+ * a byte-for-byte match — same core shape, minus the offset tail.
99
+ *
100
+ * Pure / total: an invalid IANA `tz` (misconfigured agent) degrades to the
101
+ * same am/pm shape rendered in UTC rather than throwing out of the inbound
102
+ * path — a bad zone must never crash a turn.
103
+ */
104
+ export function fmtLocalStamp(ms: number, tz: string): string {
105
+ try {
106
+ const at = new Date(ms)
107
+ const weekday = new Intl.DateTimeFormat('en-US', {
108
+ timeZone: tz,
109
+ weekday: 'long',
110
+ }).format(at)
111
+ // localDay (en-CA) yields YYYY-MM-DD and throws first on a bad zone.
112
+ const date = localDay(ms, tz)
113
+ const time = new Intl.DateTimeFormat('en-US', {
114
+ timeZone: tz,
115
+ hour: '2-digit',
116
+ minute: '2-digit',
117
+ hour12: true,
118
+ }).format(at) // "04:09 PM"
119
+ return `${weekday} ${date} ${time} ${tzAbbrev(ms, tz)}`
120
+ } catch {
121
+ // Invalid IANA zone — degrade to am/pm in UTC rather than crash the turn.
122
+ if (tz !== 'UTC') return fmtLocalStamp(ms, 'UTC')
123
+ return new Date(ms).toISOString()
124
+ }
125
+ }
@@ -0,0 +1,250 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ import {
4
+ BackstopDeliveryLedger,
5
+ backstopReceiptIds,
6
+ backstopDelivered,
7
+ runBackstopDelivery,
8
+ } from '../gateway/backstop-delivery.js'
9
+ import {
10
+ backstopSendOutcomeGated,
11
+ finalizeBackstopSendGated,
12
+ computeTurnStatus,
13
+ } from '../gateway/turn-record-status.js'
14
+
15
+ /**
16
+ * #3276 — the turn-flush backstop delivered by editing the ephemeral progress
17
+ * card and counted that card-edit id as an answer delivery, so the turn record
18
+ * said `complete` while the card was GC'd and nothing durable reached the chat.
19
+ *
20
+ * These assert the deterministic OUTCOMES the fix guarantees:
21
+ * - a delivered answer is a FRESH non-card chat id (guard 7),
22
+ * - `complete` IFF such an id exists; card-only "success" ⇒ `send_failed`,
23
+ * - a per-turn backstop double-fire latch (guard 5),
24
+ * - a bounded retry resumes mid-chunk and never re-sends chunk 0 (guard 6),
25
+ * - terminal failure reports `delivered:false` so the caller leaves the
26
+ * delivery obligation OPEN (finding 1).
27
+ */
28
+
29
+ describe('guard 7 — receipt gate: a delivered answer is a FRESH non-card chat id', () => {
30
+ it('primary: a fresh chat id is recorded whose id ≠ any card id', () => {
31
+ const cardId = 500
32
+ const sentIds = [777] // a fresh sendMessage id, not the card
33
+ const fresh = backstopReceiptIds(sentIds, cardId)
34
+ expect(fresh).toEqual([777])
35
+ expect(fresh).not.toContain(cardId)
36
+ expect(backstopDelivered(sentIds, cardId)).toBe(true)
37
+ })
38
+
39
+ it('card-present: the delivered id excludes backstopCardMessageId', () => {
40
+ const cardId = 18944
41
+ // The pre-fix backstop pushed the card id into sentIds via editMessageText.
42
+ const raw = [18944]
43
+ expect(backstopReceiptIds(raw, cardId)).toEqual([])
44
+ expect(backstopDelivered(raw, cardId)).toBe(false)
45
+ })
46
+
47
+ it('mixed: only the fresh ids survive the gate', () => {
48
+ const cardId = 18944
49
+ expect(backstopReceiptIds([18944, 18950, 18951], cardId)).toEqual([18950, 18951])
50
+ })
51
+ })
52
+
53
+ describe('status honesty — complete IFF a real non-card id exists', () => {
54
+ it('card-only delivery ⇒ send_failed, never complete', () => {
55
+ const cardId = 18944
56
+ const turn: { finalAnswerDelivered: boolean; deliveryOutcome?: 'delivered' | 'failed' | 'suppressed' } = {
57
+ finalAnswerDelivered: true,
58
+ }
59
+ finalizeBackstopSendGated(turn, {
60
+ threw: false,
61
+ sentIds: [18944], // only the card was edited
62
+ chunkCount: 1,
63
+ cardMessageId: cardId,
64
+ })
65
+ expect(turn.deliveryOutcome).toBe('failed')
66
+ expect(computeTurnStatus(turn)).toBe('send_failed')
67
+ })
68
+
69
+ it('fresh chat id delivered ⇒ complete', () => {
70
+ const turn: { finalAnswerDelivered: boolean; deliveryOutcome?: 'delivered' | 'failed' | 'suppressed' } = {
71
+ finalAnswerDelivered: true,
72
+ }
73
+ finalizeBackstopSendGated(turn, {
74
+ threw: false,
75
+ sentIds: [18950],
76
+ chunkCount: 1,
77
+ cardMessageId: 18944,
78
+ })
79
+ expect(turn.deliveryOutcome).toBe('delivered')
80
+ expect(computeTurnStatus(turn)).toBe('complete')
81
+ })
82
+
83
+ it('partial multi-chunk (fewer fresh ids than chunks) ⇒ send_failed', () => {
84
+ expect(
85
+ backstopSendOutcomeGated({ threw: false, sentIds: [18950], chunkCount: 2, cardMessageId: null }),
86
+ ).toBe('failed')
87
+ })
88
+
89
+ it('throw ⇒ failed regardless of ids', () => {
90
+ expect(
91
+ backstopSendOutcomeGated({ threw: true, sentIds: [18950], chunkCount: 1, cardMessageId: null }),
92
+ ).toBe('failed')
93
+ })
94
+
95
+ it('empty split (0 chunks) ⇒ failed, not delivered', () => {
96
+ expect(
97
+ backstopSendOutcomeGated({ threw: false, sentIds: [], chunkCount: 0, cardMessageId: null }),
98
+ ).toBe('failed')
99
+ })
100
+ })
101
+
102
+ describe('guard 5 — once-per-turn backstop double-fire latch', () => {
103
+ it('claim returns true exactly once per turnId', () => {
104
+ const ledger = new BackstopDeliveryLedger()
105
+ expect(ledger.claim('#18925')).toBe(true)
106
+ expect(ledger.claim('#18925')).toBe(false)
107
+ // A different turn is independent.
108
+ expect(ledger.claim('#18929')).toBe(true)
109
+ })
110
+
111
+ it('release re-opens the latch after a terminal failure', () => {
112
+ const ledger = new BackstopDeliveryLedger()
113
+ ledger.claim('#t')
114
+ ledger.release('#t')
115
+ expect(ledger.claim('#t')).toBe(true)
116
+ })
117
+ })
118
+
119
+ describe('guard 6 — per-chunk idempotency ledger: retry never re-sends chunk 0', () => {
120
+ it('resumes at the first unsent chunk after a partial send', () => {
121
+ const ledger = new BackstopDeliveryLedger()
122
+ const turnId = '#partial'
123
+ const chunkCount = 3
124
+ ledger.markPending(turnId, 0)
125
+ ledger.recordChunk(turnId, 0, [1001])
126
+ ledger.markPending(turnId, 1)
127
+ ledger.recordChunk(turnId, 1, [1002])
128
+ ledger.markPending(turnId, 2) // in-flight, never acked
129
+
130
+ expect(ledger.hasChunk(turnId, 0)).toBe(true)
131
+ expect(ledger.hasChunk(turnId, 1)).toBe(true)
132
+ expect(ledger.hasChunk(turnId, 2)).toBe(false)
133
+ expect(ledger.unsentIndices(turnId, chunkCount)).toEqual([2])
134
+
135
+ ledger.recordChunk(turnId, 2, [1003])
136
+ expect(ledger.sentIds(turnId)).toEqual([1001, 1002, 1003])
137
+ expect(ledger.sentIds(turnId)[0]).toBe(1001)
138
+ })
139
+
140
+ it('sentIds are returned in chunk-index order regardless of record order', () => {
141
+ const ledger = new BackstopDeliveryLedger()
142
+ ledger.recordChunk('#o', 2, [3])
143
+ ledger.recordChunk('#o', 0, [1])
144
+ ledger.recordChunk('#o', 1, [2])
145
+ expect(ledger.sentIds('#o')).toEqual([1, 2, 3])
146
+ })
147
+
148
+ it('entries() zip a resplit chunk (2 ids for 1 input chunk) in order', () => {
149
+ const ledger = new BackstopDeliveryLedger()
150
+ ledger.recordChunk('#z', 0, [10])
151
+ ledger.recordChunk('#z', 1, [11, 12]) // chunk 1 length-resplit into 2 sends
152
+ expect(ledger.entries('#z')).toEqual([
153
+ { index: 0, messageIds: [10] },
154
+ { index: 1, messageIds: [11, 12] },
155
+ ])
156
+ })
157
+ })
158
+
159
+ /**
160
+ * Integration oracles over `runBackstopDelivery` — the exact orchestration that
161
+ * replaced the card-coupled send. It drives the real retry/ledger/receipt code
162
+ * with an injected `sendChunk`, asserting on what reaches `recordOutbound` and
163
+ * on the `delivered`/`exhausted` decision the gateway feeds to the obligation
164
+ * ledger + turn record.
165
+ */
166
+ describe('runBackstopDelivery — integration oracle over the delivery wiring', () => {
167
+ it('records a FRESH non-card id in history; delivered=true (#3276 primary)', async () => {
168
+ const ledger = new BackstopDeliveryLedger()
169
+ const cardId = 18944
170
+ const recorded: Array<{ ids: number[]; texts: string[] }> = []
171
+ const sendChunk = vi.fn(async (i: number) => [18950 + i]) // fresh chat ids
172
+ const res = await runBackstopDelivery(
173
+ ledger,
174
+ '#18925',
175
+ ['the answer'],
176
+ cardId,
177
+ { sendChunk, recordOutbound: (ids, texts) => recorded.push({ ids, texts }) },
178
+ )
179
+ expect(res.delivered).toBe(true)
180
+ expect(res.exhausted).toBe(false)
181
+ expect(recorded).toHaveLength(1)
182
+ // A real fresh chat id landed in history whose id ≠ the progress-card id.
183
+ expect(recorded[0].ids).toEqual([18950])
184
+ expect(recorded[0].ids).not.toContain(cardId)
185
+ expect(backstopReceiptIds(recorded[0].ids, cardId)).toEqual([18950])
186
+ })
187
+
188
+ it('card-only "delivery" can never happen — the receipt gate excludes the card id', async () => {
189
+ const ledger = new BackstopDeliveryLedger()
190
+ const cardId = 18944
191
+ // Even if a buggy send echoed the card id, the receipt gate drops it.
192
+ const sendChunk = vi.fn(async () => [cardId])
193
+ const res = await runBackstopDelivery(ledger, '#c', ['x'], cardId, { sendChunk })
194
+ expect(res.delivered).toBe(false)
195
+ expect(res.exhausted).toBe(true)
196
+ })
197
+
198
+ it('retry resumes mid-chunk — chunk 0 is NOT re-sent on attempt 2 (guard 6, finding 1)', async () => {
199
+ const ledger = new BackstopDeliveryLedger()
200
+ const calls: number[] = []
201
+ let failedOnce = false
202
+ const sendChunk = vi.fn(async (i: number) => {
203
+ calls.push(i)
204
+ if (i === 2 && !failedOnce) {
205
+ failedOnce = true
206
+ throw new Error('FLOOD_WAIT_ACTIVE')
207
+ }
208
+ return [700 + i]
209
+ })
210
+ const res = await runBackstopDelivery(ledger, '#resume', ['c0', 'c1', 'c2'], null, { sendChunk }, 3)
211
+
212
+ expect(res.delivered).toBe(true)
213
+ expect(res.sentIds).toEqual([700, 701, 702])
214
+ // chunk 0 and 1 sent exactly once; chunk 2 attempted twice (fail, then ok).
215
+ expect(calls.filter(i => i === 0)).toHaveLength(1) // <-- chunk 0 never re-sent
216
+ expect(calls.filter(i => i === 1)).toHaveLength(1)
217
+ expect(calls.filter(i => i === 2)).toHaveLength(2)
218
+ expect(res.attempts).toBe(2)
219
+ })
220
+
221
+ it('terminal failure ⇒ delivered=false / exhausted=true after maxAttempts (obligation left open)', async () => {
222
+ const ledger = new BackstopDeliveryLedger()
223
+ const sendChunk = vi.fn(async () => { throw new Error('FLOOD_WAIT_ACTIVE') })
224
+ const res = await runBackstopDelivery(ledger, '#dead', ['only chunk'], null, { sendChunk }, 3)
225
+ expect(res.delivered).toBe(false)
226
+ expect(res.exhausted).toBe(true)
227
+ expect(res.attempts).toBe(3) // exhausted the bounded retry
228
+ expect(res.sentIds).toEqual([]) // nothing landed
229
+ // This is the exact input the gateway uses: delivered=false ⇒ it records
230
+ // send_failed AND leaves the obligation OPEN (noteTurnEnded, not close).
231
+ expect(backstopSendOutcomeGated({
232
+ threw: !res.delivered, sentIds: res.sentIds, chunkCount: res.chunkCount, cardMessageId: null,
233
+ })).toBe('failed')
234
+ })
235
+
236
+ it('recordOutbound texts are ALIGNED to sent ids even when a chunk resplits (finding 5)', async () => {
237
+ const ledger = new BackstopDeliveryLedger()
238
+ const recorded: Array<{ ids: number[]; texts: string[] }> = []
239
+ // chunk 1 lands TWO ids (a length-resplit); a naive chunks.slice zip would
240
+ // misalign. entries()-based zip repeats the source text per landed id.
241
+ const sendChunk = vi.fn(async (i: number) => (i === 1 ? [11, 12] : [10]))
242
+ await runBackstopDelivery(
243
+ ledger, '#zip', ['A', 'B'], null,
244
+ { sendChunk, recordOutbound: (ids, texts) => recorded.push({ ids, texts }) },
245
+ )
246
+ expect(recorded[0].ids).toEqual([10, 11, 12])
247
+ expect(recorded[0].texts).toEqual(['A', 'B', 'B'])
248
+ expect(recorded[0].ids).toHaveLength(recorded[0].texts.length)
249
+ })
250
+ })
@@ -30,10 +30,15 @@ import {
30
30
  FORWARDED_FROM_NAME_MAX,
31
31
  type ForwardOriginInfo,
32
32
  } from '../gateway/forward-origin.js'
33
+ import { fmtLocalStamp, resolveEnvTimezone } from '../shared/local-time.js'
33
34
 
34
35
  // Synthetic fixtures only — no real Telegram ids/names (check-no-pii-secrets).
35
36
  const DATE = 1750000000 // unix seconds
36
- const DATE_ISO = new Date(DATE * 1000).toISOString()
37
+ // switchroom #tz-fix: forwarded_date is now the agent's LOCAL am/pm wall clock
38
+ // (NOT UTC ISO), so it can't compete with the local-time hint. Compute the
39
+ // expected value through the SAME helper production uses, so the assertion is
40
+ // deterministic under whatever TZ the runner env carries.
41
+ const DATE_LOCAL = fmtLocalStamp(DATE * 1000, resolveEnvTimezone())
37
42
 
38
43
  function userOrigin(overrides: Partial<{
39
44
  first_name: string
@@ -204,7 +209,7 @@ describe('buildForwardOriginMeta — channel-tag attrs', () => {
204
209
  forwarded_from: 'Ada Lovelace (@adalove)',
205
210
  forwarded_from_type: 'user',
206
211
  forwarded_from_id: '42',
207
- forwarded_date: DATE_ISO,
212
+ forwarded_date: DATE_LOCAL,
208
213
  })
209
214
  })
210
215
 
@@ -245,6 +250,28 @@ describe('buildForwardOriginMeta — channel-tag attrs', () => {
245
250
  it('no origins → empty record (no attrs on a normal message)', () => {
246
251
  expect(buildForwardOriginMeta([])).toEqual({})
247
252
  })
253
+
254
+ // switchroom #tz-fix (deterministic outcome): under a real configured zone
255
+ // the forwarded_date the MODEL sees is LOCAL am/pm with NO "UTC" / trailing-Z.
256
+ it('renders forwarded_date as LOCAL am/pm — never a UTC ISO string', () => {
257
+ const prevTz = process.env.SWITCHROOM_TIMEZONE
258
+ const prevTZ = process.env.TZ
259
+ process.env.SWITCHROOM_TIMEZONE = 'Australia/Melbourne'
260
+ delete process.env.TZ
261
+ try {
262
+ const meta = buildForwardOriginMeta([{ name: 'Ada', type: 'user', id: 42, date: DATE }])
263
+ const d = meta.forwarded_date!
264
+ // e.g. "Sunday 2025-06-15 08:26 PM AEST" — weekday, ISO date, am/pm, abbrev.
265
+ expect(d).toMatch(/ (?:AM|PM) [A-Za-z]{2,5}$/)
266
+ expect(d).not.toContain('UTC')
267
+ expect(d.endsWith('Z')).toBe(false)
268
+ } finally {
269
+ if (prevTz === undefined) delete process.env.SWITCHROOM_TIMEZONE
270
+ else process.env.SWITCHROOM_TIMEZONE = prevTz
271
+ if (prevTZ === undefined) delete process.env.TZ
272
+ else process.env.TZ = prevTZ
273
+ }
274
+ })
248
275
  })
249
276
 
250
277
  describe('coalesced bursts — dedupe + numbered siblings', () => {
@@ -264,7 +291,7 @@ describe('coalesced bursts — dedupe + numbered siblings', () => {
264
291
  expect(meta.forwarded_from).toBe('Alice Q (@aliceq)')
265
292
  expect(meta.forwarded_from_2).toBeUndefined()
266
293
  // First occurrence wins — the emitted date is the first part's.
267
- expect(meta.forwarded_date).toBe(DATE_ISO)
294
+ expect(meta.forwarded_date).toBe(DATE_LOCAL)
268
295
  })
269
296
 
270
297
  it('multi-origin burst: first origin bare, second gets _2 keys in order', () => {