switchroom 0.18.27 → 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.
Files changed (31) hide show
  1. package/bin/handoff-briefing.sh +8 -1
  2. package/dist/auth-broker/index.js +0 -57
  3. package/dist/cli/switchroom.js +501 -497
  4. package/dist/host-control/main.js +1 -58
  5. package/dist/vault/approvals/kernel-server.js +0 -57
  6. package/dist/vault/broker/server.js +0 -57
  7. package/package.json +1 -1
  8. package/profiles/_base/start.sh.hbs +37 -19
  9. package/telegram-plugin/dist/gateway/gateway.js +655 -587
  10. package/telegram-plugin/gateway/backstop-delivery.ts +272 -0
  11. package/telegram-plugin/gateway/forward-origin.ts +9 -1
  12. package/telegram-plugin/gateway/gateway.ts +511 -397
  13. package/telegram-plugin/gateway/model-command.ts +227 -602
  14. package/telegram-plugin/gateway/turn-record-status.ts +45 -0
  15. package/telegram-plugin/gateway/worker-pin-reaper.ts +54 -0
  16. package/telegram-plugin/history.ts +153 -23
  17. package/telegram-plugin/shared/local-time.ts +56 -0
  18. package/telegram-plugin/tests/backstop-delivery.test.ts +250 -0
  19. package/telegram-plugin/tests/forward-origin.test.ts +30 -3
  20. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +86 -59
  21. package/telegram-plugin/tests/history.test.ts +88 -0
  22. package/telegram-plugin/tests/local-time.test.ts +68 -0
  23. package/telegram-plugin/tests/model-command.test.ts +317 -1535
  24. package/telegram-plugin/tests/turn-flush-safety.test.ts +34 -0
  25. package/telegram-plugin/tests/worker-feed-migration-eviction.test.ts +140 -0
  26. package/telegram-plugin/tests/worker-pin-reaper.test.ts +78 -0
  27. package/telegram-plugin/tier-downgrade.ts +4 -3
  28. package/telegram-plugin/turn-flush-safety.ts +25 -1
  29. package/telegram-plugin/worker-activity-feed.ts +78 -5
  30. package/vendor/hindsight-memory/scripts/lib/content.py +40 -6
  31. package/vendor/hindsight-memory/tests/test_content.py +28 -7
