switchroom 0.18.11 → 0.18.13

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 (150) hide show
  1. package/dist/agent-scheduler/index.js +37 -5
  2. package/dist/auth-broker/index.js +116 -78
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  5. package/dist/cli/notion-write-pretool.mjs +38 -6
  6. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  7. package/dist/cli/switchroom.js +3529 -4543
  8. package/dist/cli/ui/index.html +163 -17
  9. package/dist/host-control/main.js +605 -2847
  10. package/dist/vault/approvals/kernel-server.js +120 -13
  11. package/dist/vault/broker/server.js +259 -157
  12. package/package.json +3 -4
  13. package/profiles/_base/start.sh.hbs +65 -0
  14. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  15. package/profiles/coding/CLAUDE.md.hbs +1 -1
  16. package/profiles/default/CLAUDE.md.hbs +2 -2
  17. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  18. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  19. package/telegram-plugin/bridge/bridge.ts +37 -0
  20. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +122 -4
  22. package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
  23. package/telegram-plugin/dist/server.js +139 -5
  24. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  25. package/telegram-plugin/format.ts +19 -0
  26. package/telegram-plugin/gateway/approval-hold.ts +602 -0
  27. package/telegram-plugin/gateway/auth-command.ts +92 -2
  28. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  29. package/telegram-plugin/gateway/boot-card.ts +12 -5
  30. package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
  31. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  32. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  33. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  34. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  35. package/telegram-plugin/gateway/gateway.ts +1695 -230
  36. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  37. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  38. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  39. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  40. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  41. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  42. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  43. package/telegram-plugin/gateway/model-command.ts +9 -3
  44. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  45. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  46. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  47. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  48. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  49. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  50. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  51. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  52. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  53. package/telegram-plugin/history.ts +51 -0
  54. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  55. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  56. package/telegram-plugin/hooks/hooks.json +9 -0
  57. package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
  58. package/telegram-plugin/model-unavailable.ts +41 -11
  59. package/telegram-plugin/operator-events.ts +23 -0
  60. package/telegram-plugin/outbound-field-redact.ts +69 -0
  61. package/telegram-plugin/package.json +0 -1
  62. package/telegram-plugin/permission-rule.ts +1 -0
  63. package/telegram-plugin/permission-title.ts +1 -0
  64. package/telegram-plugin/render/render.ts +32 -14
  65. package/telegram-plugin/retry-api-call.ts +212 -2
  66. package/telegram-plugin/scoped-approval.ts +11 -2
  67. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  68. package/telegram-plugin/secret-detect/index.ts +12 -56
  69. package/telegram-plugin/send-gate-degraded.test.ts +574 -0
  70. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  71. package/telegram-plugin/send-gate-observability.ts +355 -0
  72. package/telegram-plugin/send-gate.test.ts +717 -0
  73. package/telegram-plugin/send-gate.ts +1056 -0
  74. package/telegram-plugin/session-tail.ts +82 -7
  75. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  76. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  77. package/telegram-plugin/status-pin-driver.ts +52 -7
  78. package/telegram-plugin/status-pin.ts +81 -0
  79. package/telegram-plugin/subagent-watcher.ts +173 -18
  80. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  81. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  82. package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
  83. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  84. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  85. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  86. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  87. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  88. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  89. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  90. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  91. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  92. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  93. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  94. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  95. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  96. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  97. package/telegram-plugin/tests/history.test.ts +115 -0
  98. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  99. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  100. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  101. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
  102. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  103. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  104. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  105. package/telegram-plugin/tests/model-command.test.ts +14 -0
  106. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  107. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  108. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  109. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  111. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  112. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  113. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  114. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  115. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  116. package/telegram-plugin/tests/render/render.test.ts +88 -0
  117. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  118. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  119. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  120. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  121. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  122. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  123. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  124. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  125. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  126. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  127. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  128. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  129. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  130. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  131. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  132. package/telegram-plugin/typing-emitter.ts +224 -0
  133. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  134. package/telegram-plugin/welcome-text.ts +42 -0
  135. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  136. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  137. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  138. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  139. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  140. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  141. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  142. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  143. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  144. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  145. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  146. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  147. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  148. package/telegram-plugin/channel-envelope-safety.ts +0 -56
  149. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  150. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -542,6 +542,121 @@ describe('hasOutboundDeliveredSince', () => {
542
542
  })
