switchroom 0.14.47 → 0.14.49

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.
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Unit tests for the cross-reboot boot-card message-id store
3
+ * (gateway/boot-card-msgid.ts) — the persistence that lets a routine reboot
4
+ * EDIT the prior boot card in place (zero notification) instead of sending a
5
+ * new one.
6
+ *
7
+ * All I/O is to an isolated mkdtemp dir — NEVER ~/.switchroom (test discipline).
8
+ */
9
+
10
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
11
+ import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'
12
+ import { tmpdir } from 'node:os'
13
+ import { join } from 'node:path'
14
+ import {
15
+ bootCardChatKey,
16
+ loadBootCardMsgId,
17
+ saveBootCardMsgId,
18
+ } from '../gateway/boot-card-msgid.js'
19
+
20
+ let dir: string
21
+ let path: string
22
+
23
+ beforeEach(() => {
24
+ dir = mkdtempSync(join(tmpdir(), 'boot-card-msgid-'))
25
+ path = join(dir, '.boot-card-msgid.json')
26
+ })
27
+ afterEach(() => {
28
+ rmSync(dir, { recursive: true, force: true })
29
+ })
30
+
31
+ describe('bootCardChatKey', () => {
32
+ it('keys a DM (no topic) distinctly from a supergroup topic', () => {
33
+ expect(bootCardChatKey('12345', undefined)).toBe('12345:')
34
+ expect(bootCardChatKey('-1001234567890', 4)).toBe('-1001234567890:4')
35
+ // Different topics in the same supergroup are distinct cards.
36
+ expect(bootCardChatKey('-100', 3)).not.toBe(bootCardChatKey('-100', 4))
37
+ })
38
+ })
39
+
40
+ describe('loadBootCardMsgId / saveBootCardMsgId', () => {
41
+ it('returns null when the file does not exist (first boot)', () => {
42
+ expect(loadBootCardMsgId(path, 'dm:')).toBeNull()
43
+ })
44
+
45
+ it('round-trips a saved id', () => {
46
+ saveBootCardMsgId(path, 'dm:', 353)
47
+ expect(loadBootCardMsgId(path, 'dm:')).toBe(353)
48
+ })
49
+
50
+ it('keeps ids for different chats independent', () => {
51
+ saveBootCardMsgId(path, 'dm:', 100)
52
+ saveBootCardMsgId(path, '-100:4', 200)
53
+ expect(loadBootCardMsgId(path, 'dm:')).toBe(100)
54
+ expect(loadBootCardMsgId(path, '-100:4')).toBe(200)
55
+ // Updating one leaves the other intact.
56
+ saveBootCardMsgId(path, 'dm:', 101)
57
+ expect(loadBootCardMsgId(path, 'dm:')).toBe(101)
58
+ expect(loadBootCardMsgId(path, '-100:4')).toBe(200)
59
+ })
60
+
61
+ it('returns null for an unknown chat key', () => {
62
+ saveBootCardMsgId(path, 'dm:', 1)
63
+ expect(loadBootCardMsgId(path, 'other:')).toBeNull()
64
+ })
65
+
66
+ it('rejects non-positive / non-finite ids on save and load', () => {
67
+ saveBootCardMsgId(path, 'dm:', 0)
68
+ saveBootCardMsgId(path, 'dm:', -5)
69
+ saveBootCardMsgId(path, 'dm:', Number.NaN)
70
+ expect(loadBootCardMsgId(path, 'dm:')).toBeNull()
71
+ })
72
+
73
+ it('treats a corrupt file as empty (falls back to fresh send)', () => {
74
+ writeFileSync(path, 'not json{', 'utf8')
75
+ expect(loadBootCardMsgId(path, 'dm:')).toBeNull()
76
+ // And a subsequent save still works (overwrites the garbage).
77
+ saveBootCardMsgId(path, 'dm:', 7)
78
+ expect(loadBootCardMsgId(path, 'dm:')).toBe(7)
79
+ })
80
+
81
+ it('does not rewrite the file when the id is unchanged (idempotent)', () => {
82
+ saveBootCardMsgId(path, 'dm:', 42)
83
+ expect(existsSync(path)).toBe(true)
84
+ // Saving the same value again is a no-op; the value is still readable.
85
+ saveBootCardMsgId(path, 'dm:', 42)
86
+ expect(loadBootCardMsgId(path, 'dm:')).toBe(42)
87
+ })
88
+ })
@@ -220,6 +220,33 @@ describe('redeliverBufferedInbound — wedge-clear self-heal (fleet-update incid
220
220
  expect(calls).toBe(0)
221
221
  })
