switchroom 0.12.28 → 0.12.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.
@@ -47247,8 +47247,8 @@ var {
47247
47247
  } = import__.default;
47248
47248
 
47249
47249
  // src/build-info.ts
47250
- var VERSION = "0.12.28";
47251
- var COMMIT_SHA = "61036e48";
47250
+ var VERSION = "0.12.29";
47251
+ var COMMIT_SHA = "f7c92422";
47252
47252
 
47253
47253
  // src/cli/agent.ts
47254
47254
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.12.28",
3
+ "version": "0.12.29",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -44203,6 +44203,53 @@ function dispatchOne(effect, ctx) {
44203
44203
  }
44204
44204
  }
44205
44205
 
44206
+ // gateway/prefix-warmup.ts
44207
+ var WARMUP_COOLDOWN_MS = 5 * 60000;
44208
+ var lastWarmupAtPerAgent = new Map;
44209
+ var WARMUP_TEXT = `__WARMUP_PING__
44210
+
44211
+ This is a system prefix-cache warmup (not from a user). ` + "Respond with exactly `NO_REPLY` and nothing else. " + "The gateway will suppress the response \u2014 no message will be sent to anyone.";
44212
+ function maybeFireWarmup(ctx) {
44213
+ if (process.env.SWITCHROOM_PREFIX_WARMUP !== "1")
44214
+ return false;
44215
+ const log = ctx.log ?? ((line) => process.stderr.write(line));
44216
+ const now = (ctx.now ?? Date.now)();
44217
+ const lastAt = lastWarmupAtPerAgent.get(ctx.selfAgent) ?? 0;
44218
+ if (now - lastAt < WARMUP_COOLDOWN_MS) {
44219
+ log(`telegram gateway: prefix-warmup skipped agent=${ctx.selfAgent} ` + `reason=cooldown last=${Math.round((now - lastAt) / 1000)}s ago
44220
+ `);
44221
+ return false;
44222
+ }
44223
+ const target = ctx.resolveBootTarget();
44224
+ if (!target) {
44225
+ log(`telegram gateway: prefix-warmup skipped agent=${ctx.selfAgent} ` + `reason=no-boot-chat-target
44226
+ `);
44227
+ return false;
44228
+ }
44229
+ const msg = {
44230
+ type: "inbound",
44231
+ chatId: target.chatId,
44232
+ ...target.threadId !== undefined ? { threadId: target.threadId } : {},
44233
+ messageId: 0,
44234
+ user: "switchroom-warmup",
44235
+ userId: 0,
44236
+ ts: Math.floor(now / 1000),
44237
+ text: WARMUP_TEXT,
44238
+ meta: { source: "warmup" }
44239
+ };
44240
+ try {
44241
+ ctx.client.send(msg);
44242
+ lastWarmupAtPerAgent.set(ctx.selfAgent, now);
44243
+ log(`telegram gateway: prefix-warmup fired agent=${ctx.selfAgent} ` + `chat=${target.chatId} thread=${target.threadId ?? "-"}
44244
+ `);
44245
+ return true;
44246
+ } catch (err) {
44247
+ log(`telegram gateway: prefix-warmup send threw agent=${ctx.selfAgent}: ` + `${err.message}
44248
+ `);
44249
+ return false;
44250
+ }
44251
+ }
44252
+
44206
44253
  // gateway/vault-grant-inbound-builders.ts
44207
44254
  function buildVaultGrantApprovedInbound(opts) {
44208
44255
  const ts = opts.nowMs ?? Date.now();
@@ -47515,11 +47562,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
47515
47562
  }
47516
47563
 
47517
47564
  // ../src/build-info.ts
47518
- var VERSION = "0.12.28";
47519
- var COMMIT_SHA = "61036e48";
47520
- var COMMIT_DATE = "2026-05-20T14:43:34Z";
47521
- var LATEST_PR = 1592;
47522
- var COMMITS_AHEAD_OF_TAG = 4;
47565
+ var VERSION = "0.12.29";
47566
+ var COMMIT_SHA = "f7c92422";
47567
+ var COMMIT_DATE = "2026-05-20T15:44:41Z";
47568
+ var LATEST_PR = 1595;
47569
+ var COMMITS_AHEAD_OF_TAG = 0;
47523
47570
 
47524
47571
  // gateway/boot-version.ts
