switchroom 0.19.16 → 0.19.18

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.
Files changed (66) hide show
  1. package/bin/run-hook.sh +148 -0
  2. package/bin/workspace-dynamic-hook.sh +147 -38
  3. package/dist/agent-scheduler/index.js +11 -3
  4. package/dist/auth-broker/index.js +29 -4
  5. package/dist/cli/notion-write-pretool.mjs +11 -3
  6. package/dist/cli/switchroom.js +8307 -7620
  7. package/dist/host-control/main.js +626 -36
  8. package/dist/vault/approvals/kernel-server.js +30 -5
  9. package/dist/vault/broker/server.js +71 -18
  10. package/package.json +3 -2
  11. package/profiles/_base/start.sh.hbs +8 -4
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md.hbs +3 -3
  14. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  15. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  16. package/skills/mental-model-curator/SKILL.md +8 -6
  17. package/telegram-plugin/bridge/bridge.ts +11 -19
  18. package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
  19. package/telegram-plugin/dist/bridge/bridge.js +15 -20
  20. package/telegram-plugin/dist/gateway/gateway.js +763 -373
  21. package/telegram-plugin/dist/server.js +19 -20
  22. package/telegram-plugin/gateway/boot-card.ts +5 -1
  23. package/telegram-plugin/gateway/boot-probes.ts +113 -0
  24. package/telegram-plugin/gateway/config-approval-handler.test.ts +54 -0
  25. package/telegram-plugin/gateway/config-approval-handler.ts +16 -1
  26. package/telegram-plugin/gateway/disconnect-flush.ts +17 -0
  27. package/telegram-plugin/gateway/gateway.ts +43 -1
  28. package/telegram-plugin/gateway/handback-preturn-signal.ts +61 -7
  29. package/telegram-plugin/gateway/ipc-protocol.ts +5 -0
  30. package/telegram-plugin/gateway/ipc-server.ts +13 -0
  31. package/telegram-plugin/gateway/liveness-wiring.ts +125 -5
  32. package/telegram-plugin/gateway/obligation-ledger.ts +84 -4
  33. package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
  34. package/telegram-plugin/gateway/stream-render.ts +24 -5
  35. package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
  36. package/telegram-plugin/registry/turns-schema.test.ts +8 -3
  37. package/telegram-plugin/registry/turns-schema.ts +40 -12
  38. package/telegram-plugin/runtime-metrics.ts +14 -0
  39. package/telegram-plugin/silence-poke.ts +138 -0
  40. package/telegram-plugin/tests/boot-probe-drift.test.ts +152 -0
  41. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +32 -0
  42. package/telegram-plugin/tests/handback-preturn-signal.test.ts +62 -0
  43. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +178 -0
  44. package/telegram-plugin/tests/ipc-server-validate-config-approval.test.ts +95 -0
  45. package/telegram-plugin/tests/mcp-instructions-budget.test.ts +184 -0
  46. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +22 -2
  47. package/telegram-plugin/tests/obligation-determinism.test.ts +114 -3
  48. package/telegram-plugin/tests/obligation-ledger.test.ts +310 -0
  49. package/telegram-plugin/tests/registry-turns.test.ts +13 -0
  50. package/telegram-plugin/tests/resume-inbound-builder.test.ts +15 -0
  51. package/telegram-plugin/tests/secret-guard-pretool.test.ts +347 -16
  52. package/telegram-plugin/tests/silence-poke-orphan-reap.test.ts +392 -0
  53. package/telegram-plugin/tests/silence-poke-teardown-notice.test.ts +301 -0
  54. package/telegram-plugin/tests/stream-render-golden.test.ts +103 -1
  55. package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
  56. package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
  57. package/telegram-plugin/tts-normalize.ts +6 -4
  58. package/telegram-plugin/voice-normalize-text.ts +168 -11
  59. package/vendor/hindsight-memory/CHANGELOG.md +73 -0
  60. package/vendor/hindsight-memory/scripts/lib/config.py +8 -3
  61. package/vendor/hindsight-memory/scripts/lib/directives.py +62 -4
  62. package/vendor/hindsight-memory/scripts/recall.py +257 -12
  63. package/vendor/hindsight-memory/scripts/retain.py +12 -6
  64. package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
  65. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
  66. package/vendor/hindsight-memory/settings.json +1 -1