222
222
 
223
+ // onDelivered: the deliver-until-acked enrol hook (clerk lost-message
224
+ // incident 2026-06-03). A socket-write "success" is not proof claude
225
+ // consumed it; the caller uses onDelivered to enrol the redelivered inbound
226
+ // in the deliver-until-acked queue so the sweep re-delivers until `enqueue`.
227
+ it('calls onDelivered for each CONFIRMED-delivered group (per merged identity)', () => {
228
+ const buf = createPendingInboundBuffer({ log: () => {} })
229
+ buf.push('klanker', inbound('user', 1))
230
+ buf.push('klanker', inbound('cron', 2)) // source-tagged → its own group
231
+ const delivered: number[] = []
232
+ const r = redeliverBufferedInbound(buf, 'klanker', () => true, undefined, (merged) => {
233
+ delivered.push(merged.messageId as number)
234
+ })
235
+ expect(r.redelivered).toBe(2)
236
+ expect(delivered).toEqual([1, 2]) // fired once per group, carrying the merged identity
237
+ })
238
+
239
+ it('does NOT call onDelivered for a group that failed to send (re-buffered, not enrolled)', () => {
240
+ const buf = createPendingInboundBuffer({ log: () => {} })
241
+ buf.push('klanker', inbound('user', 1))
242
+ const delivered: number[] = []
243
+ const r = redeliverBufferedInbound(buf, 'klanker', () => false, undefined, (m) =>
244
+ delivered.push(m.messageId as number),
245
+ )
246
+ expect(r.rebuffered).toBe(1)
247
+ expect(delivered).toEqual([]) // never enrolled — buffer/spool still own it
248
+ })
249
+
223
250
  it('only touches the named agent', () => {
224
251
  const buf = createPendingInboundBuffer({ log: () => {} })
225
252
  buf.push('klanker', inbound('user', 1))
@@ -179,4 +179,23 @@ describe('selectResumeBuilder', () => {
179
179
  expect(selectResumeBuilder(endedVia)).toBe(expected)
180
180
  })
181
181
  }
182
+
183
+ // 3h staleness failsafe (operator spec, 2026-06-03).
184
+ const MAX = 10_800_000 // 3h
185
+ it('downgrades a fresh resume to report when older than maxAgeMs (no auto-resume of stale work)', () => {
186
+ expect(selectResumeBuilder('restart', { ageMs: MAX + 1, maxAgeMs: MAX })).toBe('report')
187
+ expect(selectResumeBuilder(null, { ageMs: MAX + 60_000, maxAgeMs: MAX })).toBe('report')
188
+ })
189
+ it('keeps resume when within maxAgeMs', () => {
190
+ expect(selectResumeBuilder('restart', { ageMs: MAX - 1, maxAgeMs: MAX })).toBe('resume')
191
+ expect(selectResumeBuilder('sigterm', { ageMs: 1000, maxAgeMs: MAX })).toBe('resume')
192
+ })
193
+ it('age cap never UPGRADES — report/null stay as-is regardless of age', () => {
194
+ expect(selectResumeBuilder('timeout', { ageMs: MAX + 1, maxAgeMs: MAX })).toBe('report')
195
+ expect(selectResumeBuilder('stop', { ageMs: MAX + 1, maxAgeMs: MAX })).toBe(null)
196
+ })
197
+ it('legacy behaviour preserved when age/maxAge omitted (blanket resume)', () => {
198
+ expect(selectResumeBuilder('restart')).toBe('resume')
199
+ expect(selectResumeBuilder('restart', { ageMs: MAX + 1 })).toBe('resume') // needs BOTH to cap
200
+ })
182
201
  })