47525
47572
  function formatRelativeAgo(iso) {
@@ -48468,8 +48515,9 @@ function statusKey(chatId, threadId) {
48468
48515
  function streamKey3(chatId, threadId) {
48469
48516
  return chatKey(chatId, threadId);
48470
48517
  }
48471
- function purgeReactionTracking(key) {
48472
- shadowEmit({ kind: "turnEnd", key, at: Date.now(), outboundEmitted: true });
48518
+ function purgeReactionTracking(key, endingTurn) {
48519
+ const outboundEmitted = endingTurn != null ? endingTurn.replyCalled === true : currentTurn?.replyCalled === true;
48520
+ shadowEmit({ kind: "turnEnd", key, at: Date.now(), outboundEmitted });
48473
48521
  const msgInfo = activeReactionMsgIds.get(key);
48474
48522
  activeStatusReactions.delete(key);
48475
48523
  activeReactionMsgIds.delete(key);
@@ -48502,7 +48550,7 @@ function endCurrentTurnAtomic(turn) {
48502
48550
  if (currentTurn !== turn)
48503
48551
  return;
48504
48552
  currentTurn = null;
48505
- purgeReactionTracking(statusKey(turn.sessionChatId, turn.sessionThreadId));
48553
+ purgeReactionTracking(statusKey(turn.sessionChatId, turn.sessionThreadId), turn);
48506
48554
  }
48507
48555
  function maybeProactiveCompact() {
48508
48556
  if (compactDispatching)
@@ -49427,6 +49475,20 @@ var ipcServer = createIpcServer({
49427
49475
  }
49428
49476
  }
49429
49477
  }
49478
+ if (client3.agentName != null) {
49479
+ maybeFireWarmup({
49480
+ selfAgent: client3.agentName,
49481
+ client: client3,
49482
+ resolveBootTarget: () => {
49483
+ const marker = readRestartMarker();
49484
+ const ageMs = marker ? Date.now() - marker.ts : undefined;
49485
+ const target = resolveBootChatId(marker, ageMs);
49486
+ if (!target)
49487
+ return null;
49488
+ return { chatId: target.chatId, threadId: target.threadId };
49489
+ }
49490
+ });
49491
+ }
49430
49492
  const dedupeDecision = shouldSkipDuplicateBootCard({ activeBootCard, bootCardPending }, "bridge-reconnect");
49431
49493
  if (dedupeDecision.skip) {
49432
49494
  process.stderr.write(`telegram gateway: bridge-reconnect: skipping boot card (${dedupeDecision.reason})
@@ -263,6 +263,7 @@ import { chatKey, chatKeyWithSuffix } from './chat-key.js'
263
263
  import { shadowEmit } from './inbound-delivery-machine-shadow.js'
264
264
  import type { ChatKey as _ChatKey } from './inbound-delivery-machine.js'
265
265
  import { dispatchEffects, isDispatchEnabled } from './inbound-delivery-machine-dispatch.js'
266
+ import { maybeFireWarmup } from './prefix-warmup.js'
266
267
  import {
267
268
  buildVaultGrantApprovedInbound,
268
269
  buildVaultGrantDeniedInbound,
@@ -1277,14 +1278,24 @@ function streamKey(chatId: string, threadId?: number | null): string {
1277
1278
  return chatKey(chatId, threadId)
1278
1279
  }
1279
1280
 
1280
- function purgeReactionTracking(key: string): void {
1281
- // Phase 2b shadow: turn end. The key was registered via setTurnStarted
1282
- // when the inbound arrived; purge is the canonical turn-end signal.
1283
- // outboundEmitted is approximated `true` here — refined in PR 3 to read
1284
- // from the per-turn `replyCalled` flag on `currentTurn`. Conservative
1285
- // shadow approximation is safe (only affects machine's lastOutboundAt
1286
- // tracking; can't drive incorrect behavior in shadow mode).
1287
- shadowEmit({ kind: 'turnEnd', key: key as _ChatKey, at: Date.now(), outboundEmitted: true })
1281
+ function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
1282
+ // Phase 2b: turn end. The key was registered via setTurnStarted when
1283
+ // the inbound arrived; purge is the canonical turn-end signal.
1284
+ //
1285
+ // outboundEmitted: read from the explicit `endingTurn` parameter when
1286
+ // provided (canonical path via endCurrentTurnAtomic module-scope
1287
+ // currentTurn is already null by the time we get here), falling back
1288
+ // to `currentTurn?.replyCalled` for the legacy callsites that haven't
1289
+ // been threaded yet (sibling-key purges, restart-init cleanup).
1290
+ // Without this explicit-turn handoff the shadow trace would report
1291
+ // outboundEmitted=false on every replied turn (the dominant happy
1292
+ // path), producing strictly worse data than the blind `true` it
1293
+ // replaced. Invariant #5's `lastOutboundAt` correctness depends on
1294
+ // this signal being accurate.
1295
+ const outboundEmitted = endingTurn != null
1296
+ ? endingTurn.replyCalled === true
1297
+ : currentTurn?.replyCalled === true
1298
+ shadowEmit({ kind: 'turnEnd', key: key as _ChatKey, at: Date.now(), outboundEmitted })
1288
1299
  const msgInfo = activeReactionMsgIds.get(key)
1289
1300
  activeStatusReactions.delete(key)
1290
1301
  activeReactionMsgIds.delete(key)
@@ -1370,7 +1381,12 @@ function purgeReactionTracking(key: string): void {
1370
1381
  function endCurrentTurnAtomic(turn: CurrentTurn): void {
1371
1382
  if (currentTurn !== turn) return
1372
1383
  currentTurn = null
1373
- purgeReactionTracking(statusKey(turn.sessionChatId, turn.sessionThreadId))
1384
+ // Pass `turn` so purgeReactionTracking sees the authoritative
1385
+ // replyCalled flag even though we just nulled module-scope
1386
+ // currentTurn. Without this, the shadow trace's outboundEmitted
1387
+ // would be false on every replied turn (the dominant happy path),
1388
+ // producing strictly worse data than the blind `true` it replaced.
1389
+ purgeReactionTracking(statusKey(turn.sessionChatId, turn.sessionThreadId), turn)
1374
1390
  }
1375
1391
 
1376
1392
  /**
@@ -3267,6 +3283,28 @@ const ipcServer: IpcServer = createIpcServer({
3267
3283
  }
3268
3284
  }
3269
3285
 
3286
+ // Prefix-cache warmup (cold-start TTFO RFC, opt-in via
3287
+ // SWITCHROOM_PREFIX_WARMUP=1). Fires a synthetic inbound to claude
3288
+ // BEFORE the user's next real message so Anthropic's prefix cache
3289
+ // is warm on the user-perceived first turn. Gated, debounced
3290
+ // (5-min cooldown per agent), and skipped if no boot chat resolves.
3291
+ // Claude responds NO_REPLY per inline instruction; existing
3292
+ // silent-marker suppression at gateway.ts:5906 swallows the
3293
+ // outbound. See docs/rfcs/cold-start-ttfo.md Option A.
3294
+ if (client.agentName != null) {
3295
+ maybeFireWarmup({
3296
+ selfAgent: client.agentName,
3297
+ client,
3298
+ resolveBootTarget: () => {
3299
+ const marker = readRestartMarker()
3300
+ const ageMs = marker ? Date.now() - marker.ts : undefined
3301
+ const target = resolveBootChatId(marker, ageMs)
3302
+ if (!target) return null
3303
+ return { chatId: target.chatId, threadId: target.threadId }
3304
+ },
3305
+ })
3306
+ }
3307
+
3270
3308
  // If the agent reconnected after a /restart (or any restart), post a boot
3271
3309
  // card. The restart-marker carries the ack chat; if absent we fall back to
3272
3310
  // resolveBootChatId so crash-recovery reconnects also get a card.
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Prefix-cache warmup turn — opt-in cold-start TTFO optimization.
3
+ *
4
+ * Per cold-start TTFO RFC (docs/rfcs/cold-start-ttfo.md, PR #1589),
5
+ * Option A. On every bridge-up after a restart, synthesize a synthetic
6
+ * inbound (`__WARMUP_PING__`, meta.source="warmup") and deliver it to
7
+ * the just-registered bridge. Claude processes the message — paying
8
+ * the full cold-cache cost on the synthetic turn — and responds
9
+ * `NO_REPLY` per the in-prompt instruction. The existing NO_REPLY
10
+ * suppression at `gateway.ts:5949` swallows the outbound.
11
+ *
12
+ * By the time the user's REAL next message arrives, Anthropic's prefix
13
+ * cache is warm and the user-perceived TTFO drops 4-8s on average.
14
+ *
15
+ * Phase 1 (this file): minimum-viable warmup. AGENT.md is NOT modified
16
+ * — the warmup TEXT carries the NO_REPLY instruction inline. Agent
17
+ * compliance is best-effort; non-compliant agents will emit a real
18
+ * reply to the primary chat (acceptable UX cost gated behind opt-in
19
+ * env var). Cooldown prevents the gymbro-style bridge-churn case from
20
+ * burning OAuth quota on every flap.
21
+ *
22
+ * Kill switch: `SWITCHROOM_PREFIX_WARMUP=1` opt-in (default OFF).
23
+ *
24
+ * Future PR (Phase 2): suppress 👀 reaction + progress card for
25
+ * meta.source="warmup" inbound; tag for Hindsight exclusion.
26
+ */
27
+
28
+ import type { IpcClient } from './ipc-server.js'
29
+ import type { InboundMessage } from './ipc-protocol.js'
30
+
31
+ // Per cold-start RFC open-question #4: cooldown anchored on bridge-up
32
+ // time; conservative 5-minute window catches gymbro-style 6-reconnects-
33
+ // per-UAT-cycle without dropping legitimate every-restart warmups.
34
+ const WARMUP_COOLDOWN_MS = 5 * 60_000
35
+
36
+ const lastWarmupAtPerAgent = new Map<string, number>()
37
+
38
+ export const WARMUP_TEXT =
39
+ '__WARMUP_PING__\n\nThis is a system prefix-cache warmup (not from a user). ' +
40
+ 'Respond with exactly `NO_REPLY` and nothing else. ' +
41
+ 'The gateway will suppress the response — no message will be sent to anyone.'
42
+
43
+ export interface WarmupCtx {
44
+ readonly selfAgent: string
45
+ readonly client: IpcClient
46
+ readonly resolveBootTarget: () =>
47
+ | { chatId: string; threadId?: number | undefined }
48
+ | null
49
+ readonly log?: (line: string) => void
50
+ readonly now?: () => number
51
+ }
52
+
53
+ /**
54
+ * Fire a prefix-cache warmup if conditions are met. Idempotent within
55
+ * the cooldown window. Returns true when a warmup was actually sent.
56
+ *
57
+ * Conditions:
58
+ * 1. `SWITCHROOM_PREFIX_WARMUP=1` env var set (opt-in).
59
+ * 2. Cooldown elapsed for this agent (default 5 min).
60
+ * 3. A boot chat target resolves (no point warming without a chat).
61
+ *
62
+ * The warmup is delivered to `client.send()` directly — it bypasses
63
+ * the gateway's `handleInbound`, which gates on a real Telegram
64
+ * Context object. The bridge forwards to claude exactly as it would a
65
+ * Telegram message.
66
+ */
67
+ export function maybeFireWarmup(ctx: WarmupCtx): boolean {
68
+ if (process.env.SWITCHROOM_PREFIX_WARMUP !== '1') return false
69
+
70
+ const log = ctx.log ?? ((line: string) => process.stderr.write(line))
71
+ const now = (ctx.now ?? Date.now)()
72
+
73
+ const lastAt = lastWarmupAtPerAgent.get(ctx.selfAgent) ?? 0
74
+ if (now - lastAt < WARMUP_COOLDOWN_MS) {
75
+ log(
76
+ `telegram gateway: prefix-warmup skipped agent=${ctx.selfAgent} ` +
77
+ `reason=cooldown last=${Math.round((now - lastAt) / 1000)}s ago\n`,
78
+ )
79
+ return false
80
+ }
81
+
82
+ const target = ctx.resolveBootTarget()
83
+ if (!target) {
84
+ log(
85
+ `telegram gateway: prefix-warmup skipped agent=${ctx.selfAgent} ` +
86
+ `reason=no-boot-chat-target\n`,
87
+ )
88
+ return false
89
+ }
90
+
91
+ const msg: InboundMessage = {
92
+ type: 'inbound',
93
+ chatId: target.chatId,
94
+ ...(target.threadId !== undefined ? { threadId: target.threadId } : {}),
95
+ messageId: 0, // synthetic — never matches a real Telegram message
96
+ user: 'switchroom-warmup',
97
+ userId: 0,
98
+ ts: Math.floor(now / 1000),
99
+ text: WARMUP_TEXT,
100
+ meta: { source: 'warmup' },
101
+ }
102
+
103
+ try {
104
+ ctx.client.send(msg)
105
+ lastWarmupAtPerAgent.set(ctx.selfAgent, now)
106
+ log(
107
+ `telegram gateway: prefix-warmup fired agent=${ctx.selfAgent} ` +
108
+ `chat=${target.chatId} thread=${target.threadId ?? '-'}\n`,
109
+ )
110
+ return true
111
+ } catch (err) {
112
+ log(
113
+ `telegram gateway: prefix-warmup send threw agent=${ctx.selfAgent}: ` +
114
+ `${(err as Error).message}\n`,
115
+ )
116
+ return false
117
+ }
118
+ }
119
+
120
+ /** Test hook: reset the cooldown state. */
121
+ export function __resetForTests(): void {
122
+ lastWarmupAtPerAgent.clear()
123
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Tests for the prefix-cache warmup module.
3
+ *
4
+ * Per cold-start TTFO RFC Option A: fire a synthetic inbound on
5
+ * bridge-up so Anthropic's prefix cache is warm by the user's next
6
+ * real message. These tests pin: env gate, cooldown, missing-target
7
+ * skip, message shape (meta.source="warmup", correct text), and
8
+ * cooldown debounce across multiple bridge reconnects.
9
+ */
10
+
11
+ import { describe, expect, it, beforeEach, vi } from 'vitest'
12
+ import {
13
+ __resetForTests,
14
+ maybeFireWarmup,
15
+ WARMUP_TEXT,
16
+ } from '../gateway/prefix-warmup'
17
+ import type { WarmupCtx } from '../gateway/prefix-warmup'
18
+
19
+ beforeEach(() => {
20
+ __resetForTests()
21
+ delete process.env.SWITCHROOM_PREFIX_WARMUP
22
+ })
23
+
24
+ function makeCtx(overrides?: Partial<WarmupCtx>): {
25
+ ctx: WarmupCtx
26
+ send: ReturnType<typeof vi.fn>
27
+ logs: string[]
28
+ } {
29
+ const send = vi.fn()
30
+ const logs: string[] = []
31
+ const ctx: WarmupCtx = {
32
+ selfAgent: 'test-agent',
33
+ client: { send, agentName: 'test-agent', id: 'c1', isAlive: () => true, lastHeartbeat: 0, close: () => {} } as never,
34
+ resolveBootTarget: () => ({ chatId: '12345', threadId: undefined }),
35
+ log: (l: string) => logs.push(l),
36
+ now: () => 1_000_000,
37
+ ...overrides,
38
+ }
39
+ return { ctx, send, logs }
40
+ }
41
+
42
+ describe('maybeFireWarmup — env gate', () => {
43
+ it('skipped when SWITCHROOM_PREFIX_WARMUP is unset', () => {
44
+ const { ctx, send } = makeCtx()
45
+ expect(maybeFireWarmup(ctx)).toBe(false)
46
+ expect(send).not.toHaveBeenCalled()
47
+ })
48
+
49
+ it('skipped when SWITCHROOM_PREFIX_WARMUP is 0', () => {
50
+ process.env.SWITCHROOM_PREFIX_WARMUP = '0'
51
+ const { ctx, send } = makeCtx()
52
+ expect(maybeFireWarmup(ctx)).toBe(false)
53
+ expect(send).not.toHaveBeenCalled()
54
+ })
55
+
56
+ it('fires when SWITCHROOM_PREFIX_WARMUP=1', () => {
57
+ process.env.SWITCHROOM_PREFIX_WARMUP = '1'
58
+ const { ctx, send } = makeCtx()
59
+ expect(maybeFireWarmup(ctx)).toBe(true)
60
+ expect(send).toHaveBeenCalledTimes(1)
61
+ })
62
+ })
63
+
64
+ describe('maybeFireWarmup — message shape', () => {
65
+ beforeEach(() => {
66
+ process.env.SWITCHROOM_PREFIX_WARMUP = '1'
67
+ })
68
+
69
+ it('tags meta.source="warmup"', () => {
70
+ const { ctx, send } = makeCtx()
71
+ maybeFireWarmup(ctx)
72
+ const msg = send.mock.calls[0][0]
73
+ expect(msg.meta.source).toBe('warmup')
74
+ })
75
+
76
+ it('uses synthetic messageId=0 and userId=0', () => {
77
+ const { ctx, send } = makeCtx()
78
+ maybeFireWarmup(ctx)
79
+ const msg = send.mock.calls[0][0]
80
+ expect(msg.messageId).toBe(0)
81
+ expect(msg.userId).toBe(0)
82
+ expect(msg.user).toBe('switchroom-warmup')
83
+ })
84
+
85
+ it('text carries the NO_REPLY instruction', () => {
86
+ const { ctx, send } = makeCtx()
87
+ maybeFireWarmup(ctx)
88
+ const msg = send.mock.calls[0][0]
89
+ expect(msg.text).toBe(WARMUP_TEXT)
90
+ expect(msg.text).toContain('__WARMUP_PING__')
91
+ expect(msg.text).toContain('NO_REPLY')
92
+ })
93
+
94
+ it('routes to the resolved boot chat', () => {
95
+ process.env.SWITCHROOM_PREFIX_WARMUP = '1'
96
+ const { ctx, send } = makeCtx({
97
+ resolveBootTarget: () => ({ chatId: 'CHAT-9', threadId: 42 }),
98
+ })
99
+ maybeFireWarmup(ctx)
100
+ const msg = send.mock.calls[0][0]
101
+ expect(msg.chatId).toBe('CHAT-9')
102
+ expect(msg.threadId).toBe(42)
103
+ })
104
+
105
+ it('omits threadId when boot target has none', () => {
106
+ const { ctx, send } = makeCtx()
107
+ maybeFireWarmup(ctx)
108
+ const msg = send.mock.calls[0][0]
109
+ expect(msg.threadId).toBeUndefined()
110
+ })
111
+ })
112
+
113
+ describe('maybeFireWarmup — cooldown', () => {
114
+ beforeEach(() => {
115
+ process.env.SWITCHROOM_PREFIX_WARMUP = '1'
116
+ })
117
+
118
+ it('second call within cooldown is skipped', () => {
119
+ const { ctx, send, logs } = makeCtx()
120
+ expect(maybeFireWarmup(ctx)).toBe(true)
121
+ // Same now() — well within cooldown.
122
+ expect(maybeFireWarmup(ctx)).toBe(false)
123
+ expect(send).toHaveBeenCalledTimes(1)
124
+ expect(logs.some((l) => l.includes('reason=cooldown'))).toBe(true)
125
+ })
126
+
127
+ it('call after cooldown elapses fires again', () => {
128
+ let t = 1_000_000
129
+ const ctx = makeCtx({ now: () => t }).ctx
130
+ expect(maybeFireWarmup(ctx)).toBe(true)
131
+ t += 5 * 60_000 + 1 // just past cooldown
132
+ expect(maybeFireWarmup(ctx)).toBe(true)
133
+ })
134
+
135
+ it('cooldown is per-agent', () => {
136
+ const a = makeCtx({ selfAgent: 'agent-a' })
137
+ const b = makeCtx({ selfAgent: 'agent-b' })
138
+ expect(maybeFireWarmup(a.ctx)).toBe(true)
139
+ expect(maybeFireWarmup(b.ctx)).toBe(true)
140
+ expect(maybeFireWarmup(a.ctx)).toBe(false) // a in cooldown
141
+ expect(maybeFireWarmup(b.ctx)).toBe(false) // b in cooldown
142
+ })
143
+ })
144
+
145
+ describe('maybeFireWarmup — missing boot target', () => {
146
+ beforeEach(() => {
147
+ process.env.SWITCHROOM_PREFIX_WARMUP = '1'
148
+ })
149
+
150
+ it('skipped when no boot chat resolves', () => {
151
+ const { ctx, send, logs } = makeCtx({ resolveBootTarget: () => null })
152
+ expect(maybeFireWarmup(ctx)).toBe(false)
153
+ expect(send).not.toHaveBeenCalled()
154
+ expect(logs.some((l) => l.includes('reason=no-boot-chat-target'))).toBe(true)
155
+ })
156
+ })
157
+
158
+ describe('maybeFireWarmup — send error handling', () => {
159
+ beforeEach(() => {
160
+ process.env.SWITCHROOM_PREFIX_WARMUP = '1'
161
+ })
162
+
163
+ it('caught send throw returns false and does not mark cooldown', () => {
164
+ const send = vi.fn(() => {
165
+ throw new Error('boom')
166
+ })
167
+ const ctx = makeCtx({
168
+ client: { send } as never,
169
+ }).ctx
170
+ expect(maybeFireWarmup(ctx)).toBe(false)
171
+ // Cooldown NOT recorded — next call should attempt again.
172
+ expect(maybeFireWarmup(ctx)).toBe(false) // still throws
173
+ expect(send).toHaveBeenCalledTimes(2)
174
+ })
175
+ })