@@ -193,6 +193,8 @@ export interface MidTurnFloorContext {
193
193
  export type SilencePokeMetric =
194
194
  | { kind: 'silence_fallback_sent'; key: string; fallback_kind: 'working' | 'thinking'; silence_ms: number }
195
195
  | { kind: 'mid_turn_floor'; key: string; silence_ms: number; forced: boolean; decision: 'fire' | string }
196
+ /** #3552 — per-turn state dropped because its turn is provably over. */
197
+ | { kind: 'silence_poke_orphan_reaped'; key: string; silence_ms: number }
196
198
 
197
199
  export interface SilencePokeDeps {
198
200
  /** Called when the 300s fallback fires. Caller sends the user-visible
@@ -239,6 +241,30 @@ export interface SilencePokeDeps {
239
241
  * combines both. Returns null when there is no live turn for `key`.
240
242
  */
241
243
  floorState?: (key: string) => { role: LoopRole; finalAnswerDelivered: boolean } | null
244
+ /**
245
+ * #3552 — orphan-state reaper predicate. Returns true while `key` still has a
246
+ * LIVE turn behind it, false once the turn is provably over.
247
+ *
248
+ * Why: `startTurn(key)` arms per-turn state that only `endTurn(key)` drops,
249
+ * and several turn-end paths never reach an `endTurn` call — a bridge death
250
+ * (`disconnect-flush`), a keyed teardown that bypasses the `turn_end` handler,
251
+ * or any future path that nulls the turn without the cleanup tail. The state
252
+ * then survives its turn and sits in the Map until the 300s fallback fires
253
+ * against a dead key, is recognised as a late fire, logs
254
+ * `turn_ended_cleanly_during_window` and only THEN calls `endTurn`. Measured:
255
+ * 504 such skips vs 110 real fires in 14 days (switchroom#3552).
256
+ *
257
+ * Wiring this callback moves that cleanup from "300s later, at fire time" to
258
+ * "the next poll tick", deterministically and independent of which path ended
259
+ * the turn. The predicate MUST be the same one the fallback's late-fire guard
260
+ * uses (`activeTurnStartedAt` empty AND no current turn) so a turn that has
261
+ * merely released its buffer gate mid-turn — the normal post-final-answer
262
+ * state, where silence-poke must keep watching — is never reaped.
263
+ *
264
+ * Optional: when absent (test harnesses) the reaper is off and behaviour is
265
+ * exactly as before.
266
+ */
267
+ isTurnLive?: (key: string) => boolean
242
268
  }
243
269
 
244
270
  const state = new Map<string, SilencePokeState>()
@@ -505,6 +531,105 @@ export function formatFrameworkFallbackText(
505
531
  return null
506
532
  }
507
533
 
534
+ /**
535
+ * #3551 — inputs to the silence-fallback TEARDOWN NOTICE decision.
536
+ *
537
+ * Distinct from `formatFrameworkFallbackText`, which answers "is anything
538
+ * happening worth narrating?" (and, since the stall-notice was retired,
539
+ * answers `null` on every branch but `blockedOnApproval`). This one answers a
540
+ * different, terminal question: "the framework is about to END this live turn
541
+ * — does the user need to be told?"
542
+ */
543
+ export interface FallbackTeardownNoticeInput {
544
+ /** This fire is genuinely tearing down a live turn for THIS chat/thread.
545
+ * False on the multi-chat case where the current turn belongs elsewhere, or
546
+ * when there was no turn left to end. */
547
+ tearsDownLiveTurn: boolean
548
+ /** Loop role of the turn being torn down; null when unknown. */
549
+ role: LoopRole | null
550
+ /** A final answer already landed on this turn. */
551
+ finalAnswerDelivered: boolean
552
+ /** This fire ALREADY DELIVERED a LOUD, user-addressed message about why
553
+ * nothing is happening — in practice the approval re-ping ("waiting for your
554
+ * approval — tap Approve or Deny"). Never stack a second message on that.
555
+ *
556
+ * #3551 L1: deliberately NARROWER than "some text went out". The other text
557
+ * this fire can emit is `formatUpdateStatusLine`, a SILENT status surface
558
+ * about an unrelated in-flight `update_apply` that says nothing about this
559
+ * turn ending — a user who asked a question during an update and got killed
560
+ * anyway is exactly the case this notice exists for, so that line must NOT
561
+ * suppress it.
562
+ * #3551 L2: means DELIVERED, not "attempted". The caller's send is wrapped in
563
+ * a log-only try/catch, so a throwing re-ping must not buy silence. */
564
+ userAddressedTextDelivered: boolean
565
+ /** The obligation ledger is on, so the killed turn's unanswered inbound will
566
+ * be re-presented. Governs the tail sentence — never claim a re-ask that
567
+ * cannot happen. */
568
+ representWillFollow: boolean
569
+ silenceMs: number
570
+ }
571
+
572
+ export type FallbackTeardownNoticeDecision =
573
+ | {
574
+ send: false
575
+ reason:
576
+ | 'user-addressed-text-delivered'
577
+ | 'no-live-turn-torn-down'
578
+ | 'non-user-turn'
579
+ | 'answer-already-delivered'
580
+ }
581
+ | { send: true; text: string }
582
+
583
+ /**
584
+ * #3551 — decide whether a silence-poke fire that ENDS a live turn must say so.
585
+ *
586
+ * The bug this closes: on the non-approval branch the 300s fallback delivered
587
+ * NO text (`formatFrameworkFallbackText` returns null) while still tearing the
588
+ * turn down and redelivering buffered inbound. From the user's seat: five
589
+ * minutes of nothing, then the agent inexplicably restarting the same request,
590
+ * with no signal that the framework — not the agent — made that happen.
591
+ * Measured: 110 fires / 14 days, p50 silence 302s, ~9h of dead air.
592
+ *
593
+ * The fix is NOT to stop killing the turn. The teardown is the fallback's one
594
+ * genuinely load-bearing job (`#1122`: without it every later inbound queues
595
+ * behind a dead turn forever), and removing it would trade a silent turn for a
596
+ * permanently wedged conversation. The fix is to make the kill OBSERVABLE.
597
+ *
598
+ * This is deliberately NOT a re-introduction of the retired stall notice, and
599
+ * the gates below are what keep it that way:
600
+ * - `userAddressedTextDelivered` — never stack on a delivered approval
601
+ * re-ping. Narrow by design: a SILENT, unrelated update-status line does
602
+ * not buy silence here (#3551 L1), and an approval re-ping that threw on
603
+ * send does not either (#3551 L2).
604
+ * - `tearsDownLiveTurn` — a late-fire against an already-dead key says nothing.
605
+ * - `role !== 'user'` — a cron/system turn's silence is legitimate; nobody is
606
+ * waiting, so its teardown is housekeeping, not news.
607
+ * - `finalAnswerDelivered` — the user already has their answer; ending the
608
+ * residual housekeeping turn is invisible to them and needs no notice.
609
+ * What remains is exactly one case: a HUMAN asked something, got nothing for
610
+ * N minutes, and the framework just ended that attempt. That is a terminal
611
+ * event, fired at most once per turn (the caller drops the key immediately
612
+ * after), not a cadence-based progress ping — the thing
613
+ * `reference/rfcs/conversational-pacing.md` § Anti-patterns bans.
614
+ */
615
+ export function decideFallbackTeardownNotice(
616
+ input: FallbackTeardownNoticeInput,
617
+ ): FallbackTeardownNoticeDecision {
618
+ if (input.userAddressedTextDelivered)
619
+ return { send: false, reason: 'user-addressed-text-delivered' }
620
+ if (!input.tearsDownLiveTurn) return { send: false, reason: 'no-live-turn-torn-down' }
621
+ if (input.role !== 'user') return { send: false, reason: 'non-user-turn' }
622
+ if (input.finalAnswerDelivered) return { send: false, reason: 'answer-already-delivered' }
623
+ const minutes = Math.max(1, Math.round(input.silenceMs / 60_000))
624
+ const tail = input.representWillFollow
625
+ ? 'Your message is still tracked and is being re-asked now.'
626
+ : 'Your message was not answered — please re-send it.'
627
+ return {
628
+ send: true,
629
+ text: `⚠️ no output for ${minutes} min — the framework ended that stalled turn. ${tail}`,
630
+ }
631
+ }
632
+
508
633
  /**
509
634
  * #2995 — the LONGEST-running in-flight tool for a turn key, or null when
510
635
  * none is tracked. Read-only accessor over `inFlightTools` for the
@@ -603,6 +728,19 @@ function tick(now: number): void {
603
728
  const silence = now - zeroAt
604
729
  if (silence < 0) continue
605
730
 
731
+ // #3552 — orphan reap. Drop per-turn state the moment its turn is provably
732
+ // over, instead of leaving it armed until the 300s fallback fires against a
733
+ // dead key and disarms it as a "late fire". Deterministic and path-agnostic:
734
+ // it does not matter WHICH turn-end path forgot to call `endTurn`, the state
735
+ // cannot outlive the turn by more than one poll interval. Same predicate as
736
+ // the fallback's late-fire guard, so a mid-turn buffer-gate release (the
737
+ // normal post-final-answer state, turn still live) is never reaped.
738
+ if (activeDeps.isTurnLive != null && !activeDeps.isTurnLive(key)) {
739
+ state.delete(key)
740
+ activeDeps.emitMetric({ kind: 'silence_poke_orphan_reaped', key, silence_ms: silence })
741
+ continue
742
+ }
743
+
606
744
  // #2527 — the early, quiet mid-turn liveness beat (below the fallback
607
745
  // window). Evaluated every tick; fires at most once per turn.
608
746
  tryMidTurnFloor(key, s, now, false)
@@ -0,0 +1,152 @@
1
+ /**
2
+ * KEN-130 — boot-card drift probe. Outcome-asserting: a staled stamped
3
+ * surface or a host-written drift report produces a degraded probe row
4
+ * naming the surfaces; a clean agent dir stays ok (silent-when-healthy —
5
+ * ok probes never render a boot-card row).
6
+ */
7
+ import { describe, expect, it, beforeEach, afterEach } from 'vitest'
8
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
9
+ import { tmpdir } from 'node:os'
10
+ import { join } from 'node:path'
11
+
12
+ import { probeDrift } from '../gateway/boot-probes.js'
13
+ import {
14
+ CLAUDE_MD_YOURS_MARKER,
15
+ computeConfigHash,
16
+ computeStampFilesFromDisk,
17
+ writeGenerationStamp,
18
+ } from '../../src/agents/generation-stamp.js'
19
+
20
+ let dir: string
21
+ beforeEach(() => {
22
+ dir = mkdtempSync(join(tmpdir(), 'boot-drift-'))
23
+ })
24
+ afterEach(() => {
25
+ rmSync(dir, { recursive: true, force: true })
26
+ })
27
+
28
+ function seedAndStamp(): void {
29
+ writeFileSync(join(dir, 'start.sh'), '#!/bin/bash\necho hi\n')
30
+ writeFileSync(
31
+ join(dir, 'CLAUDE.md'),
32
+ `# Agent\nmanaged\n\n${CLAUDE_MD_YOURS_MARKER}\n\nyours\n`,
33
+ )
34
+ writeFileSync(join(dir, '.mcp.json'), '{"mcpServers":{}}\n')
35
+ writeGenerationStamp(dir, {
36
+ switchroomVersion: '1.0.0',
37
+ configHash: computeConfigHash({ model: 'opus' }),
38
+ files: computeStampFilesFromDisk(dir),
39
+ })
40
+ }
41
+
42
+ describe('probeDrift', () => {
43
+ it('clean agent dir with stamp → ok (no boot-card row)', async () => {
44
+ seedAndStamp()
45
+ const r = await probeDrift(dir)
46
+ expect(r.status).toBe('ok')
47
+ })
48
+
49
+ it('no stamp and no report (fresh agent / pre-KEN-130) → ok', async () => {
50
+ const r = await probeDrift(dir)
51
+ expect(r.status).toBe('ok')
52
+ })
53
+
54
+ it('tampered start.sh → degraded row naming start.sh with a next step', async () => {
55
+ seedAndStamp()
56
+ writeFileSync(join(dir, 'start.sh'), '#!/bin/bash\nTAMPERED\n')
57
+ const r = await probeDrift(dir, { agentName: 'bot' })
58
+ expect(r.status).toBe('degraded')
59
+ expect(r.detail).toContain('start.sh')
60
+ expect(r.nextStep).toContain('switchroom apply')
61
+ expect(r.nextStep).toContain('reconcile bot')
62
+ })
63
+
64
+ it('operator edit BELOW the CLAUDE.md marker → still ok', async () => {
65
+ seedAndStamp()
66
+ writeFileSync(
67
+ join(dir, 'CLAUDE.md'),
68
+ `# Agent\nmanaged\n\n${CLAUDE_MD_YOURS_MARKER}\n\ncompletely new operator prose\n`,
69
+ )
70
+ const r = await probeDrift(dir)
71
+ expect(r.status).toBe('ok')
72
+ })
73
+
74
+ it('host drift report findings surface on the row (with age when stale)', async () => {
75
+ seedAndStamp()
76
+ // Backdate the stamp so the report post-dates the last apply (a
77
+ // report older than the stamp is legitimately suppressed — see the
78
+ // superseded-by-apply test below).
79
+ writeGenerationStamp(dir, {
80
+ generatedAt: new Date(Date.now() - 3 * 86_400_000).toISOString(),
81
+ switchroomVersion: '1.0.0',
82
+ configHash: computeConfigHash({ model: 'opus' }),
83
+ files: computeStampFilesFromDisk(dir),
84
+ })
85
+ const twoDaysAgo = new Date(Date.now() - 2 * 86_400_000).toISOString()
86
+ writeFileSync(
87
+ join(dir, '.switchroom-drift.json'),
88
+ JSON.stringify({
89
+ version: 1,
90
+ generatedAt: twoDaysAgo,
91
+ findings: [
92
+ { surface: 'compose', detail: 'deployed compose differs' },
93
+ { surface: 'hook-scripts', detail: 'image scripts stale' },
94
+ ],
95
+ }),
96
+ )
97
+ const r = await probeDrift(dir)
98
+ expect(r.status).toBe('degraded')
99
+ expect(r.detail).toContain('compose')
100
+ expect(r.detail).toContain('hook-scripts')
101
+ expect(r.detail).toContain('ago') // stale-report age annotation
102
+ })
103
+
104
+ it('report older than the stamp (apply healed it) → ok, findings suppressed', async () => {
105
+ // Doctor found drift, then the operator ran `switchroom apply` — the
106
+ // reconcile rewrote the stamp but NOT the doctor report. A freshly
107
+ // applied fleet must be silent; the stale report is superseded.
108
+ writeFileSync(
109
+ join(dir, '.switchroom-drift.json'),
110
+ JSON.stringify({
111
+ version: 1,
112
+ generatedAt: new Date(Date.now() - 3_600_000).toISOString(),
113
+ findings: [{ surface: 'compose', detail: 'was drifted before apply' }],
114
+ }),
115
+ )
116
+ seedAndStamp() // stamp written AFTER the report
117
+ const r = await probeDrift(dir)
118
+ expect(r.status).toBe('ok')
119
+ })
120
+
121
+ it('report newer than the stamp still surfaces (drift found after apply)', async () => {
122
+ seedAndStamp()
123
+ writeFileSync(
124
+ join(dir, '.switchroom-drift.json'),
125
+ JSON.stringify({
126
+ version: 1,
127
+ generatedAt: new Date(Date.now() + 1000).toISOString(),
128
+ findings: [{ surface: 'hook-scripts', detail: 'image scripts stale' }],
129
+ }),
130
+ )
131
+ const r = await probeDrift(dir)
132
+ expect(r.status).toBe('degraded')
133
+ expect(r.detail).toContain('hook-scripts')
134
+ })
135
+
136
+ it('empty host report (doctor ran clean) → ok', async () => {
137
+ seedAndStamp()
138
+ writeFileSync(
139
+ join(dir, '.switchroom-drift.json'),
140
+ JSON.stringify({ version: 1, generatedAt: new Date().toISOString(), findings: [] }),
141
+ )
142
+ const r = await probeDrift(dir)
143
+ expect(r.status).toBe('ok')
144
+ })
145
+
146
+ it('corrupt report is ignored, never degrades the boot card', async () => {
147
+ seedAndStamp()
148
+ writeFileSync(join(dir, '.switchroom-drift.json'), 'not json at all')
149
+ const r = await probeDrift(dir)
150
+ expect(r.status).toBe('ok')
151
+ })
152
+ })
@@ -282,3 +282,35 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
282
282
  expect(deps.activeTurnStartedAt.size).toBe(0)
283
283
  })
