switchroom 0.14.9 → 0.14.11

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,50 +1,45 @@
1
1
  /**
2
- * silence-poke.ts — framework safety net for "model is silent to the user."
2
+ * silence-poke.ts — framework safety net for a genuinely wedged turn.
3
3
  *
4
- * Background (issue #1122): we're moving away from a pinned progress card
5
- * to a conversational shape where the chat itself is the artifact. The
6
- * progress card was implicitly doing one useful jobcovering for a
7
- * model that doesn't know how to say "still working." This module is the
8
- * explicit replacement: when the model has been silent past a threshold,
9
- * we nudge it (or, as a last resort, send a framework message ourselves).
4
+ * Scope (post-#1981-era retirement): this module is now ONLY the
5
+ * last-resort unwedge. The conversational pacing the user actually sees
6
+ * the acknowledgement beat, the "still going" progress updates is
7
+ * owned by the live-updating reply/draft (the chat IS the artifact) and
8
+ * by the model's own `reply`/`stream_reply` calls, not by framework
9
+ * nudges. The earlier model-targeted nudge ladder (ack at 10s, soft at
10
+ * 75s, firm at 180s) and the 60s user-visible awareness ping were
11
+ * retired: their success rate was 0-7% by the design's own KPI, and they
12
+ * duplicated a job the draft thinking-lane now does natively. See
13
+ * `reference/conversational-pacing.md` § Safety net.
10
14
  *
11
- * Two clocks (this module owns ONE of them; the other is the legacy
12
- * idle stall in status-reactions.ts and is unrelated):
15
+ * What remains: ONE silence clock and ONE terminal action.
13
16
  *
14
17
  * silence clock = now - lastOutboundAt (or turnStartedAt if no outbound yet)
15
18
  *
16
19
  * Outbound = a fresh `reply` or `stream_reply` first-emit. Reactions,
17
- * edits, and tool churn DO NOT reset the silence clock — that's the
18
- * whole point. The model could be ripping through 20 tool calls and
19
- * still be "silent" to the user.
20
+ * edits, and tool churn DO NOT reset the silence clock — the model could
21
+ * be ripping through 20 tool calls and still be "silent" to the user.
20
22
  *
21
- * Escalation ladder per turn:
23
+ * Terminal action, once per turn:
22
24
  *
23
25
  * t=0 startTurn() — silence clock starts at turnStartedAt
24
- * t=75s soft poke armed appended to next tool result as a
25
- * <system-reminder> nudging the model to send an update
26
- * t=180s firm poke armed (stronger wording) if no outbound landed
27
- * t=300s framework fallback: gateway itself sends a user-visible
28
- * "still working… / still thinking…" message. Fires at most
29
- * once per turn. Pings the device (user needs to know).
26
+ * t=300s framework fallback: the gateway sends a user-visible
27
+ * "still working… / still thinking…" message AND unwedges the
28
+ * turn (clears activeTurnStartedAt, nulls currentTurn, drains
29
+ * buffered inbound, purges stale turn state). This is the
30
+ * load-bearing unwedge primitive NOT a nudge that keeps a
31
+ * dead turn from pinning the conversation forever.
30
32
  *
31
- * Subagent-dispatch override: when the model dispatches a sub-agent
32
- * (`Task(...)`, `@worker` etc), the soft threshold extends to 300s for
33
- * that turn the model is legitimately waiting on a child, no point
34
- * poking it to narrate the wait. Firm/fallback thresholds unchanged.
35
- *
36
- * Wired into the gateway at the central tool-result chokepoint
37
- * (`gateway.ts:onToolCall`) so the poke text piggybacks the next tool
38
- * result back to claude. MCP doesn't allow mid-generation injection;
39
- * tool results are the only synchronous moment we own the wire.
33
+ * The fallback message is enriched with in-flight tool context so it
34
+ * reads honestly ("running Grep \"foo\" for 4m") instead of a generic
35
+ * "still working…" when the agent is clearly busy #1292. Tool churn
36
+ * enriches the TEXT only, never the timing.
40
37
  *
41
38
  * Kill switch: SWITCHROOM_DISABLE_SILENCE_POKE=1 disables the whole
42
- * subsystem (no timers, no injection, no fallback). The conversational
43
- * pacing prompt still applies; only the framework safety net is off.
39
+ * subsystem (no timer, no fallback, no unwedge). The conversational
40
+ * pacing prompt + draft still apply; only the framework safety net is off.
44
41
  */
