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
@@ -0,0 +1,232 @@
1
+ /**
2
+ * queued-card-store.test.ts — outcome-based tests for the queued-status /
3
+ * busy-ack card restart-survivability fix (#3002).
4
+ *
5
+ * PRE-FIX PROOF: before this change, no persistence existed for the
6
+ * `queuedStatusMsgIds` Map at all — `../gateway/queued-card-store.js` did not
7
+ * exist, so `import` itself failed at collection time and every test below
8
+ * failed on today's (pre-fix) base — there was no seam to test.
9
+ *
10
+ * This file exercises the REAL exported store + boot-reaper functions (not a
11
+ * structural grep), mirroring `activity-card-store.test.ts`'s in-memory fs seam
12
+ * convention, proving the exact outcome contract:
13
+ * (a) card post persists a record;
14
+ * (b) in-process reap clears it;
15
+ * (c) a boot reaper against a leftover record performs EXACTLY ONE Telegram
16
+ * delete and empties the store;
17
+ * (d) a second boot performs ZERO deletes;
18
+ * (e) a corrupt state file never crashes boot and is dropped without a delete;
19
+ * (f) a delete failure never crashes the reaper and still clears the row.
20
+ */
21
+ import { describe, it, expect } from 'vitest'
22
+ import {
23
+ loadQueuedCards,
24
+ persistQueuedCards,
25
+ writeQueuedCardRecord,
26
+ clearQueuedCardRecord,
27
+ runQueuedCardBootReaper,
28
+ type QueuedCardRecord,
29
+ type QueuedCardStoreFsSeam,
30
+ } from '../gateway/queued-card-store.js'
31
+
32
+ /** In-memory fs seam with an atomic rename — same convention as
33
+ * activity-card-store.test.ts's memFs, so the tmp→rename crash-safety
34
+ * contract is exercised without touching real disk. */
35
+ function memFs(seed: Record<string, string> = {}) {
36
+ const files = new Map<string, string>(Object.entries(seed))
37
+ const calls: string[] = []
38
+ const fs: QueuedCardStoreFsSeam = {
39
+ readFileSync: (p) => {
40
+ if (!files.has(p)) throw new Error(`ENOENT ${p}`)
41
+ return files.get(p)!
42
+ },
43
+ writeFileSync: (p, d) => {
44
+ calls.push(`write:${p}`)
45
+ files.set(p, d)
46
+ },
47
+ renameSync: (a, b) => {
48
+ calls.push(`rename:${a}->${b}`)
49
+ if (!files.has(a)) throw new Error(`ENOENT ${a}`)
50
+ files.set(b, files.get(a)!)
51
+ files.delete(a)
52
+ },
53
+ existsSync: (p) => files.has(p),
54
+ }
55
+ return { fs, files, calls }
56
+ }
57
+
58
+ const PATH = '/state/agent/telegram/queued-cards-pending.json'
59
+
60
+ function card(over: Partial<QueuedCardRecord> = {}): QueuedCardRecord {
61
+ return {
62
+ key: '-100123:42',
63
+ chatId: '-100123',
64
+ threadId: 42,
65
+ messageId: 715,
66
+ ...over,
67
+ }
68
+ }
69
+
70
+ describe('queued-card-store — persistence primitives', () => {
71
+ it('loads [] when no file exists (fresh boot)', () => {
72
+ const { fs } = memFs()
73
+ expect(loadQueuedCards(PATH, fs)).toEqual([])
74
+ })
75
+
76
+ it('(a) card post → state persisted (round-trips a written record)', () => {
77
+ const { fs } = memFs()
78
+ const record = card()
79
+ writeQueuedCardRecord(PATH, fs, record)
80
+ const loaded = loadQueuedCards(PATH, fs)
81
+ expect(loaded).toHaveLength(1)
82
+ expect(loaded[0]).toEqual(record)
83
+ })
84
+
85
+ it('round-trips a DM card (threadId null)', () => {
86
+ const { fs } = memFs()
87
+ const record = card({ key: '-100123:', threadId: null })
88
+ writeQueuedCardRecord(PATH, fs, record)
89
+ expect(loadQueuedCards(PATH, fs)[0]).toEqual(record)
90
+ })
91
+
92
+ it('a fresh post for the same topic replaces (upserts) the stale row, not appends', () => {
93
+ const { fs } = memFs()
94
+ writeQueuedCardRecord(PATH, fs, card({ messageId: 715 }))
95
+ writeQueuedCardRecord(PATH, fs, card({ messageId: 900 }))
96
+ const loaded = loadQueuedCards(PATH, fs)
97
+ expect(loaded).toHaveLength(1)
98
+ expect(loaded[0]!.messageId).toBe(900)
99
+ })
100
+
101
+ it('keeps rows for distinct keys (different topics) side by side', () => {
102
+ const { fs } = memFs()
103
+ writeQueuedCardRecord(PATH, fs, card({ key: '-100123:42', messageId: 715 }))
104
+ writeQueuedCardRecord(PATH, fs, card({ key: '-100123:43', messageId: 716 }))
105
+ expect(loadQueuedCards(PATH, fs)).toHaveLength(2)
106
+ })
107
+
108
+ it('(b) in-process reap → state cleared', () => {
109
+ const { fs } = memFs()
110
+ writeQueuedCardRecord(PATH, fs, card())
111
+ expect(loadQueuedCards(PATH, fs)).toHaveLength(1)
112
+ clearQueuedCardRecord(PATH, fs, '-100123:42')
113
+ expect(loadQueuedCards(PATH, fs)).toEqual([])
114
+ })
115
+
116
+ it('clearing an absent/mismatched key is a no-op (never throws)', () => {
117
+ const { fs } = memFs()
118
+ writeQueuedCardRecord(PATH, fs, card({ key: '-100123:99' }))
119
+ clearQueuedCardRecord(PATH, fs, '-100123:42')
120
+ expect(loadQueuedCards(PATH, fs)).toHaveLength(1)
121
+ })
122
+
123
+ it('reap-race guard: clear scoped to messageId leaves a fresh live card intact', () => {
124
+ const { fs } = memFs()
125
+ // A live turn posted a NEW card (msg 900) under the same key while a stale
126
+ // reap tries to clear the OLD one (msg 715) — the new row must survive.
127
+ writeQueuedCardRecord(PATH, fs, card({ messageId: 900 }))
128
+ clearQueuedCardRecord(PATH, fs, '-100123:42', 715)
129
+ const loaded = loadQueuedCards(PATH, fs)
130
+ expect(loaded).toHaveLength(1)
131
+ expect(loaded[0]!.messageId).toBe(900)
132
+ })
133
+
134
+ it('writes via tmp + atomic rename (crash-safe)', () => {
135
+ const { fs, calls } = memFs()
136
+ persistQueuedCards(PATH, fs, [card()])
137
+ expect(calls).toEqual([`write:${PATH}.tmp`, `rename:${PATH}.tmp->${PATH}`])
138
+ })
139
+
140
+ it('(e) corrupt state file: boot survives, loads as [] (fail-open, never throws)', () => {
141
+ const { fs } = memFs({ [PATH]: '{not json' })
142
+ expect(() => loadQueuedCards(PATH, fs)).not.toThrow()
143
+ expect(loadQueuedCards(PATH, fs)).toEqual([])
144
+ })
145
+
146
+ it('fail-open: wrong envelope version / malformed shape loads as []', () => {
147
+ const { fs: f1 } = memFs({ [PATH]: JSON.stringify({ v: 2, cards: [card()] }) })
148
+ expect(loadQueuedCards(PATH, f1)).toEqual([])
149
+ const { fs: f2 } = memFs({ [PATH]: JSON.stringify({ v: 1, cards: 'nope' }) })
150
+ expect(loadQueuedCards(PATH, f2)).toEqual([])
151
+ const { fs: f3 } = memFs({ [PATH]: JSON.stringify({ v: 1, cards: [{ key: '' }] }) })
152
+ expect(loadQueuedCards(PATH, f3)).toEqual([])
153
+ })
154
+ })
155
+
156
+ describe('queued-card-store — boot reaper (reap-on-boot, delete is the terminal)', () => {
157
+ it('(c) deletes each persisted card EXACTLY ONCE and empties the store', async () => {
158
+ const { fs } = memFs()
159
+ writeQueuedCardRecord(PATH, fs, card({ key: '-100123:42', messageId: 715 }))
160
+ writeQueuedCardRecord(PATH, fs, card({ key: '-100123:43', messageId: 716 }))
161
+ const deletes: number[] = []
162
+ const res = await runQueuedCardBootReaper({
163
+ path: PATH,
164
+ fs,
165
+ deleteCard: async (r) => {
166
+ deletes.push(r.messageId)
167
+ return { message_id: r.messageId }
168
+ },
169
+ })
170
+ expect(res).toEqual({ deleted: 2, total: 2 })
171
+ expect(deletes.sort()).toEqual([715, 716])
172
+ // Store is emptied — the stale cards can never be re-reaped.
173
+ expect(loadQueuedCards(PATH, fs)).toEqual([])
174
+ })
175
+
176
+ it('(d) a SECOND boot performs ZERO deletes (store already drained)', async () => {
177
+ const { fs } = memFs()
178
+ writeQueuedCardRecord(PATH, fs, card())
179
+ await runQueuedCardBootReaper({ path: PATH, fs, deleteCard: async () => ({}) })
180
+ const deletes: number[] = []
181
+ const res = await runQueuedCardBootReaper({
182
+ path: PATH,
183
+ fs,
184
+ deleteCard: async (r) => {
185
+ deletes.push(r.messageId)
186
+ return {}
187
+ },
188
+ })
189
+ expect(res).toEqual({ deleted: 0, total: 0 })
190
+ expect(deletes).toEqual([])
191
+ })
192
+
193
+ it('deletes the record BEFORE the delete call (idempotency guard — no double-delete on crash)', async () => {
194
+ const { fs } = memFs()
195
+ writeQueuedCardRecord(PATH, fs, card({ messageId: 715 }))
196
+ let storeAtDeleteTime: QueuedCardRecord[] | null = null
197
+ await runQueuedCardBootReaper({
198
+ path: PATH,
199
+ fs,
200
+ deleteCard: async () => {
201
+ // At the moment the Telegram delete runs, the row is already gone from
202
+ // disk — a crash here or a concurrent second boot cannot re-issue it.
203
+ storeAtDeleteTime = loadQueuedCards(PATH, fs)
204
+ return {}
205
+ },
206
+ })
207
+ expect(storeAtDeleteTime).toEqual([])
208
+ })
209
+
210
+ it('(f) a delete FAILURE never crashes the reaper and still clears the row (best-effort, tolerate "not found")', async () => {
211
+ const { fs } = memFs()
212
+ writeQueuedCardRecord(PATH, fs, card({ messageId: 715 }))
213
+ const res = await runQueuedCardBootReaper({
214
+ path: PATH,
215
+ fs,
216
+ deleteCard: async () => {
217
+ throw new Error('Bad Request: message to delete not found')
218
+ },
219
+ log: () => {},
220
+ })
221
+ // Not counted as deleted, but the row is dropped (delete-first) so it can't
222
+ // wedge every future boot.
223
+ expect(res).toEqual({ deleted: 0, total: 1 })
224
+ expect(loadQueuedCards(PATH, fs)).toEqual([])
225
+ })
226
+
227
+ it('empty store: reaper is a no-op', async () => {
228
+ const { fs } = memFs()
229
+ const res = await runQueuedCardBootReaper({ path: PATH, fs, deleteCard: async () => ({}) })
230
+ expect(res).toEqual({ deleted: 0, total: 0 })
231
+ })
232
+ })
@@ -0,0 +1,100 @@
1
+ /**
2
+ * #2094 finding 3 — reaction-flush uses the #1556 turn gate.
3
+ *
4
+ * THE BUG: flushReactionBatch delivered its synthetic reaction inbound with a
5
+ * bare `ipcServer.sendToAgent` + busy-mark, buffering ONLY on a bridge-offline
6
+ * miss. It never ran the #1556 decideInboundDelivery gate. So a reaction that
7
+ * landed WHILE a turn was in flight fired the MCP channel notification mid-turn
8
+ * — the unmodified CLI typed it into its TUI composer and the auto-submit raced
9
+ * turn-completion, re-creating the composer wedge.
10
+ *
11
+ * THE FIX: route the flush through deliverResumeSyntheticOrBuffer, the shared
12
+ * helper that consults the gate (mid-turn → buffer-until-idle, flushed by the
13
+ * turn-complete hook + idle-drain timer; idle → deliver now; buffer only on a
14
+ * genuine miss).
15
+ *
16
+ * gateway.ts is a large side-effecting module that can't be imported into a
17
+ * unit test, so this file pins (a) the gate decision for a reaction inbound's
18
+ * shape via the pure gate + a modeled buffer/drain, and (b) that
19
+ * flushReactionBatch wires the turn-safe helper structurally — the same
20
+ * strategy button-tap-turn-gated.test.ts uses.
21
+ */
22
+ import { describe, it, expect } from 'vitest'
23
+ import { readFileSync } from 'node:fs'
24
+ import { resolve } from 'node:path'
25
+ import { decideInboundDelivery } from '../gateway/inbound-delivery-gate.js'
26
+ import { planBufferedRedelivery } from '../gateway/pending-inbound-buffer.js'
27
+ import type { InboundMessage } from '../gateway/ipc-protocol.js'
28
+
29
+ const gatewaySrc = readFileSync(
30
+ resolve(__dirname, '..', 'gateway', 'gateway.ts'),
31
+ 'utf-8',
32
+ )
33
+
34
+ // A reaction inbound as flushReactionBatch builds it: synthetic messageId (ts),
35
+ // never steering, never an interrupt — the exact inputs the shared helper
36
+ // (deliverResumeSyntheticOrBuffer) passes to the gate.
37
+ function reactionInbound(ts: number): InboundMessage {
38
+ return {
39
+ type: 'inbound',
40
+ chatId: 'c1',
41
+ messageId: ts,
42
+ user: 'u',
43
+ userId: 42,
44
+ ts,
45
+ text: `[user reacted 👍 to your message]`,
46
+ meta: { chat_id: 'c1', count: '1' },
47
+ }
48
+ }
49
+
50
+ describe('reaction flush uses the turn-gate (mid-turn → buffer)', () => {
51
+ it('buffers a reaction that lands mid-turn, delivers when idle, never mid-turn', () => {
52
+ const shape = { isSteering: false as const, isInterrupt: false as const }
53
+ // Reaction lands WHILE a turn is in flight → HELD, not sent (the wedge the
54
+ // raw sendToAgent used to cause).
55
+ expect(decideInboundDelivery({ ...shape, turnInFlight: true })).toBe(
56
+ 'buffer-until-idle',
57
+ )
58
+ // Reaction lands at an idle prompt → delivered immediately.
59
+ expect(decideInboundDelivery({ ...shape, turnInFlight: false })).toBe(
60
+ 'deliver',
61
+ )
62
+ })
63
+
64
+ it('a mid-turn-buffered reaction drains as a fresh turn after turn end', () => {
65
+ // Model the buffer→drain: the reaction was held mid-turn; on turn-complete
66
+ // the buffer is planned for redelivery and the reaction lands cleanly as
67
+ // its own turn (never merged away / dropped).
68
+ const plan = planBufferedRedelivery([reactionInbound(1000)])
69
+ expect(plan).toHaveLength(1)
70
+ expect(plan[0]!.merged.text).toBe('[user reacted 👍 to your message]')
71
+ })
72
+ })
73
+
74
+ describe('flushReactionBatch wires the turn-safe helper', () => {
75
+ const start = gatewaySrc.indexOf('function flushReactionBatch(batch: ReactionBatch)')
76
+ // Bound the body by the next top-level declaration (the message_reaction
77
+ // handler block).
78
+ const body = gatewaySrc.slice(
79
+ start,
80
+ gatewaySrc.indexOf("bot.on('message_reaction'", start),
81
+ )
82
+
83
+ it('exists', () => {
84
+ expect(start, 'flushReactionBatch missing').toBeGreaterThan(0)
85
+ })
86
+
87
+ it('delivers via deliverResumeSyntheticOrBuffer, not a raw ungated sendToAgent', () => {
88
+ expect(body).toMatch(/deliverResumeSyntheticOrBuffer\(agentName, inbound\)/)
89
+ // The regressed path — a raw ungated send of the reaction inbound + an
90
+ // out-of-helper busy-mark — is gone.
91
+ expect(body).not.toMatch(/ipcServer\.sendToAgent\(agentName, inbound\)/)
92
+ expect(body).not.toMatch(/markClaudeBusyForInbound\(inbound\)/)
93
+ })
94
+
95
+ it('no longer open-codes the buffer-on-failure push (the helper owns it)', () => {
96
+ // The #1150 buffer-on-failure guarantee now lives inside
97
+ // deliverResumeSyntheticOrBuffer, so the flush body must not push directly.
98
+ expect(body).not.toMatch(/pendingInboundBuffer\.push\(/)
99
+ })
100
+ })
@@ -0,0 +1,173 @@
1
+ /**
2
+ * #3155 — `setMessageReaction` must route through the send gate + flood breaker.
3
+ *
4
+ * Before this change ~13 raw `bot.api.setMessageReaction(...)` /
5
+ * `lockedBot.api.setMessageReaction(...)` call sites (early-ack 👀, status
6
+ * reactions, stop/interrupt ⚡, permission verdict ✅/❌, refusal 🚫, ack-only,
7
+ * the MCP `react` tool, the crash-recovery sweeps, the auth-code 🔑 redact)
8
+ * fired reactions OUTSIDE both:
9
+ *
10
+ * (a) the send gate — so reaction churn was never paced/shed under flood
11
+ * pressure even with the gate ON; and
12
+ * (b) the flood circuit breaker — a 429 from a reaction never reached the
13
+ * retry module's `onFloodWait` hook, so the ban went unrecorded.
14
+ *
15
+ * Reactions were therefore a residual flood vector even when the gate is on.
16
+ * The gateway now funnels every reaction through ONE seam,
17
+ * `robustApiCall(() => lockedBot.api.setMessageReaction(...), { priorityClass:
18
+ * 'cosmetic', verb: 'set-message-reaction' })`.
19
+ *
20
+ * Two levels of coverage:
21
+ * 1. BEHAVIOURAL — compose the real `send-gate` + `retry-api-call` modules the
22
+ * same way `gateway.ts` wires `robustApiCall`, and prove a cosmetic
23
+ * reaction is (a) shed when a flood window is open and (b) visible to
24
+ * `onFloodWait` on a 429.
25
+ * 2. LOAD-BEARING SOURCE SCAN — assert the gateway has exactly ONE runtime
26
+ * `setMessageReaction` call and that it is the cosmetic seam. This FAILS if
27
+ * any future change re-introduces a raw (ungated, breaker-blind) reaction.
28
+ */
29
+ import { describe, it, expect, vi } from 'vitest'
30
+ import { readFileSync } from 'node:fs'
31
+ import { fileURLToPath } from 'node:url'
32
+ import { createSendGate, type Clock } from '../send-gate.js'
33
+ import { createRetryApiCall } from '../retry-api-call.js'
34
+ import { errors } from './fake-bot-api.js'
35
+
36
+ const GATEWAY_SRC = fileURLToPath(new URL('../gateway/gateway.ts', import.meta.url))
37
+
38
+ /** Fixed-time clock; `sleep` is a no-op so nothing actually waits under test. */
39
+ function fixedClock(now: number): Clock {
40
+ return { now: () => now, sleep: () => Promise.resolve() }
41
+ }
42
+
43
+ /**
44
+ * Reconstruct the gateway's outbound wiring for reactions: `robustApiCall =
45
+ * gate(rawRetry)`, and a `sendReaction` closure that tags the call `cosmetic`
46
+ * exactly like `gatedSetMessageReaction` in gateway.ts.
47
+ */
48
+ function wireReactionPath(opts: {
49
+ clock: Clock
50
+ enabled: boolean
51
+ windowUntilTs?: number
52
+ onFloodWait?: (retryAfterSec: number) => void
53
+ }) {
54
+ const setMessageReaction = vi.fn(async () => true as const)
55
+ const sendGate = createSendGate({
56
+ enabled: opts.enabled,
57
+ clock: opts.clock,
58
+ ...(opts.windowUntilTs != null
59
+ ? { initialWindows: [{ scopeKey: 'global', untilTs: opts.windowUntilTs }] }
60
+ : {}),
61
+ })
62
+ const rawRetry = createRetryApiCall({
63
+ maxRetries: 2,
64
+ sleep: async () => {},
65
+ ...(opts.onFloodWait ? { onFloodWait: opts.onFloodWait } : {}),
66
+ })
67
+ const robustApiCall = <T>(
68
+ fn: () => Promise<T>,
69
+ o?: Parameters<typeof rawRetry<T>>[1],
70
+ ): Promise<T> => sendGate.gate(() => rawRetry(fn, o), o)
71
+
72
+ // Mirrors gateway.ts `gatedSetMessageReaction` / `sendReaction`.
73
+ const sendReaction = (chatId: string, messageId: number, emoji: string): Promise<unknown> =>
74
+ robustApiCall(() => setMessageReaction(chatId, messageId, [{ type: 'emoji', emoji }]), {
75
+ chat_id: chatId,
76
+ verb: 'set-message-reaction',
77
+ priorityClass: 'cosmetic',
78
+ })
79
+
80
+ return { sendReaction, setMessageReaction, sendGate }
81
+ }
82
+
83
+ describe('#3155 reactions route through the send gate (cosmetic)', () => {
84
+ it('SHEDS a reaction while a flood window is open (paced/shed by the gate)', async () => {
85
+ const now = 1_000_000
86
+ const { sendReaction, setMessageReaction, sendGate } = wireReactionPath({
87
+ clock: fixedClock(now),
88
+ enabled: true,
89
+ windowUntilTs: now + 60_000, // an open global ban window
90
+ })
91
+
92
+ const result = await sendReaction('chatA', 42, '👀')
93
+
94
+ // Cosmetic + a covering window ⇒ shed: the API is NEVER hit, the promise
95
+ // resolves undefined (fire-and-forget callers .catch nothing), and the gate
96
+ // counts the shed.
97
+ expect(setMessageReaction).not.toHaveBeenCalled()
98
+ expect(result).toBeUndefined()
99
+ expect(sendGate.stats().global.shed).toBe(1)
100
+ })
101
+
102
+ it('does NOT shed when the gate is OFF (proves the gate is what sheds)', async () => {
103
+ const now = 1_000_000
104
+ const { sendReaction, setMessageReaction } = wireReactionPath({
105
+ clock: fixedClock(now),
106
+ enabled: false, // gate disabled ⇒ pure passthrough
107
+ windowUntilTs: now + 60_000,
108
+ })
109
+
110
+ await sendReaction('chatA', 42, '👀')
111
+
112
+ // Flag OFF ⇒ the reaction still fires (the window only bites when enabled).
113
+ expect(setMessageReaction).toHaveBeenCalledTimes(1)
114
+ })
115
+
116
+ it('records a 429 on a reaction to the flood breaker via onFloodWait', async () => {
117
+ const now = 1_000_000
118
+ const onFloodWait = vi.fn()
119
+ const { sendReaction, setMessageReaction } = wireReactionPath({
120
+ clock: fixedClock(now),
121
+ enabled: true, // gate on, no open window ⇒ the cosmetic call is admitted
122
+ onFloodWait,
123
+ })
124
+ // The reaction send hits a Telegram 429 flood-wait.
125
+ setMessageReaction.mockRejectedValue(errors.floodWait(7, 'setMessageReaction'))
126
+
127
+ // Fire-and-forget semantics: callers swallow. The point is the SIDE EFFECT.
128
+ await sendReaction('chatA', 42, '🤝').catch(() => {})
129
+
130
+ // The reaction actually reached the API (was admitted, not shed)...
131
+ expect(setMessageReaction).toHaveBeenCalled()
132
+ // ...and its 429 was recorded by the breaker — the whole gap this closes.
133
+ expect(onFloodWait).toHaveBeenCalledWith(7)
134
+ })
135
+ })
136
+
137
+ describe('#3155 load-bearing: gateway.ts sends no reaction raw', () => {
138
+ const src = readFileSync(GATEWAY_SRC, 'utf-8')
139
+ const lines = src.split('\n')
140
+
141
+ /** Non-comment source lines that call `(bot|lockedBot).api.setMessageReaction`. */
142
+ const rawReactionLines = lines
143
+ .map((text, i) => ({ text, n: i + 1 }))
144
+ .filter(({ text }) => {
145
+ const t = text.trim()
146
+ if (t.startsWith('*') || t.startsWith('//') || t.startsWith('/*')) return false
147
+ return /\b(bot|lockedBot)\.api\.setMessageReaction\b/.test(text)
148
+ })
149
+
150
+ it('has EXACTLY ONE runtime setMessageReaction callsite (the shared seam)', () => {
151
+ // If this fails with >1, a raw reaction was re-introduced somewhere —
152
+ // ungated and invisible to the flood breaker. Route it through
153
+ // `sendReaction` / `gatedSetMessageReaction` instead.
154
+ expect(rawReactionLines).toHaveLength(1)
155
+ })
156
+
157
+ it('the sole seam is tagged cosmetic and routed through robustApiCall', () => {
158
+ const seamLine = rawReactionLines[0]!.n
159
+ // Look at the seam line + a small window for its opts object.
160
+ const window = lines.slice(seamLine - 2, seamLine + 6).join('\n')
161
+ expect(window).toContain('robustApiCall(')
162
+ expect(window).toContain("priorityClass: 'cosmetic'")
163
+ expect(window).toContain("verb: 'set-message-reaction'")
164
+ })
165
+
166
+ it('no reaction is fired as a raw fire-and-forget bot.api call', () => {
167
+ // Belt-and-braces: the old `bot.api.setMessageReaction(...).catch(() => {})`
168
+ // shape must be gone entirely.
169
+ expect(src).not.toMatch(/bot\.api\.setMessageReaction\([^)]*\)\s*\.catch/)
170
+ // And the fire-and-forget callers now go through the helper.
171
+ expect(src).toContain('void sendReaction(')
172
+ })
173
+ })
@@ -48,6 +48,65 @@ describe("render: inline palette", () => {
48
48
  "[label](https://example.com)",
49
49
  );
50
50
  });
