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.
package/dist/cli/switchroom.js
CHANGED
|
@@ -47247,8 +47247,8 @@ var {
|
|
|
47247
47247
|
} = import__.default;
|
|
47248
47248
|
|
|
47249
47249
|
// src/build-info.ts
|
|
47250
|
-
var VERSION = "0.12.
|
|
47251
|
-
var COMMIT_SHA = "
|
|
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
|
@@ -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.
|
|
47519
|
-
var COMMIT_SHA = "
|
|
47520
|
-
var COMMIT_DATE = "2026-05-
|
|
47521
|
-
var LATEST_PR =
|
|
47522
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
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
|
-
|
|
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
|
|
1282
|
-
//
|
|
1283
|
-
//
|
|
1284
|
-
// from the
|
|
1285
|
-
//
|
|
1286
|
-
//
|
|
1287
|
-
|
|
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
|
-
|
|
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
|
+
})
|