45
42
 
46
- export type PokeLevel = 'ack' | 'soft' | 'firm'
47
-
48
43
  /** #1292: snapshot of an in-flight tool call, surfaced in the 300s
49
44
  * framework-fallback message so the user sees the actual observable
50
45
  * ("running Grep \"foo\" for 4m") instead of the dishonest generic
@@ -66,29 +61,10 @@ export interface SilencePokeState {
66
61
  turnStartedAt: number
67
62
  /** Wall-clock ms of last outbound message, or null. */
68
63
  lastOutboundAt: number | null
69
- /** 0 = none, 1 = soft fired, 2 = firm fired, 3 = fallback fired. */
70
- pokesFired: 0 | 1 | 2 | 3
71
- /** Armed pending drain on the next tool result, or null. */
72
- pokeArmed: { level: PokeLevel } | null
73
- /** When true, soft threshold extends to subagentSoft (default 300s). */
74
- subagentDispatchActive: boolean
75
64
  /** Wall-clock ms of last `thinking` session event, or null. */
76
65
  lastThinkingAt: number | null
77
66
  /** True once the 300s framework fallback has fired this turn. */
78
67
  fallbackFired: boolean
79
- /** True once the early ack-budget poke has fired this turn. One-shot:
80
- * the ack nudge is specifically about the *first* outbound, so it
81
- * never re-arms even after the model later goes quiet again. */
82
- ackPokeFired: boolean
83
- /** True once the early awareness-ping has fired this turn. One-shot:
84
- * the user only needs one "we know it's slow" cue before the heavier
85
- * 300s fallback escalates. Independent of the ack-poke (which targets
86
- * the model via piggyback) — awareness-ping targets the user directly
87
- * via Telegram, so it lands even during pure-thinking or held-inbound
88
- * silences when no tool_result is available to piggyback on. */
89
- awarenessPingFired: boolean
90
- /** Wall-clock ms of last poke fire — used for poke-success latency. */
91
- lastPokeFiredAt: number | null
92
68
  /** #1292: in-flight tool calls keyed by toolUseId. Populated by
93
69
  * `noteToolStart` on every parent-agent `tool_use` event the gateway
94
70
  * sees and drained by `noteToolEnd` on the matching `tool_result`.
@@ -102,37 +78,13 @@ export interface SilencePokeState {
102
78
  }
103
79
 
104
80
  export interface ThresholdsMs {
105
- /** Ack budget: if NO outbound at all has landed this many ms after
106
- * turn start, arm an 'ack' poke. This is the framework enforcing the
107
- * human-baseline "acknowledge within a beat" — far tighter than the
108
- * 75s `soft` threshold, which measures silence-since-last-outbound
109
- * and is the wrong instrument for "you never said hello." */
110
- ack: number
111
- /** Awareness ping: if NO outbound has landed this many ms after turn
112
- * start, send a framework-owned user-visible "still working…" status
113
- * message directly via Telegram. Sits between the model-targeted ack
114
- * poke (10s) and the 300s framework_fallback. Lands even during pure
115
- * extended-thinking or held-inbound silences when no tool_result is
116
- * available to piggyback the model-targeted pokes onto. One-shot per
117
- * turn — the 300s fallback handles further escalation if still silent. */
118
- awarenessPing: number
119
- soft: number
120
- firm: number
81
+ /** Silence (since last outbound, or turn start) after which the
82
+ * framework sends the user-visible fallback AND unwedges the turn. */
121
83
  fallback: number
122
- /** Soft threshold when subagentDispatchActive=true. */
123
- subagentSoft: number
124
- /** How long after a poke we still count an outbound as a "success." */
125
- pokeSuccessWindowMs: number
126
84
  }
127
85
 
128
86
  export const DEFAULT_THRESHOLDS: ThresholdsMs = {
129
- ack: 10_000,
130
- awarenessPing: 60_000,
131
- soft: 75_000,
132
- firm: 180_000,
133
87
  fallback: 300_000,
134
- subagentSoft: 300_000,
135
- pokeSuccessWindowMs: 15_000,
136
88
  }
