switchroom 0.18.28 → 0.18.30
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/bin/handoff-briefing.sh +15 -2
- package/dist/agent-scheduler/index.js +111 -7
- package/dist/auth-broker/index.js +154 -73
- package/dist/cli/autoaccept-poll.js +8 -3
- package/dist/cli/drive-write-pretool.mjs +8 -3
- package/dist/cli/ms-365-write-pretool.mjs +158 -11
- package/dist/cli/notion-write-pretool.mjs +103 -4
- package/dist/cli/switchroom.js +2712 -2219
- package/dist/host-control/main.js +110 -70
- package/dist/vault/approvals/kernel-server.js +116 -70
- package/dist/vault/broker/server.js +314 -202
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +105 -34
- package/telegram-plugin/dist/bridge/bridge.js +71 -47
- package/telegram-plugin/dist/gateway/gateway.js +1128 -666
- package/telegram-plugin/dist/server.js +89 -64
- package/telegram-plugin/gateway/backstop-delivery.ts +272 -0
- package/telegram-plugin/gateway/forward-origin.ts +9 -1
- package/telegram-plugin/gateway/gateway.ts +656 -388
- package/telegram-plugin/gateway/model-command.ts +331 -602
- package/telegram-plugin/gateway/session-model-file.ts +40 -0
- package/telegram-plugin/gateway/turn-record-status.ts +45 -0
- package/telegram-plugin/gateway/unhandled-message.ts +177 -0
- package/telegram-plugin/history.ts +153 -23
- package/telegram-plugin/llm-error-present.ts +24 -0
- package/telegram-plugin/model-unavailable.ts +55 -0
- package/telegram-plugin/operator-events.ts +113 -0
- package/telegram-plugin/pending-user-notice.ts +88 -0
- package/telegram-plugin/shared/local-time.ts +99 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +250 -0
- package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +103 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +264 -0
- package/telegram-plugin/tests/forward-origin.test.ts +30 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +111 -60
- package/telegram-plugin/tests/history.test.ts +88 -0
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +278 -0
- package/telegram-plugin/tests/local-time.test.ts +135 -0
- package/telegram-plugin/tests/model-command.test.ts +427 -1512
- package/telegram-plugin/tests/session-model-file.test.ts +23 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +34 -0
- package/telegram-plugin/tier-downgrade.ts +4 -3
- package/telegram-plugin/turn-flush-safety.ts +25 -1
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +399 -2
- package/vendor/hindsight-memory/scripts/lib/client.py +47 -0
- package/vendor/hindsight-memory/scripts/lib/content.py +93 -7
- package/vendor/hindsight-memory/scripts/lib/turnlog.py +450 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +467 -0
- package/vendor/hindsight-memory/tests/test_content.py +63 -7
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for the terminal catch-all message handler + diagnostic
|
|
3
|
+
* update tap (#3300).
|
|
4
|
+
*
|
|
5
|
+
* THE CLASS OF BUG: the gateway registered ONLY content-specific handlers —
|
|
6
|
+
* `bot.on('message:text')`, `:photo`, `:document`, … `:paid_media`. grammy
|
|
7
|
+
* ^1.44 routes `bot.on` as filtering middleware (`on → filter(pred, handler)
|
|
8
|
+
* → branch(pred, handler, pass)`, verified in grammy/out/composer.js); when
|
|
9
|
+
* NO registered filter matches an inbound `message`, grammy SILENTLY drops
|
|
10
|
+
* it — no log, no ack, no history row. Live signature (2026-07-16, klanker
|
|
11
|
+
* DM): message_id 19090 was allocated between an outbound reply and the next
|
|
12
|
+
* inbound with ZERO gateway trace while polling stayed healthy.
|
|
13
|
+
*
|
|
14
|
+
* THE FIX under test is the REAL production module
|
|
15
|
+
* (`gateway/unhandled-message.ts`) — the same `installUpdateTap` /
|
|
16
|
+
* `installUnhandledMessageCatchAll` gateway.ts wires — driven through a real
|
|
17
|
+
* grammy 1.44 Bot via `bot.handleUpdate`, with a real `bot.on('message:text')`
|
|
18
|
+
* registered BEFORE the catch-all exactly like the gateway's registration
|
|
19
|
+
* order. (gateway.ts itself is a side-effecting module that cannot be
|
|
20
|
+
* imported into a unit test — the extraction into unhandled-message.ts exists
|
|
21
|
+
* precisely so the registered composer path is testable; a structural suite
|
|
22
|
+
* below guards that gateway.ts actually wires it in that order.)
|
|
23
|
+
*
|
|
24
|
+
* Outcomes asserted:
|
|
25
|
+
* (b) an unregistered content type reaches the catch-all, is logged, and
|
|
26
|
+
* yields a turn with the placeholder text naming the content type;
|
|
27
|
+
* (c) a plain text message is consumed by the specific handler and does
|
|
28
|
+
* NOT double-handle in the catch-all;
|
|
29
|
+
* service-noise messages (forum topic lifecycle etc.) are logged but do
|
|
30
|
+
* NOT become turns;
|
|
31
|
+
* the tap observes every update and rate-limits with a suppression summary.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
35
|
+
import { readFileSync } from 'node:fs'
|
|
36
|
+
import { Bot, type Context } from 'grammy'
|
|
37
|
+
import type { Update } from 'grammy/types'
|
|
38
|
+
import { makeMessageUpdate, resetUpdateCounters } from './update-factory.js'
|
|
39
|
+
import {
|
|
40
|
+
installUpdateTap,
|
|
41
|
+
installUnhandledMessageCatchAll,
|
|
42
|
+
planUnhandledMessage,
|
|
43
|
+
SERVICE_NOISE_KEYS,
|
|
44
|
+
TAP_MAX_LINES_PER_MINUTE,
|
|
45
|
+
} from '../gateway/unhandled-message.js'
|
|
46
|
+
|
|
47
|
+
interface DeliveredTurn {
|
|
48
|
+
via: 'text' | 'catch-all'
|
|
49
|
+
text: string
|
|
50
|
+
update_id: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Wire a real grammy Bot in the gateway's registration order using the REAL
|
|
55
|
+
* production install functions: tap first, specific `message:text` handler,
|
|
56
|
+
* terminal catch-all LAST. `onInbound` records what would flow into
|
|
57
|
+
* handleInboundCoalesced.
|
|
58
|
+
*/
|
|
59
|
+
function buildHarness() {
|
|
60
|
+
const delivered: DeliveredTurn[] = []
|
|
61
|
+
const logLines: string[] = []
|
|
62
|
+
|
|
63
|
+
const bot = new Bot('12345:TEST_TOKEN_NOT_REAL')
|
|
64
|
+
bot.botInfo = {
|
|
65
|
+
id: 999,
|
|
66
|
+
is_bot: true,
|
|
67
|
+
first_name: 'TestBot',
|
|
68
|
+
username: 'test_bot',
|
|
69
|
+
can_join_groups: true,
|
|
70
|
+
can_read_all_group_messages: false,
|
|
71
|
+
supports_inline_queries: false,
|
|
72
|
+
can_connect_to_business: false,
|
|
73
|
+
has_main_web_app: false,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// REAL production tap (same call gateway.ts makes).
|
|
77
|
+
installUpdateTap(bot, line => logLines.push(line))
|
|
78
|
+
|
|
79
|
+
// Specific handler — representative of the gateway's message:text
|
|
80
|
+
// registration, placed BEFORE the catch-all exactly as in gateway.ts.
|
|
81
|
+
bot.on('message:text', async ctx => {
|
|
82
|
+
delivered.push({ via: 'text', text: ctx.message.text, update_id: ctx.update.update_id })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// REAL production catch-all, registered LAST (same call gateway.ts makes).
|
|
86
|
+
installUnhandledMessageCatchAll(
|
|
87
|
+
bot,
|
|
88
|
+
async (ctx: Context, text: string) => {
|
|
89
|
+
delivered.push({ via: 'catch-all', text, update_id: ctx.update.update_id })
|
|
90
|
+
},
|
|
91
|
+
line => logLines.push(line),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return { bot, delivered, logLines }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A message update carrying content that no `message:*` filter covers. */
|
|
98
|
+
function makeContentUpdate(update_id: number, content: Record<string, unknown>): Update {
|
|
99
|
+
return {
|
|
100
|
+
update_id,
|
|
101
|
+
message: {
|
|
102
|
+
message_id: 5000 + update_id,
|
|
103
|
+
chat: { id: 777, type: 'private' },
|
|
104
|
+
from: { id: 777, is_bot: false, first_name: 'Test' },
|
|
105
|
+
date: Math.floor(Date.now() / 1000),
|
|
106
|
+
...content,
|
|
107
|
+
},
|
|
108
|
+
} as unknown as Update
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
describe('terminal catch-all — real module on a real grammy 1.44 composer (#3300)', () => {
|
|
112
|
+
beforeEach(() => resetUpdateCounters())
|
|
113
|
+
|
|
114
|
+
it('(c) a plain-text message is consumed by message:text and does NOT reach the catch-all', async () => {
|
|
115
|
+
const { bot, delivered, logLines } = buildHarness()
|
|
116
|
+
await bot.handleUpdate(makeMessageUpdate({ text: 'hello brief', update_id: 42 }))
|
|
117
|
+
|
|
118
|
+
expect(delivered).toHaveLength(1)
|
|
119
|
+
expect(delivered[0]).toMatchObject({ via: 'text', text: 'hello brief' })
|
|
120
|
+
// No catch-all log line — specific handler won (no double-handling).
|
|
121
|
+
expect(logLines.filter(l => l.includes('catch-all inbound'))).toHaveLength(0)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('(b) an unregistered content type reaches the catch-all, is logged, and yields a placeholder turn', async () => {
|
|
125
|
+
const { bot, delivered, logLines } = buildHarness()
|
|
126
|
+
await bot.handleUpdate(makeContentUpdate(99, { unknown_future_type: { some: 'payload' } }))
|
|
127
|
+
|
|
128
|
+
// Logged with content KEYS + ids only (never the payload body).
|
|
129
|
+
const catchAllLines = logLines.filter(l => l.includes('catch-all inbound'))
|
|
130
|
+
expect(catchAllLines).toHaveLength(1)
|
|
131
|
+
expect(catchAllLines[0]).toContain('update_id=99')
|
|
132
|
+
expect(catchAllLines[0]).toContain('content_keys=[unknown_future_type]')
|
|
133
|
+
expect(catchAllLines[0]).not.toContain('payload')
|
|
134
|
+
|
|
135
|
+
// Produced a delivered turn with placeholder text naming the content type.
|
|
136
|
+
expect(delivered).toHaveLength(1)
|
|
137
|
+
expect(delivered[0]).toMatchObject({
|
|
138
|
+
via: 'catch-all',
|
|
139
|
+
text: '(unhandled message content: unknown_future_type)',
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('a caption on an unhandled content type is used as the turn text (best-effort)', async () => {
|
|
144
|
+
const { bot, delivered } = buildHarness()
|
|
145
|
+
await bot.handleUpdate(
|
|
146
|
+
makeContentUpdate(120, { some_new_media: { id: 'x' }, caption: 'read this brief' }),
|
|
147
|
+
)
|
|
148
|
+
expect(delivered).toHaveLength(1)
|
|
149
|
+
expect(delivered[0]).toMatchObject({ via: 'catch-all', text: 'read this brief' })
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('known-noise service messages are LOGGED but produce NO agent turn', async () => {
|
|
153
|
+
const { bot, delivered, logLines } = buildHarness()
|
|
154
|
+
await bot.handleUpdate(
|
|
155
|
+
makeContentUpdate(130, { forum_topic_created: { name: 'spam topic', icon_color: 1 } }),
|
|
156
|
+
)
|
|
157
|
+
await bot.handleUpdate(
|
|
158
|
+
makeContentUpdate(131, { new_chat_members: [{ id: 5, is_bot: false, first_name: 'X' }] }),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
// No turns delivered — service noise never reaches the agent.
|
|
162
|
+
expect(delivered).toHaveLength(0)
|
|
163
|
+
// But both were explicitly logged (never silently dropped).
|
|
164
|
+
const noiseLines = logLines.filter(l => l.includes('action=log-only'))
|
|
165
|
+
expect(noiseLines).toHaveLength(2)
|
|
166
|
+
expect(noiseLines[0]).toContain('content_keys=[forum_topic_created]')
|
|
167
|
+
expect(noiseLines[1]).toContain('content_keys=[new_chat_members]')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('the diagnostic tap observes EVERY update (pass-through, both routed and caught)', async () => {
|
|
171
|
+
const { bot, logLines } = buildHarness()
|
|
172
|
+
await bot.handleUpdate(makeMessageUpdate({ text: 'routed', update_id: 1 }))
|
|
173
|
+
await bot.handleUpdate(makeContentUpdate(2, { unknown_future_type: {} }))
|
|
174
|
+
|
|
175
|
+
const rx = logLines.filter(l => l.includes('rx update_id='))
|
|
176
|
+
expect(rx).toHaveLength(2)
|
|
177
|
+
expect(rx[0]).toContain('rx update_id=1 type=message')
|
|
178
|
+
expect(rx[1]).toContain('rx update_id=2 type=message')
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('planUnhandledMessage — service-noise classification', () => {
|
|
183
|
+
it('every SERVICE_NOISE_KEYS entry classifies log-only', () => {
|
|
184
|
+
for (const key of SERVICE_NOISE_KEYS) {
|
|
185
|
+
const plan = planUnhandledMessage({ message_id: 1, chat: {}, date: 0, [key]: {} })
|
|
186
|
+
expect(plan.action, `key ${key} should be log-only`).toBe('log-only')
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('an unknown content type classifies as a turn (fail-toward-delivery)', () => {
|
|
191
|
+
const plan = planUnhandledMessage({ message_id: 1, chat: {}, date: 0, mystery_type: {} })
|
|
192
|
+
expect(plan).toMatchObject({
|
|
193
|
+
action: 'turn',
|
|
194
|
+
text: '(unhandled message content: mystery_type)',
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('a message mixing noise with real content still becomes a turn', () => {
|
|
199
|
+
const plan = planUnhandledMessage({
|
|
200
|
+
message_id: 1, chat: {}, date: 0,
|
|
201
|
+
boost_added: {}, mystery_media: {}, caption: 'look',
|
|
202
|
+
})
|
|
203
|
+
expect(plan).toMatchObject({ action: 'turn', text: 'look' })
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
describe('installUpdateTap — rate limit', () => {
|
|
208
|
+
it('caps lines per minute and emits ONE suppression summary on window rollover', async () => {
|
|
209
|
+
const logLines: string[] = []
|
|
210
|
+
let now = 1_000_000
|
|
211
|
+
const mw: Array<(ctx: unknown, next: () => Promise<void>) => Promise<void>> = []
|
|
212
|
+
const fakeBot = { use: (fn: (ctx: unknown, next: () => Promise<void>) => Promise<void>) => { mw.push(fn) } }
|
|
213
|
+
installUpdateTap(fakeBot as never, l => logLines.push(l), () => now)
|
|
214
|
+
|
|
215
|
+
const fire = (update_id: number) =>
|
|
216
|
+
mw[0]({ update: { update_id }, message: undefined } as never, async () => {})
|
|
217
|
+
|
|
218
|
+
for (let i = 0; i < TAP_MAX_LINES_PER_MINUTE + 50; i++) await fire(i)
|
|
219
|
+
expect(logLines.filter(l => l.includes('rx update_id='))).toHaveLength(TAP_MAX_LINES_PER_MINUTE)
|
|
220
|
+
|
|
221
|
+
// Roll the window: the 50 suppressed lines surface as ONE summary.
|
|
222
|
+
now += 61_000
|
|
223
|
+
await fire(9999)
|
|
224
|
+
const summaries = logLines.filter(l => l.includes('suppressed 50 update lines'))
|
|
225
|
+
expect(summaries).toHaveLength(1)
|
|
226
|
+
// And logging resumes in the fresh window.
|
|
227
|
+
expect(logLines.filter(l => l.includes('rx update_id=9999'))).toHaveLength(1)
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
// ─── Structural guard on the real gateway.ts wiring ────────────────────────
|
|
232
|
+
// The functional harness above proves the composer contract using the REAL
|
|
233
|
+
// production install functions; this guards that gateway.ts wires those same
|
|
234
|
+
// functions in the required order. Hardened beyond a naive regex: it fails
|
|
235
|
+
// if the install call disappears (e.g. refactored away) OR if ANY
|
|
236
|
+
// `bot.on('message…')` registration appears after the catch-all install.
|
|
237
|
+
describe('gateway.ts catch-all registration invariant', () => {
|
|
238
|
+
const SRC = readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8')
|
|
239
|
+
|
|
240
|
+
it('imports and installs the real catch-all + tap from unhandled-message.ts', () => {
|
|
241
|
+
expect(SRC).toContain("from './unhandled-message.js'")
|
|
242
|
+
expect(SRC).toContain('installUpdateTap(bot,')
|
|
243
|
+
expect(SRC).toContain('installUnhandledMessageCatchAll(')
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('the catch-all install is AFTER every message handler registration', () => {
|
|
247
|
+
const installIdx = SRC.indexOf('installUnhandledMessageCatchAll(')
|
|
248
|
+
expect(installIdx).toBeGreaterThan(0)
|
|
249
|
+
const after = SRC.slice(installIdx)
|
|
250
|
+
// No message-filter registration of any spelling may follow the terminal
|
|
251
|
+
// catch-all — grammy ordering is the no-double-handling guarantee.
|
|
252
|
+
// (`message_reaction` etc. are DIFFERENT update types, not `message`
|
|
253
|
+
// filters — the pattern requires `message` exactly or `message:<sub>`.)
|
|
254
|
+
expect(after).not.toMatch(/bot\.on\(\s*['"`]message(:|['"`])/)
|
|
255
|
+
// And there must be exactly one catch-all install (no duplicate turns).
|
|
256
|
+
expect(SRC.indexOf('installUnhandledMessageCatchAll(', installIdx + 1)).toBe(-1)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('routes the catch-all through handleInboundCoalesced (same gating + forward-origin path)', () => {
|
|
260
|
+
const installIdx = SRC.indexOf('installUnhandledMessageCatchAll(')
|
|
261
|
+
const body = SRC.slice(installIdx, installIdx + 400)
|
|
262
|
+
expect(body).toMatch(/handleInboundCoalesced\(ctx, text, undefined\)/)
|
|
263
|
+
})
|
|
264
|
+
})
|
|
@@ -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
|
-
|
|
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:
|
|
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(
|
|
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
|
|
3
|
-
* (reference/rfcs/session-model-stickiness.md §0.
|
|
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/
|
|
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 +
|
|
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 +
|
|
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 +
|
|
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,84 +69,134 @@ 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 +
|
|
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:
|
|
77
|
-
it('
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
expect(
|
|
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('
|
|
90
|
-
|
|
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
|
|
97
|
-
const idx = GATEWAY_SRC.indexOf('
|
|
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 +
|
|
100
|
-
expect(win).toContain('
|
|
101
|
-
expect(win).
|
|
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
|
-
|
|
105
|
-
|
|
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 +
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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:
|
|
115
|
-
it('
|
|
116
|
-
const idx = GATEWAY_SRC.indexOf('
|
|
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 +
|
|
119
|
-
expect(win).toContain('
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
expect(win).toContain(
|
|
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 +
|
|
143
|
-
|
|
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 + 5200)
|
|
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('warns instead of a green ✅ when a non-default switch silently reverted to the default (silent-revert fix)', () => {
|
|
162
|
+
const idx = GATEWAY_SRC.indexOf('const isApplyBoot = launched.length > 0')
|
|
163
|
+
const win = GATEWAY_SRC.slice(idx, idx + 5200)
|
|
164
|
+
// The confirmation card is derived from the pure classifier, not an inline
|
|
165
|
+
// isApplyBoot ternary — so a reverted non-default switch yields the ⚠️ card.
|
|
166
|
+
expect(win).toContain('classifyModelSwitchConfirmation({')
|
|
167
|
+
expect(win).toContain("confirmation.kind === 'applied'")
|
|
168
|
+
expect(win).toContain("confirmation.kind === 'not-applied'")
|
|
169
|
+
expect(win).toContain("⚠️ Your switch to")
|
|
170
|
+
expect(win).toContain("didn't apply")
|
|
171
|
+
// LOW-3: the re-issue hint interpolates the target inside backticks so a
|
|
172
|
+
// token containing Markdown metachars can't italicize / 400 the send.
|
|
173
|
+
expect(win).toContain('Re-issue \\`/model ${confirmation.target}\\`')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('dedups the not-applied card against a tailored .session-model-alert (LOW-2)', () => {
|
|
177
|
+
const idx = GATEWAY_SRC.indexOf('const isApplyBoot = launched.length > 0')
|
|
178
|
+
const win = GATEWAY_SRC.slice(idx, idx + 5200)
|
|
179
|
+
// When start.sh wrote a specific alert for this revert, the classifier's
|
|
180
|
+
// generic not-applied card is suppressed (the alert relay is the message).
|
|
181
|
+
expect(win).toContain("existsSync(join(smAgentDir, '.session-model-alert'))")
|
|
182
|
+
expect(win).toContain("confirmation.kind === 'not-applied' && hasSessionModelAlert")
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('N4/reason: the /model switch reason is captured from the clean-shutdown marker', () => {
|
|
186
|
+
expect(GATEWAY_SRC).toContain("cleanMarker.reason.startsWith('user: /model')")
|
|
187
|
+
expect(GATEWAY_SRC).toContain('let modelSwitchReason: string | null = null')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('N3: the generic boot card is suppressed on a /model apply-boot (one card per switch)', () => {
|
|
191
|
+
const idx = GATEWAY_SRC.indexOf('const suppressBootCardForModelSwitch')
|
|
192
|
+
expect(idx).toBeGreaterThan(0)
|
|
193
|
+
const win = GATEWAY_SRC.slice(idx, idx + 800)
|
|
194
|
+
expect(win).toContain('modelSwitchReason != null && modelSwitchMarkerChat != null')
|
|
195
|
+
expect(win).toContain('else if (target)')
|
|
196
|
+
})
|
|
197
|
+
|
|
147
198
|
it('consumes the .session-model-alert sentinel, notifies ALL operators, and deletes it', () => {
|
|
148
|
-
const idx = GATEWAY_SRC.indexOf("join(smAgentDir, '.session-model-alert')")
|
|
199
|
+
const idx = GATEWAY_SRC.indexOf("const alertPath = join(smAgentDir, '.session-model-alert')")
|
|
149
200
|
expect(idx).toBeGreaterThan(0)
|
|
150
201
|
const win = GATEWAY_SRC.slice(idx, idx + 1100)
|
|
151
202
|
expect(win).toContain('unlinkSync(alertPath)')
|
|
@@ -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
|
+
})
|