51
+ it("escapes a `)` in a link href so the URL is not truncated (F3)", () => {
52
+ // An angle-bracket destination is the only way to smuggle an UNBALANCED
53
+ // `)` into an href via the parser (a bare `)` would otherwise close the
54
+ // destination). CommonMark strips the angle brackets → href holds the `)`.
55
+ const doc = parse("[wiki](<https://example.com/a)b>)");
56
+ const link = (doc.blocks[0] as any).children.find((c: any) => c.type === "link");
57
+ expect(link.href).toBe("https://example.com/a)b"); // parser captured the full URL
58
+
59
+ const out = render(doc);
60
+ // The `)` is backslash-escaped so it can no longer terminate the `(...)`
61
+ // destination early — a valid Bot API 10.1 link, not a broken one.
62
+ expect(out).toContain("a\\)b");
63
+ // And it round-trips: re-parsing recovers the SAME href, not a truncated
64
+ // `https://example.com/a` with stray `b)` prose spilled after it.
65
+ const reparsed = parse(out);
66
+ const reLink = (reparsed.blocks[0] as any).children.find((c: any) => c.type === "link");
67
+ expect(reLink.href).toBe("https://example.com/a)b");
68
+ });
69
+ it("round-trips a balanced-paren href (Wikipedia disambiguation) without leaking a backslash", () => {
70
+ // A bare destination with BALANCED parens is legal CommonMark; the parser
71
+ // captures the whole URL including the inner `(...)`. Escaping only `)`
72
+ // (the old fix) would unbalance the parens and leak a literal backslash
73
+ // into the decoded href. Escaping both parens keeps it balanced.
74
+ const src = "[w](https://en.wikipedia.org/wiki/Foo_(disambiguation))";
75
+ const doc = parse(src);
76
+ const link = (doc.blocks[0] as any).children.find((c: any) => c.type === "link");
77
+ expect(link.href).toBe("https://en.wikipedia.org/wiki/Foo_(disambiguation)");
78
+
79
+ const out = render(doc);
80
+ const reparsed = parse(out);
81
+ const reLink = (reparsed.blocks[0] as any).children.find((c: any) => c.type === "link");
82
+ // Re-parsed href is byte-for-byte the original — no truncation, no stray `\`.
83
+ expect(reLink.href).toBe("https://en.wikipedia.org/wiki/Foo_(disambiguation)");
84
+ expect(reLink.href).not.toContain("\\");
85
+ });
86
+ it("round-trips an href with multiple balanced paren groups", () => {
87
+ const src = "[m](https://example.com/a(b)c(d))";
88
+ const doc = parse(src);
89
+ const link = (doc.blocks[0] as any).children.find((c: any) => c.type === "link");
90
+ expect(link.href).toBe("https://example.com/a(b)c(d)");
91
+
92
+ const out = render(doc);
93
+ const reparsed = parse(out);
94
+ const reLink = (reparsed.blocks[0] as any).children.find((c: any) => c.type === "link");
95
+ expect(reLink.href).toBe("https://example.com/a(b)c(d)");
96
+ expect(reLink.href).not.toContain("\\");
97
+ });
98
+ it("round-trips an href with a lone unbalanced `)` without leaking a backslash", () => {
99
+ // Angle-bracket destination smuggles a lone `)` past the parser.
100
+ const doc = parse("[x](<https://example.com/a)b>)");
101
+ const link = (doc.blocks[0] as any).children.find((c: any) => c.type === "link");
102
+ expect(link.href).toBe("https://example.com/a)b");
103
+
104
+ const out = render(doc);
105
+ const reparsed = parse(out);
106
+ const reLink = (reparsed.blocks[0] as any).children.find((c: any) => c.type === "link");
107
+ expect(reLink.href).toBe("https://example.com/a)b");
108
+ expect(reLink.href).not.toContain("\\");
109
+ });
51
110
  it("underline", () => {
52
111
  expect(render(parse("__hi__"))).toBe("__hi__");
53
112
  });