137
89
 
138
90
  export const DEFAULT_POLL_INTERVAL_MS = 5_000
@@ -158,25 +110,13 @@ export interface FrameworkFallbackContext {
158
110
  }
159
111
 
160
112
  export type SilencePokeMetric =
161
- | { kind: 'silence_poke_fired'; key: string; level: PokeLevel; silence_ms: number; subagent_wait: boolean }
162
- | { kind: 'silence_poke_succeeded'; key: string; level: PokeLevel; latency_ms: number }
163
113
  | { kind: 'silence_fallback_sent'; key: string; fallback_kind: 'working' | 'thinking'; silence_ms: number }
164
- | { kind: 'awareness_ping_sent'; key: string; fallback_kind: 'working' | 'thinking'; silence_ms: number }
165
114
 
166
115
  export interface SilencePokeDeps {
167
116
  /** Called when the 300s fallback fires. Caller sends the user-visible
168
- * message + ensures it pings the device. Caller must NOT call back
169
- * into noteOutbound for this — it's a framework-sourced message,
170
- * not a model-sourced one, and we want pokes to continue (well, no,
171
- * fallbackFired ensures only one per turn anyway). */
117
+ * message + ensures it pings the device, then unwedges the turn. */
172
118
  onFrameworkFallback: (ctx: FrameworkFallbackContext) => Promise<void> | void
173
- /** Called when the awareness ping fires (default 60s). Caller sends
174
- * a user-visible "still working…" message — silent (no device ping),
175
- * framework-owned, one-shot per turn. Reuses the same context shape
176
- * as onFrameworkFallback so the gateway can reuse `formatFrameworkFallbackText`
177
- * and the in-flight-tool enrichment. */
178
- onAwarenessPing: (ctx: FrameworkFallbackContext) => Promise<void> | void
179
- /** Telemetry sink for poke events. */
119
+ /** Telemetry sink for the fallback event. */
180
120
  emitMetric: (event: SilencePokeMetric) => void
181
121
  /** Threshold overrides (tests). */
182
122
  thresholdsMs?: ThresholdsMs
@@ -205,69 +145,24 @@ export function startTurn(key: string, now: number): void {
205
145
  state.set(key, {
206
146
  turnStartedAt: now,
207
147
  lastOutboundAt: null,
208
- pokesFired: 0,
209
- pokeArmed: null,
210
- subagentDispatchActive: false,
211
148
  lastThinkingAt: null,
212
149
  fallbackFired: false,
213
- ackPokeFired: false,
214
- awarenessPingFired: false,
215
- lastPokeFiredAt: null,
216
150
  inFlightTools: new Map(),
217
151
  })
218
152
  }
219
153
 
220
154
  /**
221
155
  * Record a fresh user-visible outbound message (reply or stream_reply
222
- * first-emit). Resets the silence clock + the escalation counter. If a
223
- * poke fired recently, emit a `silence_poke_succeeded` metric.
156
+ * first-emit). Resets the silence clock so the 300s fallback is measured
157
+ * from the most recent thing the user actually saw.
224
158
  */
225
159
  export function noteOutbound(key: string, now: number): void {
226
160
  const s = state.get(key)
227
161
  if (s == null) return
228
- // Success measurement: if a poke fired within the success window and
229
- // an outbound just landed, count it as a successful poke.
230
- const thresholds = activeDeps?.thresholdsMs ?? DEFAULT_THRESHOLDS
231
- if (
232
- s.lastPokeFiredAt != null
233
- && (now - s.lastPokeFiredAt) <= thresholds.pokeSuccessWindowMs
234
- && activeDeps != null
235
- && s.pokesFired >= 1
236
- && s.pokesFired <= 2
237
- ) {
238
- activeDeps.emitMetric({
239
- kind: 'silence_poke_succeeded',
240
- key,
241
- level: s.pokesFired === 1 ? 'soft' : 'firm',
242
- latency_ms: now - s.lastPokeFiredAt,
243
- })
244
- }
245
162
  s.lastOutboundAt = now
246
- s.pokesFired = 0
247
- s.pokeArmed = null
248
- // Intentionally DO NOT clear `subagentDispatchActive` here. The
249
- // model's `reply` narrating the dispatch ("spinning up @reviewer")
250
- // is itself the outbound that resets the silence clock — clearing
251
- // the flag would defeat the extended-threshold guarantee for the
252
- // wait that follows. The flag persists until endTurn(). Fixes the
253
- // non-blocking note from PR2 review (#1125).
254
- s.lastPokeFiredAt = null
255
163
  s.fallbackFired = false
256
164
  }
257
165
 
258
- /**
259
- * Note that the model dispatched a sub-agent (Task tool, @worker, etc).
260
- * Extends the soft threshold for THIS turn. The flag persists until
261
- * endTurn() — subsequent outbound messages within the turn keep the
262
- * extended threshold, which is the correct shape for the dispatch
263
- * narrate → wait → child-result → summarise sequence.
264
- */
265
- export function noteSubagentDispatch(key: string): void {
266
- const s = state.get(key)
267
- if (s == null) return
268
- s.subagentDispatchActive = true
269
- }
270
-
271
166
  /**
272
167
  * Record a `thinking` session event. Used to pick "still thinking…" vs
273
168
  * "still working…" wording for the 300s framework fallback.
@@ -344,70 +239,16 @@ export function noteToolLabel(
344
239
  entry.label = label
345
240
  }
346
241
 
347
- /**
348
- * Drain any armed poke for ANY active turn and return the system-reminder
349
- * text to append. Returns null if nothing is armed.
350
- *
351
- * Called at the gateway's tool-result chokepoint; the appended reminder
352
- * piggybacks the result back to claude. Drains the flag immediately so
353
- * the next tool result doesn't double-inject.
354
- *
355
- * Iterates all keys because the tool result doesn't carry which turn it
356
- * belongs to. In practice the gateway has ≤1 active turn at a time, but
357
- * the code handles multi-turn correctly: each turn's poke text is
358
- * appended once (and never appears in another turn's tool result, since
359
- * we drain by mutating the matched state).
360
- */
361
- export function consumeArmedPoke(): string | null {
362
- for (const s of state.values()) {
363
- if (s.pokeArmed != null) {
364
- const level = s.pokeArmed.level
365
- s.pokeArmed = null
366
- return formatPokeText(level)
367
- }
368
- }
369
- return null
370
- }
371
-
372
242
  /** End a turn — drop state. Idempotent. */
373
243
  export function endTurn(key: string): void {
374
244
  state.delete(key)
375
245
  }
376
246
 
377
- /** Verbatim poke text. Wording is load-bearing — see issue #1122 design. */
378
- export function formatPokeText(level: PokeLevel): string {
379
- if (level === 'ack') {
380
- return (
381
- "[silence-poke] You haven't sent the user anything yet this turn — "
382
- + 'they are looking at a silent chat. Send a short, human one-line '
383
- + 'acknowledgement now via `reply` (e.g. "on it — checking"), in your '
384
- + "persona's voice, before you do any more work. A good colleague "
385
- + "answers in a beat; don't leave the message hanging while you think. "
386
- + 'If the full answer is genuinely seconds away, send that instead.'
387
- )
388
- }
389
- if (level === 'soft') {
390
- return (
391
- "[silence-poke] You've been silent to the user for 75s. If you're "
392
- + "still working on this, send one short conversational reply — e.g. "
393
- + "\"still going, working through X\" — so they know you're alive. "
394
- + "Keep it brief; don't restate the task. If you're about to finish "
395
- + 'within the next few seconds, skip the update.'
396
- )
397
- }
398
- return (
399
- "[silence-poke] 3 minutes silent. Please send an update now — what "
400
- + "you're working on, or whether you're stuck. If something is taking "
401
- + 'unusually long (slow tool, network, waiting on a sub-agent), say so '
402
- + 'explicitly.'
403
- )
404
- }
405
-
406
247
  /**
407
248
  * Verbatim framework-fallback text — the user-visible "still working / still
408
249
  * thinking" message the gateway sends at the 300s threshold when the model
409
250
  * hasn't broken its own silence. Wording is load-bearing (see
410
- * `reference/conversational-pacing.md` § Silence-poke ladder). Two principles:
251
+ * `reference/conversational-pacing.md` § Safety net). Two principles:
411
252
  *
412
253
  * 1. The parenthetical `(no update from agent in N min)` is honest —
413
254
  * distinguishes from "the agent said something" so users learn to trust
@@ -485,8 +326,10 @@ function truncateLabel(label: string): string {
485
326
  }
486
327
 
487
328
  /**
488
- * Internal tick — iterates active states, arms pokes or fires fallback.
489
- * Exported as __tickForTests so suite can step the clock deterministically.
329
+ * Internal tick — iterates active states and fires the 300s framework
330
+ * fallback (which the gateway turns into a user-visible message + an
331
+ * unwedge). Exported as __tickForTests so the suite can step the clock
332
+ * deterministically.
490
333
  */
491
334
  function tick(now: number): void {
492
335
  if (activeDeps == null) return
@@ -495,125 +338,9 @@ function tick(now: number): void {
495
338
  const zeroAt = s.lastOutboundAt ?? s.turnStartedAt
496
339
  const silence = now - zeroAt
497
340
  if (silence < 0) continue
498
- const softThreshold = s.subagentDispatchActive
499
- ? thresholds.subagentSoft
500
- : thresholds.soft
501
-
502
- // Ack budget — the framework enforcing the human-baseline "answer
503
- // in a beat." Fires once, only when NOTHING has been sent this turn
504
- // (`lastOutboundAt == null`), well before the 75s `soft` threshold.
505
- // `soft` measures silence-since-last-outbound and is the wrong
506
- // instrument for "you never acknowledged me." Independent of the
507
- // soft/firm/fallback ladder: if the model never acks, it still
508
- // escalates soft → firm → fallback on schedule after this.
509
- if (
510
- !s.ackPokeFired
511
- && s.lastOutboundAt == null
512
- && s.pokesFired === 0
513
- && silence >= thresholds.ack
514
- ) {
515
- s.pokeArmed = { level: 'ack' }
516
- s.ackPokeFired = true
517
- s.lastPokeFiredAt = now
518
- activeDeps.emitMetric({
519
- kind: 'silence_poke_fired',
520
- key,
521
- level: 'ack',
522
- silence_ms: silence,
523
- subagent_wait: s.subagentDispatchActive,
524
- })
525
- continue
526
- }
527
-
528
- // Awareness ping — framework-owned user-visible status BEFORE the
529
- // 300s heavy fallback. Lands at ~60s even when the model is in pure
530
- // extended-thinking or the inbound is held (#1892 follow-up), since
531
- // it's delivered directly via the gateway → Telegram, not
532
- // piggybacked on tool_result. One-shot per turn; suppressed if any
533
- // outbound has happened. The 300s fallback is unchanged and
534
- // escalates further if silence persists.
535
- //
536
- // Independent of pokesFired so soft/firm/fallback still escalate on
537
- // their own schedule. Independent of ackPokeFired so a long-running
538
- // turn that already received the ack-poke (then went silent again)
539
- // still gets the user-facing awareness ping.
540
- if (
541
- !s.awarenessPingFired
542
- && s.lastOutboundAt == null
543
- && !s.subagentDispatchActive
544
- && silence >= thresholds.awarenessPing
545
- ) {
546
- s.awarenessPingFired = true
547
- const { chatId, threadId } = parseKey(key)
548
- const recentThinking = s.lastThinkingAt != null
549
- && (now - s.lastThinkingAt) < 30_000
550
- const fallbackKind: 'working' | 'thinking' = recentThinking ? 'thinking' : 'working'
551
- const inFlightTools: ToolSnapshot[] = Array.from(s.inFlightTools.values())
552
- .sort((a, b) => a.startedAt - b.startedAt)
553
- .map(t => ({
554
- name: t.name,
555
- label: t.label,
556
- durationMs: now - t.startedAt,
557
- }))
558
- activeDeps.emitMetric({
559
- kind: 'awareness_ping_sent',
560
- key,
561
- fallback_kind: fallbackKind,
562
- silence_ms: silence,
563
- })
564
- try {
565
- const ret = activeDeps.onAwarenessPing({
566
- key,
567
- chatId,
568
- threadId,
569
- fallbackKind,
570
- silenceMs: silence,
571
- inFlightTools,
572
- })
573
- if (ret != null && typeof (ret as Promise<unknown>).then === 'function') {
574
- ;(ret as Promise<unknown>).catch(err => {
575
- process.stderr.write(`silence-poke: awareness-ping handler rejected: ${err}\n`)
576
- })
577
- }
578
- } catch (err) {
579
- process.stderr.write(`silence-poke: awareness-ping handler threw: ${err}\n`)
580
- }
581
- // Don't `continue` — soft/firm/fallback can still arm in the same tick
582
- // if their thresholds have also been crossed. Awareness-ping is a
583
- // sibling signal, not part of the ladder.
584
- }
585
-
586
- if (s.pokesFired === 0 && silence >= softThreshold) {
587
- s.pokeArmed = { level: 'soft' }
588
- s.pokesFired = 1
589
- s.lastPokeFiredAt = now
590
- activeDeps.emitMetric({
591
- kind: 'silence_poke_fired',
592
- key,
593
- level: 'soft',
594
- silence_ms: silence,
595
- subagent_wait: s.subagentDispatchActive,
596
- })
597
- continue
598
- }
599
-
600
- if (s.pokesFired === 1 && silence >= thresholds.firm) {
601
- s.pokeArmed = { level: 'firm' }
602
- s.pokesFired = 2
603
- s.lastPokeFiredAt = now
604
- activeDeps.emitMetric({
605
- kind: 'silence_poke_fired',
606
- key,
607
- level: 'firm',
608
- silence_ms: silence,
609
- subagent_wait: s.subagentDispatchActive,
610
- })
611
- continue
612
- }
613
341
 
614
- if (s.pokesFired === 2 && !s.fallbackFired && silence >= thresholds.fallback) {
342
+ if (!s.fallbackFired && silence >= thresholds.fallback) {
615
343
  s.fallbackFired = true
616
- s.pokesFired = 3
617
344
  const { chatId, threadId } = parseKey(key)
618
345
  const recentThinking = s.lastThinkingAt != null
619
346
  && (now - s.lastThinkingAt) < 30_000
@@ -1,21 +1,22 @@
1
1
  /**
2
- * Pin the contract: the boot card silences its Telegram notification
3
- * (passes `disable_notification: true` to `sendMessage`) iff the
4
- * restart marker's `reason` text starts with `"operator:"`.
2
+ * Pin the contract: the boot card ALWAYS silences its Telegram
3
+ * notification (passes `disable_notification: true` to `sendMessage`),
4
+ * regardless of what triggered the restart.
5
5
  *
6
6
  * Background: every agent in the fleet posts a boot card after a
7
- * `switchroom update`. Without this gate the operator gets N push
8
- * notifications for one planned redeploy once-per-agent on every
9
- * routine update. User-initiated restarts (`/restart` from chat,
10
- * `cli: switchroom restart`) and unplanned events (crash, fresh) still
11
- * notify because the user asked for them or needs to know something
12
- * went wrong.
7
+ * restart. A boot card is a status RECORD ("✅ <agent> back up ·
8
+ * vX.Y.Z") that lands in the chat for scroll-back it is never
9
+ * something that should buzz a phone. A fleet redeploy of N agents
10
+ * would otherwise produce N push notifications; even a single user
11
+ * `/restart` or a crash-recovery is a record, not an alert.
13
12
  *
14
- * The toggle is keyed on the reason TEXT (`opts.restartReasonDetail`),
15
- * not the RestartReason enum, because the enum collapses all
16
- * marker-bearing restarts into `'graceful'` losing the operator-vs-
17
- * user distinction. The reason text is the source of truth for who
18
- * triggered the restart.
13
+ * History: this used to be keyed on the `operator:` reason-detail
14
+ * prefix only routine `switchroom update` was silent, while user
15
+ * `/restart`, `cli: switchroom restart` rollouts, crashes, and fresh
16
+ * boots all still notified. Operator decision (2026-05-29): silence
17
+ * them all, unconditionally. These cases now assert the inverse of
18
+ * what they originally pinned, across every reason path, so a future
19
+ * regression that re-introduces a notifying boot card is caught.
19
20
  */
20
21
 
21
22
  import { describe, it, expect } from 'vitest'
@@ -54,50 +55,46 @@ function mkOpts(overrides: { restartReasonDetail?: string; restartReason?: 'plan
54
55
  }
55
56
  }
56
57
 
57
- describe('boot card — silent-on-operator-reason', () => {
58
- it('passes disable_notification: true when restartReasonDetail starts with "operator:"', async () => {
58
+ describe('boot card — always silent (no Telegram notification)', () => {
59
+ it('silences operator-initiated redeploys (operator: switchroom update)', async () => {
59
60
  const { bot, sends } = makeCapturingBot()
60
61
  await startBootCard('chat1', undefined, bot, mkOpts({ restartReasonDetail: 'operator: switchroom update' }))
61
62
  expect(sends).toHaveLength(1)
62
63
  expect(sends[0]!.opts.disable_notification).toBe(true)
63
64
  })
64
65
 
65
- it('omits disable_notification when restartReasonDetail starts with "user:"', async () => {
66
+ it('silences user-initiated restarts (user: /restart from chat)', async () => {
66
67
  const { bot, sends } = makeCapturingBot()
67
68
  await startBootCard('chat1', undefined, bot, mkOpts({ restartReasonDetail: 'user: /restart from chat' }))
68
69
  expect(sends).toHaveLength(1)
69
- expect(sends[0]!.opts.disable_notification).toBeUndefined()
70
+ expect(sends[0]!.opts.disable_notification).toBe(true)
70
71
  })
71
72
 
72
- it('omits disable_notification when restartReasonDetail starts with "cli:"', async () => {
73
+ it('silences cli rollouts (cli: switchroom restart)', async () => {
73
74
  const { bot, sends } = makeCapturingBot()
74
75
  await startBootCard('chat1', undefined, bot, mkOpts({ restartReasonDetail: 'cli: switchroom restart' }))
75
76
  expect(sends).toHaveLength(1)
76
- expect(sends[0]!.opts.disable_notification).toBeUndefined()
77
+ expect(sends[0]!.opts.disable_notification).toBe(true)
77
78
  })
78
79
 
79
- it('omits disable_notification when restartReasonDetail is undefined (crash / fresh path)', async () => {
80
+ it('silences crash / fresh boots (restartReasonDetail undefined)', async () => {
80
81
  const { bot, sends } = makeCapturingBot()
81
82
  await startBootCard('chat1', undefined, bot, mkOpts({ restartReason: 'crash' }))
82
83
  expect(sends).toHaveLength(1)
83
- expect(sends[0]!.opts.disable_notification).toBeUndefined()
84
+ expect(sends[0]!.opts.disable_notification).toBe(true)
84
85
  })
85
86
 
86
- it('omits disable_notification when restartReasonDetail is empty string', async () => {
87
+ it('silences when restartReasonDetail is empty string', async () => {
87
88
  const { bot, sends } = makeCapturingBot()
88
89
  await startBootCard('chat1', undefined, bot, mkOpts({ restartReasonDetail: '' }))
89
90
  expect(sends).toHaveLength(1)
90
- expect(sends[0]!.opts.disable_notification).toBeUndefined()
91
+ expect(sends[0]!.opts.disable_notification).toBe(true)
91
92
  })
92
93
 
93
- it('matches the "operator:" prefix exactly "operator-ish" should NOT silence', async () => {
94
- // Defence against future operator-side reasons that don't actually
95
- // want silent — confirms we're matching the prefix-with-colon shape,
96
- // not a fuzzy contains.
94
+ it('silences any other reason shape too (no notifying path remains)', async () => {
97
95
  const { bot, sends } = makeCapturingBot()
98
96
  await startBootCard('chat1', undefined, bot, mkOpts({ restartReasonDetail: 'operator-ish: rolled over' }))
99
97
  expect(sends).toHaveLength(1)
100
- // 'operator-ish:' does NOT start with 'operator:' so still notifies.
101
- expect(sends[0]!.opts.disable_notification).toBeUndefined()
98
+ expect(sends[0]!.opts.disable_notification).toBe(true)
102
99
  })
103
100
  })