@@ -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', () => {
@@ -1,10 +1,11 @@
1
1
  /**
2
- * Structural pins for the session-scoped /model wiring in gateway.ts
3
- * (reference/rfcs/session-model-stickiness.md §0.1, rev 4 — consume-once).
2
+ * Structural pins for the DETERMINISTIC /model wiring in gateway.ts
3
+ * (reference/rfcs/session-model-stickiness.md §0.05, rev 5 — every switch
4
+ * relaunches through the consume-once carrier; the inject/scrape path retired).
4
5
  *
5
6
  * The behaviour lives in un-exported inline closures (buildModelDeps's
6
- * scheduleModelRelaunch/scheduleRestart, the typed/menu recorders, and the
7
- * boot re-hydration block inside the startup IIFE), so — mirroring the other
7
+ * scheduleModelRelaunch / scheduleModelDefaultRelaunch / scheduleRestart, and
8
+ * the boot re-hydration block inside the startup IIFE), so — mirroring the other
8
9
  * gateway-*.test.ts source-pins — we assert on the source structure. The
9
10
  * end-to-end boot behaviour is exercised in tests/scaffold.session-model.test.ts
10
11
  * (rendered start.sh), the file helpers in session-model-file.test.ts, and the
@@ -42,7 +43,7 @@ describe('gateway: scheduleModelRelaunch dep (consume-once .session-model carrie
42
43
  it('writes the carrier via writeSessionModelFile before dispatching the restart', () => {
43
44
  const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
44
45
  expect(idx).toBeGreaterThan(0)
45
- const win = GATEWAY_SRC.slice(idx, idx + 1800)
46
+ const win = GATEWAY_SRC.slice(idx, idx + 2600)
46
47
  const writeIdx = win.indexOf('writeSessionModelFile(')
47
48
  const restartIdx = win.indexOf('deps.scheduleRestart(reason)')
48
49
  expect(writeIdx).toBeGreaterThan(0)
@@ -51,7 +52,7 @@ describe('gateway: scheduleModelRelaunch dep (consume-once .session-model carrie
51
52
 
52
53
  it('sets the in-memory session-model override before dispatching the restart', () => {
53
54
  const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
54
- const win = GATEWAY_SRC.slice(idx, idx + 1800)
55
+ const win = GATEWAY_SRC.slice(idx, idx + 2600)
55
56
  const setIdx = win.indexOf('sessionModelSource.setOverride(model)')
56
57
  const restartIdx = win.indexOf('deps.scheduleRestart(reason)')
57
58
  expect(setIdx).toBeGreaterThan(0)
@@ -60,7 +61,7 @@ describe('gateway: scheduleModelRelaunch dep (consume-once .session-model carrie
60
61
 
61
62
  it('rolls back the prior carrier content (not just deletion) on a non-in-flight dispatch failure', () => {
62
63
  const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
63
- const win = GATEWAY_SRC.slice(idx, idx + 1800)
64
+ const win = GATEWAY_SRC.slice(idx, idx + 2600)
64
65
  expect(win).toContain('const prevFileRaw = readSessionModelFileRaw(agentDir)')
65
66
  expect(win).toContain('restoreSessionModelFileRaw(agentDir, prevFileRaw)')
66
67
  expect(win).toContain("!== 'restart_in_flight'")
@@ -68,82 +69,108 @@ describe('gateway: scheduleModelRelaunch dep (consume-once .session-model carrie
68
69
 
69
70
  it('reuses the same scheduleRestart dispatch (not a bespoke restart path)', () => {
70
71
  const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
71
- const win = GATEWAY_SRC.slice(idx, idx + 1800)
72
+ const win = GATEWAY_SRC.slice(idx, idx + 2600)
72
73
  expect(win).toContain('await deps.scheduleRestart(reason)')
73
74
  })
74
75
  })
75
76
 
76
- describe('gateway: menu callback carrier handling (session-scoped)', () => {
77
- it('a live Claude selection records the in-memory override but writes NO carrier', () => {
78
- const idx = GATEWAY_SRC.indexOf('function recordModelMenuSideEffects')
79
- expect(idx).toBeGreaterThan(0)
80
- const win = GATEWAY_SRC.slice(idx, idx + 900)
81
- // The first (live-switch) block sets the override but no longer persists.
82
- expect(win).toContain('sessionModelSource.setOverride(outcome.selectedModel)')
83
- const overrideIdx = win.indexOf('sessionModelSource.setOverride(outcome.selectedModel)')
84
- const nextClearIdx = win.indexOf('outcome.clearedDefault')
85
- const writeBetween = win.slice(overrideIdx, nextClearIdx)
86
- expect(writeBetween).not.toContain('writeSessionModelFile(')
77
+ describe('gateway: the retired scrape-recorders are GONE (rev 5 inversion)', () => {
78
+ it('recordTypedModelSwitch and recordModelMenuSideEffects no longer exist', () => {
79
+ // INVERTED from rev 4: these helpers recorded a scrape-derived selectedModel
80
+ // and drove the sr-to-claude special case. Every switch now relaunches, so
81
+ // they are deleted — their presence would mean the retired path survived.
82
+ expect(GATEWAY_SRC).not.toContain('function recordTypedModelSwitch')
83
+ expect(GATEWAY_SRC).not.toContain('function recordModelMenuSideEffects')
87
84
  })
88
85
 
89
- it('a confirmed "Default" selection CLEARS the carrier', () => {
90
- const idx = GATEWAY_SRC.indexOf('function recordModelMenuSideEffects')
91
- const win = GATEWAY_SRC.slice(idx, idx + 2400)
92
- expect(win).toContain('outcome.clearedDefault')
93
- expect(win).toContain('clearSessionModelFile(smDir)')
86
+ it('no isSrToClaudeTransition wiring (every switch relaunches — no distinct transition)', () => {
87
+ expect(GATEWAY_SRC).not.toContain('isSrToClaudeTransition')
94
88
  })
95
89
 
96
- it('the sr-* callback branch calls scheduleModelRelaunch, not inject', () => {
97
- const idx = GATEWAY_SRC.indexOf('const srLabel = escapeHtmlForTg(srFriendlyLabel(srName))')
90
+ it('buildModelDeps wires neither the inject nor the select terminal-driver dep', () => {
91
+ const idx = GATEWAY_SRC.indexOf('function buildModelDeps')
98
92
  expect(idx).toBeGreaterThan(0)
99
- const win = GATEWAY_SRC.slice(idx, idx + 1400)
100
- expect(win).toContain('modelDeps.scheduleModelRelaunch(srName')
101
- expect(win).toMatch(/scheduleModelRelaunch[\s\S]*?\n\s*return\n/)
93
+ const win = GATEWAY_SRC.slice(idx, idx + 4000)
94
+ expect(win).not.toContain('inject: injectSlashCommandImpl')
95
+ expect(win).not.toContain('select: (a, label) => selectModel')
102
96
  })
97
+ })
103
98
 
104
- it('the sr-to-claude transition (a relaunch) writes the carrier, and clears it on a Default tap', () => {
105
- const idx = GATEWAY_SRC.indexOf('isSrToClaudeTransition(prevSessionModel, outcome.selectedModel)')
99
+ describe('gateway: scheduleModelDefaultRelaunch (G1 — clear + revert relaunch)', () => {
100
+ it('clears the carrier + override and mirrors scheduleModelRelaunch rollback', () => {
101
+ const idx = GATEWAY_SRC.indexOf('scheduleModelDefaultRelaunch: async')
106
102
  expect(idx).toBeGreaterThan(0)
107
- const win = GATEWAY_SRC.slice(idx, idx + 3600)
108
- expect(win).toMatch(/writeSessionModelFile\(\s*agentDir,\s*token/)
109
- expect(win).toContain('clearSessionModelFile(agentDir)')
110
- expect(win).toContain("triggerSelfRestart(agentName, 'sr-to-claude-model-switch'")
103
+ const win = GATEWAY_SRC.slice(idx, idx + 900)
104
+ const clearIdx = win.indexOf('clearSessionModelFile(agentDir)')
105
+ const overrideIdx = win.indexOf('sessionModelSource.setOverride(null)')
106
+ const restartIdx = win.indexOf('deps.scheduleRestart(reason)')
107
+ expect(clearIdx).toBeGreaterThan(0)
108
+ expect(overrideIdx).toBeGreaterThan(clearIdx)
109
+ expect(restartIdx).toBeGreaterThan(overrideIdx)
110
+ // G1 rollback on a non-in-flight dispatch failure.
111
+ expect(win).toContain('restoreSessionModelFileRaw(agentDir, prevFileRaw)')
112
+ expect(win).toContain("!== 'restart_in_flight'")
111
113
  })
112
114
  })
113
115
 
114
- describe('gateway: typed /model is session-scoped (live Claude switch writes no carrier)', () => {
115
- it('the Claude path sets the in-memory override but never persists a carrier', () => {
116
- const idx = GATEWAY_SRC.indexOf('function recordTypedModelSwitch')
116
+ describe('gateway: the live callback dispatcher routes every switch tap to the handler', () => {
117
+ it('calls handleModelMenuCallback and no longer post-processes a scrape outcome', () => {
118
+ const idx = GATEWAY_SRC.indexOf('const outcome = await handleModelMenuCallback(data, modelDeps)')
117
119
  expect(idx).toBeGreaterThan(0)
118
- const win = GATEWAY_SRC.slice(idx, idx + 1400)
119
- expect(win).toContain('sessionModelSource.setOverride(reply.selectedModel)')
120
- // No durable carrier write on the live-switch path.
121
- expect(win).not.toContain('writeSessionModelFile(')
122
- })
123
-
124
- it('`/model default` clears the carrier + in-memory override (silent-switch path must not resurrect)', () => {
125
- const idx = GATEWAY_SRC.indexOf('function recordTypedModelSwitch')
126
- const win = GATEWAY_SRC.slice(idx, idx + 1400)
127
- expect(win).toContain("requested?.toLowerCase() === 'default'")
128
- expect(win).toContain('clearSessionModelFile(smDir)')
129
- expect(win).toContain('sessionModelSource.setOverride(null)')
130
- // The file-clear is not gated on a positive confirmation.
131
- const clearIdx = win.indexOf('if (smDir) clearSessionModelFile(smDir)')
132
- const gatedOverrideIdx = win.indexOf('if (reply.selectedModel) sessionModelSource.setOverride(null)')
133
- expect(clearIdx).toBeGreaterThan(0)
134
- expect(gatedOverrideIdx).toBeGreaterThan(clearIdx)
120
+ const win = GATEWAY_SRC.slice(idx, idx + 600)
121
+ expect(win).not.toContain('recordModelMenuSideEffects')
122
+ })
123
+
124
+ it('the typed dispatcher relays the handler reply directly (no recordTypedModelSwitch)', () => {
125
+ const idx = GATEWAY_SRC.indexOf('const reply = await handleModelCommand(parsed, deps)')
126
+ expect(idx).toBeGreaterThan(0)
127
+ const win = GATEWAY_SRC.slice(idx, idx + 400)
128
+ expect(win).not.toContain('recordTypedModelSwitch')
129
+ expect(win).toContain('switchroomReply(ctx, reply.text')
135
130
  })
136
131
  })
137
132
 
138
- describe('gateway boot: session-model re-hydration + alert relay', () => {
139
- it('re-hydrates the override from .active-session-model', () => {
133
+ describe('gateway boot: session-model re-hydration + confirmation + alert relay', () => {
134
+ it('re-hydrates the override from .active-session-model (launched !== configured)', () => {
140
135
  const idx = GATEWAY_SRC.indexOf("join(smAgentDir, '.active-session-model')")
141
136
  expect(idx).toBeGreaterThan(0)
142
- const win = GATEWAY_SRC.slice(idx - 200, idx + 1400)
143
- expect(win).toMatch(/launched\.length > 0 && launched !== configured \? launched : null/)
137
+ const win = GATEWAY_SRC.slice(idx - 200, idx + 3200)
138
+ // F1: `launched !== configured` is the deterministic apply-boot signal.
139
+ expect(win).toContain('const isApplyBoot = launched.length > 0 && launched !== configured')
140
+ expect(win).toContain('sessionModelSource.setOverride(isApplyBoot ? launched : null)')
144
141
  expect(win).toContain('resolveMainModel(raw ?? undefined)')
145
142
  })
146
143
 
144
+ it('logs the applied model for diagnosability (F1)', () => {
145
+ expect(GATEWAY_SRC).toContain('gw /model relaunch applied agent=')
146
+ expect(GATEWAY_SRC).toContain('gw /model relaunch scheduled agent=')
147
+ })
148
+
149
+ it('sends ONE switch-confirmation from the ACTUAL launched model, keyed on the /model reason (F1/N4)', () => {
150
+ const idx = GATEWAY_SRC.indexOf('const isApplyBoot = launched.length > 0')
151
+ expect(idx).toBeGreaterThan(0)
152
+ const win = GATEWAY_SRC.slice(idx, idx + 3400)
153
+ // Keyed on the deterministic /model switch reason, so it also fires on a
154
+ // launched===configured apply-boot (/model default) — N4. Never optimistic.
155
+ expect(win).toContain('if (modelSwitchReason != null && modelSwitchMarkerChat)')
156
+ expect(win).toContain('✅ Now running')
157
+ // N4: the launched===configured branch still confirms.
158
+ expect(win).toContain('(the configured default)')
159
+ })
160
+
161
+ it('N4/reason: the /model switch reason is captured from the clean-shutdown marker', () => {
162
+ expect(GATEWAY_SRC).toContain("cleanMarker.reason.startsWith('user: /model')")
163
+ expect(GATEWAY_SRC).toContain('let modelSwitchReason: string | null = null')
164
+ })
165
+
166
+ it('N3: the generic boot card is suppressed on a /model apply-boot (one card per switch)', () => {
167
+ const idx = GATEWAY_SRC.indexOf('const suppressBootCardForModelSwitch')
168
+ expect(idx).toBeGreaterThan(0)
169
+ const win = GATEWAY_SRC.slice(idx, idx + 800)
170
+ expect(win).toContain('modelSwitchReason != null && modelSwitchMarkerChat != null')
171
+ expect(win).toContain('else if (target)')
172
+ })
173
+
147
174
  it('consumes the .session-model-alert sentinel, notifies ALL operators, and deletes it', () => {
148
175
  const idx = GATEWAY_SRC.indexOf("join(smAgentDir, '.session-model-alert')")
149
176
  expect(idx).toBeGreaterThan(0)
@@ -16,6 +16,7 @@ import {
16
16
  hasOutboundDeliveredSince,
17
17
  hasOutboundWithText,
18
18
  normalizeDeliveryText,
19
+ verifyHistoryWritable,
19
20
  _resetForTests,
20
21
  } from '../history.js'
21
22
 
@@ -963,3 +964,90 @@ describe('hasOutboundWithText (durable text-identity oracle)', () => {
963
964
  expect(hasOutboundWithText('1', 'repeated answer', null, 250_000)).toBe(true)
964
965
  })
965
966
  })
967
+
968
+ // ── 2026-07-16 incident hardening: writer durability across restart + surfacing
969
+ // swallowed insert failures. Root cause: turn-flush deliveries (18944/18958)
970
+ // reached Telegram but were absent from history.db, blinding
971
+ // getRecentOutboundCount / hasOutboundDeliveredSince. The gateway had already
972
+ // logged "history capture enabled" on both restarts, so DB-open success was
973
+ // NOT proof the row-insert path worked. These tests pin the durable fix.
974
+ describe('history writer durability (2026-07-16 incident)', () => {
975
+ it('verifyHistoryWritable proves the INSERT path works on a live DB', () => {
976
+ initHistory(stateDir, 30)
977
+ const res = verifyHistoryWritable()
978
+ expect(res.ok).toBe(true)
979
+ // The self-check must leave NO sentinel residue behind.
980
+ expect(getRecentOutboundCount('__history_selfcheck__', 86_400)).toBe(0)
981
+ })
982
+
983
+ it('verifyHistoryWritable reports not-ok before init (no silent success)', () => {
984
+ // No initHistory() this test — the writer is uninitialised.
985
+ const res = verifyHistoryWritable()
986
+ expect(res.ok).toBe(false)
987
+ expect(res.error).toMatch(/initHistory/)
988
+ })
989
+
990
+ // The core recovery contract: recording must survive a shutdown + reinit
991
+ // (a gateway restart, which nulls the module singleton and re-opens the same
992
+ // file). Rows written before AND after the boundary must all be queryable.
993
+ it('recording continues across a simulated restart (reinit of the same DB)', () => {
994
+ const nowSec = Math.floor(Date.now() / 1000)
995
+ initHistory(stateDir, 30)
996
+ recordOutbound({ chat_id: '9', thread_id: null, message_ids: [100], texts: ['before restart'], ts: nowSec - 60 })
997
+ // Simulate a gateway restart: close + forget the singleton, then reinit
998
+ // against the SAME stateDir (fresh process, db=null → re-open).
999
+ _resetForTests()
1000
+ initHistory(stateDir, 30)
1001
+ // Boot self-check must still pass against the existing, populated file.
1002
+ expect(verifyHistoryWritable().ok).toBe(true)
1003
+ recordOutbound({ chat_id: '9', thread_id: null, message_ids: [101], texts: ['after restart'], ts: nowSec })
1004
+ const rows = query({ chat_id: '9' })
1005
+ expect(rows.map((r) => r.message_id)).toEqual([100, 101])
1006
+ // The backstop suppression counter (the surface blinded by the incident)
1007
+ // must see BOTH the pre- and post-restart outbounds.
1008
+ expect(getRecentOutboundCount('9', 10_000_000_000)).toBe(2)
1009
+ })
1010
+
1011
+ // The exact incident shape: a malformed send result yields an invalid
1012
+ // message_id. OLD behaviour: the NOT NULL PRIMARY KEY throws inside the tx and
1013
+ // the caller's `catch {}` swallows it — the row is lost AND invisible. NEW
1014
+ // behaviour: the invalid chunk is filtered + logged, valid chunks still land,
1015
+ // and no throw escapes to be swallowed.
1016
+ it('drops an invalid message_id chunk loudly but records the valid ones (no silent total loss)', () => {
1017
+ initHistory(stateDir, 30)
1018
+ const errs: string[] = []
1019
+ const orig = process.stderr.write.bind(process.stderr)
1020
+ // @ts-expect-error narrow test shim over the write overloads
1021
+ process.stderr.write = (chunk: string) => { errs.push(String(chunk)); return true }
1022
+ try {
1023
+ recordOutbound({
1024
+ chat_id: '9',
1025
+ thread_id: null,
1026
+ // chunk 0 is a malformed (undefined) id; chunk 1 is real.
1027
+ message_ids: [undefined as unknown as number, 200],
1028
+ texts: ['lost chunk', 'kept chunk'],
1029
+ ts: 3000,
1030
+ })
1031
+ } finally {
1032
+ process.stderr.write = orig
1033
+ }
1034
+ // The valid chunk is recorded (delivery accounting is NOT silently zeroed).
1035
+ const rows = query({ chat_id: '9' })
1036
+ expect(rows.map((r) => r.message_id)).toEqual([200])
1037
+ // The drop was surfaced loudly, not swallowed.
1038
+ expect(errs.join('')).toMatch(/invalid message_id/)
1039
+ })
1040
+
1041
+ it('recordOutbound with an all-invalid id set no-ops without throwing', () => {
1042
+ initHistory(stateDir, 30)
1043
+ expect(() =>
1044
+ recordOutbound({
1045
+ chat_id: '9',
1046
+ thread_id: null,
1047
+ message_ids: [NaN, null as unknown as number],
1048
+ texts: ['a', 'b'],
1049
+ }),
1050
+ ).not.toThrow()
1051
+ expect(getRecentOutboundCount('9', 10_000_000_000)).toBe(0)
1052
+ })
1053
+ })
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Unit tests for the model-facing local-time primitives in
3
+ * telegram-plugin/shared/local-time.ts.
4
+ *
5
+ * switchroom #tz-fix: `fmtLocalStamp` + `resolveEnvTimezone` are the single
6
+ * source of truth for the LOCAL am/pm timestamps the model now sees on inbound
7
+ * `<channel ts="…">` tags, `forwarded_date`, and the get_recent_messages
8
+ * buffer — replacing the UTC ISO strings that competed with the local-time
9
+ * hint. These pin the deterministic outcome: local am/pm, NEVER a UTC string.
10
+ */
11
+
12
+ import { describe, expect, it } from 'vitest'
13
+ import { fmtLocalStamp, resolveEnvTimezone } from '../shared/local-time.js'
14
+
15
+ // 2025-06-15T14:26:40Z — a fixed instant so the local rendering is deterministic.
16
+ const MS = 1750000000 * 1000
17
+
18
+ describe('resolveEnvTimezone', () => {
19
+ it('follows the SWITCHROOM_TIMEZONE → TZ → UTC cascade', () => {
20
+ expect(resolveEnvTimezone({ SWITCHROOM_TIMEZONE: 'Australia/Melbourne', TZ: 'America/New_York' })).toBe(
21
+ 'Australia/Melbourne',
22
+ )
23
+ expect(resolveEnvTimezone({ TZ: 'America/New_York' })).toBe('America/New_York')
24
+ expect(resolveEnvTimezone({})).toBe('UTC')
25
+ })
26
+ })
27
+
28
+ describe('fmtLocalStamp', () => {
29
+ it('renders LOCAL am/pm with weekday, ISO date, and zone abbrev — no UTC', () => {
30
+ const out = fmtLocalStamp(MS, 'Australia/Melbourne')
31
+ // e.g. "Sunday 2025-06-16 12:26 AM AEST"
32
+ expect(out).toMatch(/^[A-Za-z]+ \d{4}-\d{2}-\d{2} \d{2}:\d{2} (?:AM|PM) [A-Za-z]{2,5}$/)
33
+ expect(out).not.toContain('UTC')
34
+ expect(out.endsWith('Z')).toBe(false)
35
+ })
36
+
37
+ it('reflects the requested zone (different wall clock for a different tz)', () => {
38
+ const melbourne = fmtLocalStamp(MS, 'Australia/Melbourne')
39
+ const newYork = fmtLocalStamp(MS, 'America/New_York')
40
+ expect(melbourne).not.toBe(newYork)
41
+ expect(newYork).toMatch(/ (?:AM|PM) [A-Za-z]{2,5}$/)
42
+ })
43
+
44
+ it('is total — an invalid IANA zone degrades to am/pm, never throws', () => {
45
+ // Must not throw out of the inbound path on a misconfigured agent.
46
+ const out = fmtLocalStamp(MS, 'Not/ARealZone')
47
+ expect(typeof out).toBe('string')
48
+ expect(out.length).toBeGreaterThan(0)
49
+ })
50
+
51
+ it('UTC zone still renders am/pm (24h/"UTC-suffix" form is gone)', () => {
52
+ const out = fmtLocalStamp(MS, 'UTC')
53
+ expect(out).toMatch(/ (?:AM|PM) /)
54
+ })
55
+
56
+ it('tracks DST — Australia/Melbourne is AEDT in Jan (summer) and AEST in Jul (winter)', () => {
57
+ // Southern-hemisphere DST: daylight time is the Dec–Mar summer.
58
+ const summer = fmtLocalStamp(Date.UTC(2026, 0, 15, 3, 0, 0), 'Australia/Melbourne') // 15 Jan
59
+ const winter = fmtLocalStamp(Date.UTC(2026, 6, 15, 3, 0, 0), 'Australia/Melbourne') // 15 Jul
60
+ expect(summer).toContain('AEDT')
61
+ expect(winter).toContain('AEST')
62
+ // Both stay am/pm and UTC-free across the transition.
63
+ for (const s of [summer, winter]) {
64
+ expect(s).toMatch(/ (?:AM|PM) /)
65
+ expect(s).not.toContain('UTC')
66
+ }
67
+ })
68
+ })