284
284
  })
285
+
286
+ describe('#3552 — onTurnKeyEnded disarms per-turn timers on bridge death', () => {
287
+ it('reports EVERY torn-down turn key, from both the controller loop and the dangling sweep', () => {
288
+ const { deps } = makeDeps('clerk')
289
+ // A third key that the controller loop does not cover — it only lives in
290
+ // activeTurnStartedAt (finalize already ran on the canonical reply path),
291
+ // so it is picked up by the dangling sweep.
292
+ deps.activeTurnStartedAt.set('chat3:thr3:msg3', 300)
293
+ const ended: string[] = []
294
+
295
+ flushOnAgentDisconnect({ ...deps, onTurnKeyEnded: (k: string) => ended.push(k) })
296
+
297
+ // Outcome: no turn key survives the flush without being reported, so no
298
+ // silence-poke state can outlive the dead bridge's turns.
299
+ expect(new Set(ended)).toEqual(
300
+ new Set(['chat1:thr1:msg1', 'chat2:thr2:msg2', 'chat3:thr3:msg3']),
301
+ )
302
+ expect(deps.activeTurnStartedAt.size).toBe(0)
303
+ })
304
+
305
+ it('is never called for an anonymous disconnect (no turns were owned)', () => {
306
+ const { deps } = makeDeps(null)
307
+ const ended: string[] = []
308
+ flushOnAgentDisconnect({ ...deps, onTurnKeyEnded: (k: string) => ended.push(k) })
309
+ expect(ended).toEqual([])
310
+ })
311
+
312
+ it('omitting onTurnKeyEnded is safe (optional callback)', () => {
313
+ const { deps } = makeDeps('clerk')
314
+ expect(() => flushOnAgentDisconnect(deps)).not.toThrow()
315
+ })
316
+ })
@@ -333,6 +333,68 @@ describe('handback-preturn-signal — dead-air pre-turn emit→adopt→reap', ()
333
333
  expect(h.openCard).toHaveBeenCalledTimes(1)
334
334
  })