@@ -226,6 +285,35 @@ describe("render: block palette", () => {
226
285
  const out = render(parse(md));
227
286
  expect(out).toContain("**bold** cell");
228
287
  });
288
+ it("neutralizes a `|` inside a code span in a table cell so the row survives (F4)", () => {
289
+ // Source pipe inside the code span is `\|`-escaped so the INPUT is a valid
290
+ // GFM table; the parser folds it to a code node whose text is `a|b`.
291
+ const md = ["| Col |", "| --- |", "| `a\\|b` |"].join("\n");
292
+ const doc = parse(md);
293
+ const srcCell = (doc.blocks[0] as TableNode).rows[0].cells[0].children.find(
294
+ (c: any) => c.type === "code",
295
+ ) as any;
296
+ expect(srcCell.text).toBe("a|b"); // parser captured the literal pipe
297
+
298
+ const out = render(doc);
299
+ // The `|` is re-escaped inside the code span so it can't be read as a
300
+ // column separator and tear the row — valid Bot API 10.1 GFM output.
301
+ expect(out).toContain("`a\\|b`");
302
+ // Every rendered table line stays a structurally-valid row (`|`-delimited,
303
+ // balanced) — a torn row would start with `|` but not end with one.
304
+ for (const line of out.split("\n")) {
305
+ if (line.trimStart().startsWith("|")) {
306
+ expect(line.trimEnd().endsWith("|")).toBe(true);
307
+ }
308
+ }
309
+ // And it round-trips: the re-parsed table still has ONE body cell whose
310
+ // code text is `a|b`, not a torn row with an unterminated code span.
311
+ const reparsed = parse(out);
312
+ const reRow = (reparsed.blocks[0] as TableNode).rows[0];
313
+ expect(reRow.cells).toHaveLength(1);
314
+ const reCell = reRow.cells[0].children.find((c: any) => c.type === "code") as any;
315
+ expect(reCell.text).toBe("a|b");
316
+ });
229
317
  });
230
318
 
231
319
  describe("render: full document", () => {