543
543
  })
544
544
 
545
+ // Review finding H5 regression: the original PRIMARY KEY (chat_id, thread_id, message_id) does
546
+ // NOT dedupe thread-less rows because SQLite treats NULL as distinct from NULL
547
+ // in a UNIQUE/PK index. The documented at-least-once boot replay re-records an
548
+ // already-stored DM/non-topic message, so `INSERT OR REPLACE` appended a
549
+ // DUPLICATE row instead of replacing — over-counting the silence / over-ping
550
+ // detectors. The COALESCE(thread_id,'') unique index makes the upsert
551
+ // idempotent. These tests fail (duplicate rows) without the fix.
552
+ describe('idempotent upsert for null-thread rows (H5)', () => {
553
+ beforeEach(() => initHistory(stateDir, 30))
554
+
555
+ it('re-recording the same null-thread inbound yields exactly ONE row', () => {
556
+ const msg = {
557
+ chat_id: '-100',
558
+ thread_id: null,
559
+ message_id: 7,
560
+ user: 'alice',
561
+ user_id: '111',
562
+ ts: 1000,
563
+ text: 'hello',
564
+ }
565
+ // Simulate the at-least-once replay: record the same message twice.
566
+ recordInbound(msg)
567
+ recordInbound(msg)
568
+ const rows = query({ chat_id: '-100' })
569
+ expect(rows).toHaveLength(1)
570
+ expect(rows[0]).toMatchObject({ message_id: 7, role: 'user', text: 'hello' })
571
+ // The over-count surface the finding calls out must stay accurate.
572
+ expect(getRecentOutboundCount('-100', 999_999)).toBe(0)
573
+ })
574
+
575
+ it('re-recording the same null-thread outbound yields exactly ONE row', () => {
576
+ const now = Math.floor(Date.now() / 1000)
577
+ const send = {
578
+ chat_id: '-100',
579
+ thread_id: null,
580
+ message_ids: [42],
581
+ texts: ['the answer'],
582
+ ts: now,
583
+ }
584
+ recordOutbound(send)
585
+ recordOutbound(send)
586
+ const rows = query({ chat_id: '-100' })
587
+ expect(rows).toHaveLength(1)
588
+ // hasOutboundDeliveredSince / getRecentOutboundCount read this table; a
589
+ // duplicate here would double-count and over-ping.
590
+ expect(getRecentOutboundCount('-100', 60)).toBe(1)
591
+ })
592
+
593
+ it('a re-record REPLACES rather than appends (newest text wins)', () => {
594
+ recordInbound({ chat_id: '-100', thread_id: null, message_id: 7, user: 'a', user_id: '1', ts: 1000, text: 'first version' })
595
+ recordInbound({ chat_id: '-100', thread_id: null, message_id: 7, user: 'a', user_id: '1', ts: 1000, text: 'second version' })
596
+ const rows = query({ chat_id: '-100' })
597
+ expect(rows).toHaveLength(1)
598
+ expect(rows[0]?.text).toBe('second version')
599
+ })
600
+
601
+ it('a topic row and a general row with the SAME message_id stay DISTINCT', () => {
602
+ // Same chat_id + message_id, but one is in a forum topic (thread 5) and one
603
+ // is the general/DM row (null thread). These are different logical messages
604
+ // and must both survive — the COALESCE sentinel keeps them separate.
605
+ recordInbound({ chat_id: '-100', thread_id: 5, message_id: 9, user: 'a', user_id: '1', ts: 100, text: 'in topic 5' })
606
+ recordInbound({ chat_id: '-100', thread_id: null, message_id: 9, user: 'a', user_id: '1', ts: 100, text: 'in general' })
607
+ expect(query({ chat_id: '-100' })).toHaveLength(2)
608
+ expect(query({ chat_id: '-100', thread_id: 5 }).map(r => r.text)).toEqual(['in topic 5'])
609
+ expect(query({ chat_id: '-100', thread_id: null }).map(r => r.text)).toEqual(['in general'])
610
+ })
611
+
612
+ it('two distinct forum topics with the same message_id stay DISTINCT', () => {
613
+ recordInbound({ chat_id: '-100', thread_id: 5, message_id: 9, user: 'a', user_id: '1', ts: 100, text: 'topic5' })
614
+ recordInbound({ chat_id: '-100', thread_id: 6, message_id: 9, user: 'a', user_id: '1', ts: 100, text: 'topic6' })
615
+ expect(query({ chat_id: '-100' })).toHaveLength(2)
616
+ expect(query({ chat_id: '-100', thread_id: 5 }).map(r => r.text)).toEqual(['topic5'])
617
+ expect(query({ chat_id: '-100', thread_id: 6 }).map(r => r.text)).toEqual(['topic6'])
618
+ })
619
+
620
+ it('migration de-dupes pre-existing null-thread duplicates from a legacy DB', async () => {
621
+ // Simulate a DB written by the pre-fix build: insert duplicate null-thread
622
+ // rows DIRECTLY, bypassing the (now-fixed) upsert, to reproduce the exact
623
+ // corruption the finding describes. Then re-init to fire the migration.
624
+ const { Database } = await import('bun:sqlite')
625
+ const dbPath = join(stateDir, 'history.db')
626
+ const now = Math.floor(Date.now() / 1000) // recent ts so the retention sweep keeps them
627
+ _resetForTests()
628
+ const raw = new Database(dbPath)
629
+ // Drop the logical-key index so we're back to the legacy, dupe-permitting
630
+ // schema, then append two rows sharing the same (chat, null-thread, msg).
631
+ raw.exec('DROP INDEX IF EXISTS idx_messages_logical_key')
632
+ const insert = raw.prepare(
633
+ `INSERT INTO messages (chat_id, thread_id, message_id, role, user, user_id, ts, text, group_id)
634
+ VALUES (?, NULL, ?, 'user', 'a', '1', ?, ?, NULL)`,
635
+ )
636
+ insert.run('-100', 7, now, 'stale copy')
637
+ insert.run('-100', 7, now + 1, 'newest copy')
638
+ // A distinct topic row with the same message_id must survive the migration.
639
+ raw.prepare(
640
+ `INSERT INTO messages (chat_id, thread_id, message_id, role, user, user_id, ts, text, group_id)
641
+ VALUES (?, 5, ?, 'user', 'a', '1', ?, ?, NULL)`,
642
+ ).run('-100', 7, now, 'topic row')
643
+ raw.close()
644
+
645
+ // Re-open through initHistory → runs the H5 migration (dedupe + index).
646
+ initHistory(stateDir, 30)
647
+ const general = query({ chat_id: '-100', thread_id: null })
648
+ expect(general).toHaveLength(1)
649
+ expect(general[0]?.text).toBe('newest copy') // kept the newest (highest ts/rowid)
650
+ // The topic row with the same message_id is untouched.
651
+ expect(query({ chat_id: '-100', thread_id: 5 }).map(r => r.text)).toEqual(['topic row'])
652
+ expect(query({ chat_id: '-100' })).toHaveLength(2)
653
+
654
+ // And the upsert is idempotent going forward.
655
+ recordInbound({ chat_id: '-100', thread_id: null, message_id: 7, user: 'a', user_id: '1', ts: now + 2, text: 'replay' })
656
+ expect(query({ chat_id: '-100', thread_id: null })).toHaveLength(1)
657
+ })
658
+ })
659
+
545
660
  describe('secret redaction at persistence (both directions)', () => {
546
661
  beforeEach(() => initHistory(stateDir, 30))
547
662
 
@@ -1,17 +1,231 @@
1
+ /**
2
+ * Idle auto-clear — what "idle" MEANS.
3
+ *
4
+ * On 2026-07-11 `overlord` worked for three hours straight (sub-agents, ~25 PRs
5
+ * merged) and was `/clear`ed 12 s after the window elapsed — three hours of
6
+ * session context destroyed. Root cause: the idle window was measured from the
7
+ * last turn START, and nothing in a turn (tool calls, sub-agent work, the turn
8
+ * ENDING) counted as activity. The invariant was inverted: the longer and more
9
+ * productive the work, the more certain the wipe.
10
+ *
11
+ * These tests assert the OUTCOME: an agent that is doing things is not idle,
12
+ * and an agent that is doing nothing still gets cleared.
13
+ *
14
+ * Deterministic virtual clock only (`T + n * H`) — no fake timers, no
15
+ * `Date.now()`. Everything schedulable takes the clock as an argument, so the
16
+ * whole 3h window is exercised in microseconds and the suite stays portable if
17
+ * this file is ever moved to the bun runner. (It runs under vitest today: it is
18
+ * not in `package.json`'s `test:bun` list and not excluded in
19
+ * `vitest.config.ts`.)
20
+ */
21
+
1
22
  import { describe, it, expect } from 'vitest'
2
23
  import {
3
24
  decideIdleClear,
25
+ classifyIdleEvent,
4
26
  idleDurationToMs,
5
27
  DEFAULT_IDLE_CLEAR_MS,
6
28
  type IdleClearState,
7
29
  } from '../gateway/idle-clear.js'
8
30
 
9
31
  const H = 3_600_000
32
+ const M = 60_000
33
+ /** Gateway's IDLE_CLEAR_CHECK_MS default. */
34
+ const TICK = 60_000
35
+
10
36
  function state(p: Partial<IdleClearState>): IdleClearState {
11
- return { lastActivityAt: 0, idleClearMs: 3 * H, alreadyCleared: false, turnInFlight: false, ...p }
37
+ return {
38
+ lastActivityAt: 0,
39
+ lastTurnEndedAt: null,
40
+ idleClearMs: 3 * H,
41
+ alreadyCleared: false,
42
+ turnInFlight: false,
43
+ ...p,
44
+ }
45
+ }
46
+
47
+ /**
48
+ * A minimal model of the gateway's idle bookkeeping, driven by a virtual clock.
49
+ * Mirrors gateway.ts: `markIdleActivity()` on inbound / cron / ANY session
50
+ * event, `markIdleTurnEnd()` on a turn ending, `decideIdleClear()` on each
51
+ * IDLE_CLEAR_CHECK_MS tick, `alreadyCleared` latched on fire.
52
+ */
53
+ class IdleModel {
54
+ lastActivityAt: number
55
+ lastTurnEndedAt: number | null = null
56
+ alreadyCleared = false
57
+ turnInFlight = false
58
+ clears: number[] = []
59
+
60
+ constructor(
61
+ startedAt: number,
62
+ readonly idleClearMs = 3 * H,
63
+ ) {
64
+ this.lastActivityAt = startedAt
65
+ }
66
+
67
+ /** A claude session-stream event (the gateway's handleSessionEvent stamp). */
68
+ sessionEvent(kind: string, now: number, durationMs?: number): void {
69
+ const signal = classifyIdleEvent(kind, durationMs)
70
+ if (signal.activity) {
71
+ this.lastActivityAt = now
72
+ this.alreadyCleared = false
73
+ }
74
+ if (signal.turnEnded) this.lastTurnEndedAt = now
75
+ }
76
+
77
+ /** Inbound / cron fire. */
78
+ activity(now: number): void {
79
+ this.lastActivityAt = now
80
+ this.alreadyCleared = false
81
+ }
82
+
83
+ tick(now: number): boolean {
84
+ const { clear } = decideIdleClear(
85
+ {
86
+ lastActivityAt: this.lastActivityAt,
87
+ lastTurnEndedAt: this.lastTurnEndedAt,
88
+ idleClearMs: this.idleClearMs,
89
+ alreadyCleared: this.alreadyCleared,
90
+ turnInFlight: this.turnInFlight,
91
+ },
92
+ now,
93
+ )
94
+ if (clear) {
95
+ this.alreadyCleared = true
96
+ this.clears.push(now)
97
+ }
98
+ return clear
99
+ }
100
+
101
+ /** Run the interval from `from` to `to` inclusive, one tick per TICK. */
102
+ ticksThrough(from: number, to: number): void {
103
+ for (let t = from; t <= to; t += TICK) this.tick(t)
104
+ }
12
105
  }
13
106
 
14
- describe('decideIdleClear', () => {
107
+ describe('idle-clear: a working agent is not idle', () => {
108
+ it('HEADLINE — a turn that runs LONGER than the idle window is not cleared when it ends', () => {
109
+ // The overlord incident, replayed. Window 3h. Turn starts at T, runs 3h08m
110
+ // of real work (sub-agents, tool calls), ends at T+3h08m.
111
+ const T = 100 * H
112
+ const m = new IdleModel(T)
113
+
114
+ m.turnInFlight = true
115
+ m.sessionEvent('enqueue', T) // turn start
116
+
117
+ // ... 3h08m of work. The gate suppresses the clear while in flight.
118
+ m.ticksThrough(T, T + 3 * H + 8 * M)
119
+ expect(m.clears).toEqual([])
120
+
121
+ // Turn ends. `turnInFlight` flips false — the moment of truth.
122
+ const end = T + 3 * H + 8 * M
123
+ m.sessionEvent('turn_end', end, 11_314_000)
124
+ m.turnInFlight = false
125
+
126
+ // The very next tick (12 s later in production) must NOT clear...
127
+ expect(m.tick(end + 12_000)).toBe(false)
128
+ // ...nor the next several ticks, nor anything short of a full fresh window.
129
+ m.ticksThrough(end, end + 3 * H - TICK)
130
+ expect(m.clears).toEqual([])
131
+ })
132
+
133
+ it('in-turn work keeps the timer warm even when the gateway sees no turn boundary', () => {
134
+ // What ACTUALLY happened to overlord: the last turn ended at 07:05 and the
135
+ // agent then worked for ~3h under stop-hook continuations that opened no new
136
+ // turn. `turnInFlight` was false the whole time, so only the per-event stamp
137
+ // can save it.
138
+ const T = 100 * H
139
+ const m = new IdleModel(T)
140
+ m.sessionEvent('turn_end', T, 34_596) // last turn boundary
141
+ m.turnInFlight = false
142
+
143
+ // 3h30m of real work with no turn boundary: tool calls + sub-agent activity
144
+ // every ~10 min, and a tick between each.
145
+ for (let t = T; t <= T + 3 * H + 30 * M; t += 10 * M) {
146
+ m.sessionEvent('tool_use', t)
147
+ m.sessionEvent('sub_agent_tool_use', t + 60_000)
148
+ m.ticksThrough(t, t + 10 * M - TICK)
149
+ }
150
+ expect(m.clears).toEqual([])
151
+ })
152
+
153
+ it('every genuine session event counts as activity (sub-agents included)', () => {
154
+ for (const kind of [
155
+ 'enqueue',
156
+ 'thinking',
157
+ 'model',
158
+ 'tool_use',
159
+ 'tool_result',
160
+ 'text',
161
+ 'sub_agent_started',
162
+ 'sub_agent_tool_use',
163
+ 'sub_agent_text',
164
+ 'sub_agent_turn_end',
165
+ ]) {
166
+ expect(classifyIdleEvent(kind).activity).toBe(true)
167
+ expect(classifyIdleEvent(kind).turnEnded).toBe(false)
168
+ }
169
+ // A real turn_end is activity AND a turn boundary.
170
+ expect(classifyIdleEvent('turn_end', 5_000)).toEqual({ activity: true, turnEnded: true })
171
+ // The gateway's own synthetic turn_end (orphaned-reply backstop) is not the
172
+ // model doing something — but it still ends the turn.
173
+ expect(classifyIdleEvent('turn_end', -1)).toEqual({ activity: false, turnEnded: true })
174
+ })
175
+
176
+ it('a background sub-agent still emitting events after the main turn ends holds off the clear', () => {
177
+ const T = 100 * H
178
+ const m = new IdleModel(T)
179
+ m.sessionEvent('turn_end', T, 30_000)
180
+ m.turnInFlight = false
181
+ // Worker grinds for 4h past the main turn end.
182
+ for (let t = T; t <= T + 4 * H; t += 5 * M) {
183
+ m.sessionEvent('sub_agent_tool_use', t)
184
+ m.ticksThrough(t, t + 5 * M - TICK)
185
+ }
186
+ expect(m.clears).toEqual([])
187
+ })
188
+ })
189
+
190
+ describe('idle-clear: a genuinely idle agent is still cleared', () => {
191
+ it('no turn, no inbound, no session event for a full window → cleared exactly once', () => {
192
+ const T = 100 * H
193
+ const m = new IdleModel(T)
194
+ m.ticksThrough(T, T + 6 * H)
195
+ expect(m.clears).toEqual([T + 3 * H])
196
+ })
197
+
198
+ it('cleared once per idle period, and re-arms on the next inbound', () => {
199
+ const T = 100 * H
200
+ const m = new IdleModel(T)
201
+ m.ticksThrough(T, T + 5 * H)
202
+ expect(m.clears).toEqual([T + 3 * H])
203
+
204
+ const inbound = T + 5 * H
205
+ m.activity(inbound) // operator comes back
206
+ m.ticksThrough(inbound, inbound + 3 * H - TICK)
207
+ expect(m.clears).toEqual([T + 3 * H]) // not yet
208
+ m.ticksThrough(inbound, inbound + 3 * H)
209
+ expect(m.clears).toEqual([T + 3 * H, inbound + 3 * H])
210
+ })
211
+
212
+ it('a turn that ends is cleared one full window after it ended (not before)', () => {
213
+ const T = 100 * H
214
+ const m = new IdleModel(T)
215
+ m.turnInFlight = true
216
+ m.sessionEvent('enqueue', T)
217
+ const end = T + 3 * H + 8 * M
218
+ m.sessionEvent('turn_end', end, 11_314_000)
219
+ m.turnInFlight = false
220
+
221
+ m.ticksThrough(end, end + 3 * H - TICK)
222
+ expect(m.clears).toEqual([]) // still warm
223
+ m.ticksThrough(end, end + 3 * H + TICK)
224
+ expect(m.clears).toEqual([end + 3 * H]) // now genuinely idle → cleared
225
+ })
226
+ })
227
+
228
+ describe('decideIdleClear (pure gate)', () => {
15
229
  it('fires once the idle window has elapsed', () => {
16
230
  expect(decideIdleClear(state({ lastActivityAt: 0 }), 3 * H).clear).toBe(true)
17
231
  expect(decideIdleClear(state({ lastActivityAt: 0 }), 3 * H + 1).clear).toBe(true)
@@ -32,10 +246,26 @@ describe('decideIdleClear', () => {
32
246
  it('is disabled when idleClearMs <= 0', () => {
33
247
  expect(decideIdleClear(state({ lastActivityAt: 0, idleClearMs: 0 }), 10 * H).clear).toBe(false)
34
248
  expect(decideIdleClear(state({ lastActivityAt: 0, idleClearMs: -1 }), 10 * H).clear).toBe(false)
249
+ // ...even with a fresh turn end in play.
250
+ expect(
251
+ decideIdleClear(state({ lastActivityAt: 0, lastTurnEndedAt: 9 * H, idleClearMs: 0 }), 10 * H).clear,
252
+ ).toBe(false)
253
+ })
254
+
255
+ it('measures the window from max(lastActivityAt, lastTurnEndedAt)', () => {
256
+ // Turn started at 0 (lastActivityAt), ended at 5h. At 5h the window has long
257
+ // "elapsed" against the START — it must not be treated as idle.
258
+ const justEnded = state({ lastActivityAt: 0, lastTurnEndedAt: 5 * H })
259
+ expect(decideIdleClear(justEnded, 5 * H).clear).toBe(false)
260
+ expect(decideIdleClear(justEnded, 8 * H - 1).clear).toBe(false)
261
+ expect(decideIdleClear(justEnded, 8 * H).clear).toBe(true) // a full window after the END
262
+ // Symmetric: activity newer than the turn end wins.
263
+ const activeSince = state({ lastActivityAt: 6 * H, lastTurnEndedAt: 5 * H })
264
+ expect(decideIdleClear(activeSince, 8 * H).clear).toBe(false)
265
+ expect(decideIdleClear(activeSince, 9 * H).clear).toBe(true)
35
266
  })
36
267
 
37
268
  it('re-arms after activity (fresh lastActivityAt + alreadyCleared=false → waits again)', () => {
38
- // Simulated: after a clear, activity resets lastActivityAt to `now` and the flag.
39
269
  const now = 100 * H
40
270
  const reArmed = state({ lastActivityAt: now, alreadyCleared: false })
41
271
  expect(decideIdleClear(reArmed, now + 3 * H - 1).clear).toBe(false) // not yet
@@ -0,0 +1,93 @@
1
+ /**
2
+ * #2094 finding 4 — bridge-side messageId dedup.
3
+ *
4
+ * Outcome contract: a genuine user message re-delivered by the stranded-
5
+ * message sweep within one turn is forwarded to claude exactly ONCE; after
6
+ * the turn-complete clear (a successful reply for that chat) forwarding
7
+ * resumes. Synthetic / button / structured-event inbounds are never deduped,
8
+ * and per-chat memory is capped.
9
+ */
10
+ import { describe, it, expect } from 'vitest'
11
+ import {
12
+ InboundDedup,
13
+ shouldDedupInbound,
14
+ dedupChatKey,
15
+ INBOUND_DEDUP_MAX_PER_CHAT,
16
+ type DedupInboundLike,
17
+ } from '../bridge/inbound-dedup.js'
18
+
19
+ function userMsg(messageId: number, extra: Record<string, string> = {}): DedupInboundLike {
20
+ return { messageId, meta: { chat_id: '12345', message_id: String(messageId), ...extra } }
21
+ }
22
+
23
+ describe('shouldDedupInbound gate', () => {
24
+ it('dedups a plain user message', () => {
25
+ expect(shouldDedupInbound(userMsg(500))).toBe(true)
26
+ })
27
+ it('never dedups synthetic (meta.source), button, structured, or reaction inbounds', () => {
28
+ expect(shouldDedupInbound(userMsg(500, { source: 'cron' }))).toBe(false)
29
+ expect(shouldDedupInbound(userMsg(500, { button_callback: 'true' }))).toBe(false)
30
+ expect(shouldDedupInbound(userMsg(500, { kind: 'checklist_task_changed' }))).toBe(false)
31
+ expect(shouldDedupInbound(userMsg(500, { event: 'reaction' }))).toBe(false)
32
+ })
33
+ it('never dedups a non-positive messageId (no real Telegram id)', () => {
34
+ expect(shouldDedupInbound(userMsg(0))).toBe(false)
35
+ expect(shouldDedupInbound({ messageId: -1, meta: {} })).toBe(false)
36
+ })
37
+ })
38
+
39
+ describe('InboundDedup checkAndRecord + clearChat', () => {
40
+ it('processes a duplicate messageId within one turn exactly once', () => {
41
+ const d = new InboundDedup()
42
+ const key = dedupChatKey('12345')
43
+ expect(d.checkAndRecord(key, 500)).toBe('fresh')
44
+ // Sweep re-delivers the SAME messageId before the turn completes.
45
+ expect(d.checkAndRecord(key, 500)).toBe('duplicate')
46
+ expect(d.checkAndRecord(key, 500)).toBe('duplicate')
47
+ // A different message in the same turn is still forwarded.
48
+ expect(d.checkAndRecord(key, 501)).toBe('fresh')
49
+ })
50
+
51
+ it('resumes processing the same messageId after the turn-complete clear', () => {
52
+ const d = new InboundDedup()
53
+ const key = dedupChatKey('12345')
54
+ expect(d.checkAndRecord(key, 500)).toBe('fresh')
55
+ expect(d.checkAndRecord(key, 500)).toBe('duplicate')
56
+ // reply fired for the chat → turn-complete reset.
57
+ d.clearChat('12345')
58
+ expect(d.size(key)).toBe(0)
59
+ // A genuinely new turn may re-see the id (fresh again).
60
+ expect(d.checkAndRecord(key, 500)).toBe('fresh')
61
+ })
62
+
63
+ it('clearChat is thread-agnostic — clears every thread key for the chat', () => {
64
+ const d = new InboundDedup()
65
+ const dm = dedupChatKey('12345')
66
+ const topic = dedupChatKey('12345', 42)
67
+ d.checkAndRecord(dm, 500)
68
+ d.checkAndRecord(topic, 700)
69
+ d.checkAndRecord(dedupChatKey('999', 1), 800) // unrelated chat, untouched
70
+ d.clearChat('12345')
71
+ expect(d.size(dm)).toBe(0)
72
+ expect(d.size(topic)).toBe(0)
73
+ expect(d.size(dedupChatKey('999', 1))).toBe(1)
74
+ })
75
+
76
+ it('caps per-chat memory (FIFO-evicts the oldest id)', () => {
77
+ const d = new InboundDedup(3)
78
+ const key = dedupChatKey('12345')
79
+ d.checkAndRecord(key, 1)
80
+ d.checkAndRecord(key, 2)
81
+ d.checkAndRecord(key, 3)
82
+ d.checkAndRecord(key, 4) // evicts id 1
83
+ expect(d.size(key)).toBe(3)
84
+ // id 1 was evicted → treated as fresh again (bounded memory, accepted).
85
+ expect(d.checkAndRecord(key, 1)).toBe('fresh')
86
+ // ids still tracked are duplicates.
87
+ expect(d.checkAndRecord(key, 4)).toBe('duplicate')
88
+ })
89
+
90
+ it('default cap is a sane bound', () => {
91
+ expect(INBOUND_DEDUP_MAX_PER_CHAT).toBeGreaterThanOrEqual(128)
92
+ })
93
+ })
@@ -206,7 +206,11 @@ describe('inbound message-type helpers: handleAckOnly + handleRefusal', () => {
206
206
  const fnStart = SRC.indexOf('async function handleAckOnly(')
207
207
  const fnSlice = SRC.slice(fnStart, fnStart + 2000)
208
208
  expect(fnSlice).toContain('gate(ctx)')
209
- expect(fnSlice).toContain('setMessageReaction')
209
+ // #3155: the reaction now routes through the gated `sendReaction` helper
210
+ // (send gate + flood breaker, cosmetic) instead of a raw
211
+ // `bot.api.setMessageReaction`. The invariant is unchanged: it gates, THEN
212
+ // reacts.
213
+ expect(fnSlice).toContain('sendReaction(')
210
214
  })
211
215
 
212
216
  it('handleAckOnly drops non-allowlisted senders silently', () => {