335
335
 
336
+ // ── #3544 ──────────────────────────────────────────────────────────────
337
+ // The 30 s orphan reap used to call `stopTypingLoop` unconditionally. That
338
+ // loop is keyed on (chat, thread), NOT on this seam's entry — so a handback
339
+ // turn still composing at 30 s had its indicator killed mid-turn, and nothing
340
+ // re-armed it. Outcome assertion: chat actions are still landing at 45 s.
341
+ it('#3544 — the orphan reap keeps typing alive when a real turn is live on the key (still emitting at 45s)', async () => {
342
+ vi.useFakeTimers()
343
+ try {
344
+ const liveKeys = new Set<string>()
345
+ const h = makeHarness({ hasLiveTurn: (k) => liveKeys.has(k) })
346
+ h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatH', messageId: 900 }))
347
+ await h.sched.advance(700) // debounce fires: typing armed, card painted
348
+ expect(h.typing.activeCount()).toBe(1)
349
+
350
+ // The handback's turn mints and is composing (a long one — worker results
351
+ // to read, a reply to write).
352
+ liveKeys.add('chatH:_')
353
+ const beforeReap = h.sendChatAction.mock.calls.length
354
+
355
+ await h.sched.advance(30_000) // the age-based orphan reap fires
356
+ // Pre-#3544 this was 0 and the chat went dark for the rest of the turn.
357
+ expect(h.typing.activeCount()).toBe(1)
358
+
359
+ // …and it is genuinely still EMITTING (not merely registered) at 45 s.
360
+ vi.advanceTimersByTime(15_000) // 15 s of the loop's 4 s refresh
361
+ expect(h.sendChatAction.mock.calls.length).toBeGreaterThan(beforeReap + 2)
362
+ expect(h.sendChatAction).toHaveBeenLastCalledWith('chatH', null)
363
+ } finally {
364
+ vi.useRealTimers()
365
+ }
366
+ })
367
+
368
+ it('#3544 — the orphan reap still stops typing when NO turn is live (no leaked interval)', async () => {
369
+ const h = makeHarness({ hasLiveTurn: () => false })
370
+ h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatI', messageId: 901 }))
371
+ await h.sched.advance(700)
372
+ expect(h.typing.activeCount()).toBe(1)
373
+ await h.sched.advance(30_000)
374
+ expect(h.typing.activeCount()).toBe(0)
375
+ expect(h.signal.pendingCount()).toBe(0)
376
+ })
377
+
378
+ it('#3544 — handleReaped keeps typing alive for a live turn, stops it otherwise', async () => {
379
+ const liveKeys = new Set<string>()
380
+ const h = makeHarness({ hasLiveTurn: (k) => liveKeys.has(k) })
381
+ h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatJ', messageId: 902 }))
382
+ await h.sched.advance(700)
383
+ const syntheticKey = [...h.records.keys()][0]!
384
+ expect(syntheticKey.startsWith(PRETURN_TURNKEY_PREFIX)).toBe(true)
385
+ liveKeys.add('chatJ:_')
386
+ h.signal.handleReaped(syntheticKey)
387
+ expect(h.typing.activeCount()).toBe(1) // live turn owns the stop
388
+ expect(h.signal.pendingCount()).toBe(0) // entry still dropped
389
+
390
+ // Same hook with no live turn DOES stop the loop.
391
+ const h2 = makeHarness({ hasLiveTurn: () => false })
392
+ h2.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatK', messageId: 903 }))
393
+ await h2.sched.advance(700)
394
+ h2.signal.handleReaped([...h2.records.keys()][0]!)
395
+ expect(h2.typing.activeCount()).toBe(0)
396
+ })
397
+
336
398
  it('skips the emit when the adopting turn already settled before the debounce fires', async () => {
337
399
  const settled = new Set<string>()
338
400
  const h = makeHarness({ isTurnSettled: (k) => settled.has(k) })
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Test fixture for the REAL `buildSilencePokeOptions` wiring (#3575 review B3).
3
+ *
4
+ * The two highest-blast-radius pieces of `liveness-wiring.ts` — the `isTurnLive`
5
+ * reaper predicate and the `onFrameworkFallback` teardown + notice — had NO
6
+ * executing coverage: `buildSilencePokeOptions` has exactly one caller (the
7
+ * gateway's module-load `startTimer`) and no test imported it, so the suite
8
+ * either stubbed the predicate or asserted regexes against the function's own
9
+ * SOURCE TEXT. A hand-retyped copy of a predicate cannot detect divergence, and
10
+ * a source regex passes even if the handler is never invoked.
11
+ *
12
+ * `buildSilencePokeOptions` is a pure function of its deps object (its only
13
+ * gateway import is `import type`), so the whole options object CAN be built in
14
+ * a unit test. This fixture supplies the collaborators the fallback actually
15
+ * touches, with spies on the two observable effects (`sendSilenceText`,
16
+ * `endCurrentTurnForKey`).
17
+ */
18
+ import { mkdtempSync } from 'node:fs'
19
+ import { tmpdir } from 'node:os'
20
+ import { join } from 'node:path'
21
+ import type { buildSilencePokeOptions } from '../../gateway/liveness-wiring.js'
22
+
23
+ type Deps = Parameters<typeof buildSilencePokeOptions>[0]
24
+
25
+ export interface SentText {
26
+ chatId: string
27
+ threadId: number | null
28
+ text: string
29
+ silent: boolean
30
+ }
31
+
32
+ export interface LivenessFixture {
33
+ deps: Deps
34
+ sent: SentText[]
35
+ endedKeys: string[]
36
+ /** The gateway's `activeTurnStartedAt` map, writable by the test. */
37
+ activeTurnStartedAt: Map<string, number>
38
+ /** Swap the "current turn" the wiring reads. */
39
+ setCurrentTurn: (t: unknown) => void
40
+ /**
41
+ * Start a turn FOR A TOPIC KEY. With a `turnMap` supplied (the flag-ON
42
+ * `CurrentTurnMap`), this is the gateway's `setCurrentTurn(turn, key)`: it
43
+ * writes the per-topic entry AND flips the most-recent-set mirror. Without
44
+ * one, it degrades to the flag-OFF singleton assignment.
45
+ */
46
+ setCurrentTurnForKey: (key: string, t: unknown) => void
47
+ /** Open obligation ids the per-turn `representWillFollow` lookup consults. */
48
+ openObligations: Set<string>
49
+ }
50
+
51
+ /**
52
+ * The subset of the REAL `CurrentTurnMap` (gateway/current-turn-map.ts) the
53
+ * liveness wiring touches. #3580 tests pass a genuine instance, re-imported
54
+ * under `SWITCHROOM_EMISSION_AUTHORITY=1` via the module's read-once seam, so
55
+ * the flag-ON mirror-vs-keyed semantics under test are the production ones and
56
+ * not a hand-retyped model of them.
57
+ */
58
+ export interface TurnMapLike {
59
+ get(key?: string): unknown
60
+ set(turn: never, key: string): void
61
+ isLiveForKey(turn: never, key: string): boolean
62
+ endTurnForKey(turn: never, key: string): boolean
63
+ purgeChatStale(chatId: string, isStale?: (key: string) => boolean): string[]
64
+ }
65
+
66
+ export function statusKeyForTests(chatId: string, threadId: number | null | undefined): string {
67
+ return `${chatId}:${threadId ?? '_'}`
68
+ }
69
+
70
+ /** Minimal but REAL-shaped CurrentTurn for the fields the fallback reads. */
71
+ export function makeTurn(over: Record<string, unknown> = {}): Record<string, unknown> {
72
+ return {
73
+ sessionChatId: '-100999',
74
+ sessionThreadId: undefined,
75
+ turnId: '-100999#42',
76
+ registryKey: null,
77
+ role: 'user',
78
+ finalAnswerDelivered: false,
79
+ capturedText: [],
80
+ lastAssistantMsgId: null,
81
+ lastAssistantDone: false,
82
+ toolCallCount: 0,
83
+ ...over,
84
+ }
85
+ }
86
+
87
+ export function makeLivenessFixture(
88
+ over: Partial<Record<string, unknown>> = {},
89
+ turnMap?: TurnMapLike,
90
+ ): LivenessFixture {
91
+ const sent: SentText[] = []
92
+ const endedKeys: string[] = []
93
+ const activeTurnStartedAt = new Map<string, number>()
94
+ const openObligations = new Set<string>()
95
+ let currentTurn: unknown = null
96
+
97
+ // Mirrors gateway.ts's `gatewayLivenessWiringDeps()` exactly. Without a
98
+ // `turnMap` the fixture models the flag-OFF gateway, where `currentTurnMap`
99
+ // holds only the singleton and the keyed read `currentTurnMap.get(key)`
100
+ // returns it — hence `getCurrentTurnForKey` ignoring the key here is the
101
+ // production flag-OFF behaviour, not a shortcut. With a `turnMap` it is the
102
+ // real keyed store and every accessor delegates to it.
103
+ const turnMapDeps = turnMap == null
104
+ ? {
105
+ getCurrentTurn: () => currentTurn,
106
+ getCurrentTurnForKey: (_key: string) => currentTurn,
107
+ currentTurnMap: { purgeChatStale: () => {} },
108
+ turnLiveForItsTopic: () => true,
109
+ endCurrentTurnForKey: (_turn: unknown, key: string) => {
110
+ endedKeys.push(key)
111
+ currentTurn = null
112
+ return true
113
+ },
114
+ }
115
+ : {
116
+ getCurrentTurn: () => turnMap.get() ?? null,
117
+ getCurrentTurnForKey: (key: string) => turnMap.get(key) ?? null,
118
+ currentTurnMap: turnMap,
119
+ turnLiveForItsTopic: (turn: { sessionChatId: string; sessionThreadId?: number | null }) =>
120
+ turnMap.isLiveForKey(
121
+ turn as never,
122
+ statusKeyForTests(turn.sessionChatId, turn.sessionThreadId),
123
+ ),
124
+ endCurrentTurnForKey: (turn: unknown, key: string) => {
125
+ endedKeys.push(key)
126
+ return turnMap.endTurnForKey(turn as never, key)
127
+ },
128
+ }
129
+
130
+ const deps = {
131
+ SILENCE_FALLBACK_MS: 300_000,
132
+ SILENCE_FALLBACK_HARD_MS: 900_000,
133
+ SILENCE_FLOOR_MS: 90_000,
134
+ SILENCE_DEFER_INFLIGHT_TOOLS: false,
135
+ isObligationOpenForTurn: (originTurnId: string | null): boolean =>
136
+ originTurnId != null && openObligations.has(originTurnId),
137
+ TURN_PREVIEW_MAX: 200,
138
+ STATE_DIR: mkdtempSync(join(tmpdir(), 'liveness-wiring-')),
139
+ isLegitimatelyWorking: () => false,
140
+ ...turnMapDeps,
141
+ getInFlightUpdate: () => null,
142
+ getTurnsDb: () => null,
143
+ getInboundSpool: () => undefined,
144
+ sendToAgent: () => true,
145
+ sendSilenceText: async (chatId: string, threadId: number | null, text: string, silent: boolean) => {
146
+ sent.push({ chatId, threadId, text, silent })
147
+ },
148
+ getMyAgentName: () => 'test-agent',
149
+ statusKey: statusKeyForTests,
150
+ activeTurnStartedAt,
151
+ activeStatusReactions: new Map(),
152
+ lastPtyPreviewByChat: new Map(),
153
+ preambleSuppressor: { dropNow: () => {} },
154
+ purgeReactionTracking: () => {},
155
+ getPendingInboundBuffer: () => ({ drain: () => [] }),
156
+ trackRedeliveredInbound: () => {},
157
+ closeActivityLane: () => {},
158
+ closeProgressLane: () => {},
159
+ hangRestart: null,
160
+ ...over,
161
+ } as unknown as Deps
162
+
163
+ return {
164
+ deps,
165
+ sent,
166
+ endedKeys,
167
+ activeTurnStartedAt,
168
+ setCurrentTurn: (t: unknown) => {
169
+ if (turnMap != null) throw new Error('use setCurrentTurnForKey with a keyed turnMap')
170
+ currentTurn = t
171
+ },
172
+ setCurrentTurnForKey: (key: string, t: unknown) => {
173
+ if (turnMap == null) { currentTurn = t; return }
174
+ turnMap.set(t as never, key)
175
+ },
176
+ openObligations,
177
+ }
178
+ }