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.
@@ -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 4consume-once).
2
+ * Structural pins for the DETERMINISTIC /model wiring in gateway.ts
3
+ * (reference/rfcs/session-model-stickiness.md §0.05, rev 5every 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
+ })