switchroom 0.7.15 → 0.10.0

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 (301) hide show
  1. package/README.md +51 -59
  2. package/bin/run-hook.sh +27 -11
  3. package/bin/timezone-hook.sh +9 -7
  4. package/dist/agent-scheduler/index.js +410 -133
  5. package/dist/auth-broker/index.js +13932 -0
  6. package/dist/cli/switchroom.js +26937 -5601
  7. package/dist/host-control/main.js +12702 -0
  8. package/dist/vault/approvals/kernel-server.js +467 -184
  9. package/dist/vault/broker/server.js +1430 -724
  10. package/examples/minimal.yaml +63 -0
  11. package/examples/personal-google-workspace-mcp/.env.example +34 -0
  12. package/examples/personal-google-workspace-mcp/README.md +194 -0
  13. package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
  14. package/examples/switchroom.yaml +220 -0
  15. package/package.json +7 -4
  16. package/profiles/_base/settings.json.hbs +20 -5
  17. package/profiles/_base/start.sh.hbs +16 -3
  18. package/profiles/_shared/agent-self-service.md.hbs +126 -0
  19. package/profiles/_shared/telegram-style.md.hbs +20 -90
  20. package/profiles/_shared/vault-protocol.md.hbs +68 -0
  21. package/profiles/default/CLAUDE.md +50 -96
  22. package/profiles/default/CLAUDE.md.hbs +36 -6
  23. package/profiles/default/workspace/SOUL.md.hbs +12 -5
  24. package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
  25. package/skills/buildkite-agent-runtime/SKILL.md +44 -11
  26. package/skills/buildkite-api/SKILL.md +31 -8
  27. package/skills/buildkite-cli/SKILL.md +27 -9
  28. package/skills/buildkite-migration/SKILL.md +22 -9
  29. package/skills/buildkite-pipelines/SKILL.md +26 -9
  30. package/skills/buildkite-secure-delivery/SKILL.md +23 -9
  31. package/skills/buildkite-test-engine/SKILL.md +25 -8
  32. package/skills/docx/SKILL.md +1 -1
  33. package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
  34. package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
  35. package/skills/file-bug/SKILL.md +34 -6
  36. package/skills/humanizer/SKILL.md +15 -0
  37. package/skills/humanizer-calibrate/SKILL.md +7 -1
  38. package/skills/mcp-builder/SKILL.md +1 -1
  39. package/skills/pdf/SKILL.md +1 -1
  40. package/skills/pptx/SKILL.md +1 -1
  41. package/skills/skill-creator/SKILL.md +21 -1
  42. package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  43. package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
  44. package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
  45. package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
  46. package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
  47. package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  48. package/skills/switchroom-cli/SKILL.md +63 -64
  49. package/skills/switchroom-health/SKILL.md +23 -10
  50. package/skills/switchroom-install/SKILL.md +3 -3
  51. package/skills/switchroom-manage/SKILL.md +26 -19
  52. package/skills/switchroom-runtime/SKILL.md +191 -0
  53. package/skills/switchroom-status/SKILL.md +27 -2
  54. package/skills/telegram-test-harness/SKILL.md +3 -0
  55. package/skills/token-helpers/SKILL.md +24 -1
  56. package/skills/webapp-testing/SKILL.md +31 -1
  57. package/skills/xlsx/SKILL.md +1 -1
  58. package/telegram-plugin/admin-commands/index.ts +7 -5
  59. package/telegram-plugin/analytics-posthog.ts +191 -0
  60. package/telegram-plugin/bridge/bridge.ts +69 -0
  61. package/telegram-plugin/bridge/ipc-client.ts +4 -1
  62. package/telegram-plugin/dist/bridge/bridge.js +194 -119
  63. package/telegram-plugin/dist/gateway/gateway.js +23611 -19671
  64. package/telegram-plugin/dist/server.js +245 -189
  65. package/telegram-plugin/first-paint.ts +3 -24
  66. package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
  67. package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
  68. package/telegram-plugin/gateway/auth-command.ts +794 -0
  69. package/telegram-plugin/gateway/auth-line.ts +123 -0
  70. package/telegram-plugin/gateway/boot-card.ts +169 -40
  71. package/telegram-plugin/gateway/boot-issue-cache.ts +308 -0
  72. package/telegram-plugin/gateway/boot-probes.ts +166 -123
  73. package/telegram-plugin/gateway/boot-reason.ts +41 -7
  74. package/telegram-plugin/gateway/boot-version.ts +66 -0
  75. package/telegram-plugin/gateway/gateway.ts +3499 -1885
  76. package/telegram-plugin/gateway/hostd-dispatch.ts +117 -0
  77. package/telegram-plugin/gateway/ipc-protocol.ts +18 -0
  78. package/telegram-plugin/gateway/pending-inbound-buffer.ts +106 -0
  79. package/telegram-plugin/gateway/quarantine.ts +69 -0
  80. package/telegram-plugin/gateway/quota-cache.ts +9 -4
  81. package/telegram-plugin/gateway/reaction-trigger.ts +401 -0
  82. package/telegram-plugin/gateway/recent-denials.test.ts +103 -0
  83. package/telegram-plugin/gateway/recent-denials.ts +77 -0
  84. package/telegram-plugin/gateway/startup-network-retry.ts +109 -31
  85. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +125 -0
  86. package/telegram-plugin/history.ts +91 -0
  87. package/telegram-plugin/hooks/hooks.json +10 -0
  88. package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +130 -0
  89. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +19 -2
  90. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +22 -2
  91. package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
  92. package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
  93. package/telegram-plugin/inbound-classifier.ts +50 -0
  94. package/telegram-plugin/inline-keyboard-callbacks.ts +136 -0
  95. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  96. package/telegram-plugin/package.json +4 -2
  97. package/telegram-plugin/permission-rule.ts +51 -0
  98. package/telegram-plugin/permission-title.ts +56 -0
  99. package/telegram-plugin/quota-check.ts +19 -41
  100. package/telegram-plugin/registry/reaper.ts +223 -0
  101. package/telegram-plugin/retry-api-call.ts +80 -0
  102. package/telegram-plugin/runtime-metrics.ts +177 -0
  103. package/telegram-plugin/scripts/build.mjs +0 -1
  104. package/telegram-plugin/secret-detect/index.ts +24 -0
  105. package/telegram-plugin/secret-detect/vault-error.test.ts +64 -12
  106. package/telegram-plugin/secret-detect/vault-error.ts +78 -11
  107. package/telegram-plugin/secret-detect/vault-write.ts +14 -2
  108. package/telegram-plugin/server.js +41795 -0
  109. package/telegram-plugin/session-tail.ts +6 -1
  110. package/telegram-plugin/shared/bot-runtime.ts +5 -4
  111. package/telegram-plugin/silence-poke.ts +420 -0
  112. package/telegram-plugin/silent-end.ts +174 -0
  113. package/telegram-plugin/stream-controller.ts +13 -0
  114. package/telegram-plugin/stream-reply-handler.ts +7 -0
  115. package/telegram-plugin/subagent-watcher.ts +213 -4
  116. package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
  117. package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
  118. package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
  119. package/telegram-plugin/tests/boot-card-issue-dedup.test.ts +247 -0
  120. package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +182 -0
  121. package/telegram-plugin/tests/boot-card-reason.test.ts +65 -2
  122. package/telegram-plugin/tests/boot-card-render.test.ts +146 -0
  123. package/telegram-plugin/tests/boot-card-silent-on-operator.test.ts +103 -0
  124. package/telegram-plugin/tests/boot-probes.test.ts +216 -10
  125. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  126. package/telegram-plugin/tests/finalize-callback.test.ts +190 -0
  127. package/telegram-plugin/tests/gateway-message-validator.test.ts +26 -0
  128. package/telegram-plugin/tests/gateway-secret-detect.test.ts +12 -3
  129. package/telegram-plugin/tests/gateway-startup-network-retry.test.ts +104 -0
  130. package/telegram-plugin/tests/history-reaper.test.ts +378 -0
  131. package/telegram-plugin/tests/hostd-dispatch.test.ts +129 -0
  132. package/telegram-plugin/tests/inbound-classifier.test.ts +76 -0
  133. package/telegram-plugin/tests/inbound-message-types.test.ts +267 -0
  134. package/telegram-plugin/tests/issues-card.test.ts +49 -0
  135. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +132 -0
  136. package/telegram-plugin/tests/permission-rule.test.ts +80 -1
  137. package/telegram-plugin/tests/permission-title.test.ts +31 -0
  138. package/telegram-plugin/tests/quota-check.test.ts +5 -35
  139. package/telegram-plugin/tests/races.test.ts +179 -0
  140. package/telegram-plugin/tests/reaction-trigger-flow.test.ts +353 -0
  141. package/telegram-plugin/tests/reaction-trigger.test.ts +397 -0
  142. package/telegram-plugin/tests/retry-api-call.test.ts +152 -1
  143. package/telegram-plugin/tests/runtime-metrics.test.ts +145 -0
  144. package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +155 -0
  145. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +133 -0
  146. package/telegram-plugin/tests/secret-detect-false-positives.test.ts +137 -0
  147. package/telegram-plugin/tests/silence-poke.test.ts +493 -0
  148. package/telegram-plugin/tests/silent-end.test.ts +206 -0
  149. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +107 -0
  150. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +224 -0
  151. package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +316 -0
  152. package/telegram-plugin/tests/subagent-watcher.test.ts +263 -0
  153. package/telegram-plugin/tests/turn-signal-tracker.test.ts +81 -0
  154. package/telegram-plugin/tests/vault-approval-posture.test.ts +256 -0
  155. package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +73 -0
  156. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +226 -0
  157. package/telegram-plugin/tests/vault-grant-union.test.ts +130 -0
  158. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +140 -0
  159. package/telegram-plugin/tests/vault-posture-quarantine.test.ts +104 -0
  160. package/telegram-plugin/tests/vault-request-access-tool.test.ts +114 -0
  161. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +106 -0
  162. package/telegram-plugin/turn-signal-tracker.ts +100 -24
  163. package/telegram-plugin/uat/SETUP.md +210 -35
  164. package/telegram-plugin/uat/assertions.ts +264 -37
  165. package/telegram-plugin/uat/driver-info.ts +57 -0
  166. package/telegram-plugin/uat/driver.ts +590 -51
  167. package/telegram-plugin/uat/harness.ts +140 -94
  168. package/telegram-plugin/uat/load-env.test.ts +72 -0
  169. package/telegram-plugin/uat/load-env.ts +48 -0
  170. package/telegram-plugin/uat/login.ts +96 -53
  171. package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
  172. package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
  173. package/telegram-plugin/uat/runners/report.ts +150 -0
  174. package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
  175. package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
  176. package/telegram-plugin/uat/runners/scorer.ts +106 -0
  177. package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
  178. package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
  179. package/telegram-plugin/uat/scenarios/ask-user-button-tap-dm.test.ts +141 -0
  180. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +191 -0
  181. package/telegram-plugin/uat/scenarios/fuzz-extended-dm.test.ts +255 -0
  182. package/telegram-plugin/uat/scenarios/fuzz-human-style-dm.test.ts +275 -0
  183. package/telegram-plugin/uat/scenarios/fuzz-random-prompts-dm.test.ts +146 -0
  184. package/telegram-plugin/uat/scenarios/fuzz-status-ask-dm.test.ts +486 -0
  185. package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +67 -0
  186. package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +100 -0
  187. package/telegram-plugin/uat/scenarios/jtbd-soft-commit-dm.test.ts +67 -0
  188. package/telegram-plugin/uat/scenarios/jtbd-status-query-dm.test.ts +49 -0
  189. package/telegram-plugin/uat/scenarios/location-inbound-dm.test.ts +65 -0
  190. package/telegram-plugin/uat/scenarios/midturn-silent-dm.test.ts +175 -0
  191. package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +142 -0
  192. package/telegram-plugin/uat/scenarios/reactions-trigger-turn-dm.test.ts +96 -0
  193. package/telegram-plugin/uat/scenarios/secret-redaction-deletes-original-dm.test.ts +123 -0
  194. package/telegram-plugin/uat/scenarios/secret-redaction-no-false-positive-dm.test.ts +87 -0
  195. package/telegram-plugin/uat/scenarios/silence-poke-soft-dm.test.ts +155 -0
  196. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +95 -0
  197. package/telegram-plugin/uat/scenarios/smoke-dm-reply.test.ts +57 -0
  198. package/telegram-plugin/uat/scenarios/subagent-watcher-no-rerun-dm.test.ts +135 -0
  199. package/telegram-plugin/uat/scenarios/vault-approval-posture-telegram-id-dm.test.ts +191 -0
  200. package/telegram-plugin/uat/scenarios/vault-audit-allow-dm.test.ts +108 -0
  201. package/telegram-plugin/uat/scenarios/vault-grant-auto-resume-dm.test.ts +121 -0
  202. package/telegram-plugin/uat/scenarios/vault-request-access-concurrent-dm.test.ts +161 -0
  203. package/telegram-plugin/uat/scenarios/vault-request-access-end-to-end-dm.test.ts +158 -0
  204. package/telegram-plugin/uat/scenarios/voice-inbound-dm.test.ts +65 -0
  205. package/telegram-plugin/vault-approval-posture.ts +42 -0
  206. package/telegram-plugin/welcome-text.ts +1 -0
  207. package/telegram-plugin/active-pins-sweep.ts +0 -204
  208. package/telegram-plugin/active-pins.ts +0 -146
  209. package/telegram-plugin/auth-dashboard.ts +0 -1104
  210. package/telegram-plugin/auth-slot-parser.ts +0 -497
  211. package/telegram-plugin/card-event-log.ts +0 -138
  212. package/telegram-plugin/dist/foreman/foreman.js +0 -31106
  213. package/telegram-plugin/docs/multi-agent-card-design.md +0 -847
  214. package/telegram-plugin/docs/pinned-progress-card-reliability.md +0 -144
  215. package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
  216. package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
  217. package/telegram-plugin/foreman/foreman.ts +0 -1165
  218. package/telegram-plugin/foreman/setup-flow.ts +0 -345
  219. package/telegram-plugin/foreman/setup-state.ts +0 -239
  220. package/telegram-plugin/foreman/state.ts +0 -203
  221. package/telegram-plugin/pin-event-log.ts +0 -76
  222. package/telegram-plugin/progress-card-driver.ts +0 -2886
  223. package/telegram-plugin/progress-card-pin-manager.ts +0 -589
  224. package/telegram-plugin/progress-card-pin-watchdog.ts +0 -98
  225. package/telegram-plugin/progress-card.ts +0 -1409
  226. package/telegram-plugin/tests/HARNESS.md +0 -340
  227. package/telegram-plugin/tests/_progress-card-harness.ts +0 -109
  228. package/telegram-plugin/tests/active-pins-boot-reaper.test.ts +0 -211
  229. package/telegram-plugin/tests/active-pins-sweep.test.ts +0 -309
  230. package/telegram-plugin/tests/active-pins.test.ts +0 -187
  231. package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
  232. package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
  233. package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
  234. package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
  235. package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
  236. package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
  237. package/telegram-plugin/tests/bg-agent-progress-card-757.test.ts +0 -201
  238. package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
  239. package/telegram-plugin/tests/card-event-log.test.ts +0 -145
  240. package/telegram-plugin/tests/first-paint.test.ts +0 -257
  241. package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
  242. package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
  243. package/telegram-plugin/tests/foreman-state.test.ts +0 -164
  244. package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
  245. package/telegram-plugin/tests/harness-ordering-invariants.test.ts +0 -243
  246. package/telegram-plugin/tests/pin-event-log.test.ts +0 -124
  247. package/telegram-plugin/tests/progress-card-api-failure-during-deferred.test.ts +0 -73
  248. package/telegram-plugin/tests/progress-card-close-paths-converge.test.ts +0 -272
  249. package/telegram-plugin/tests/progress-card-cross-turn.test.ts +0 -258
  250. package/telegram-plugin/tests/progress-card-delay-842.test.ts +0 -160
  251. package/telegram-plugin/tests/progress-card-dispose-preservepending.test.ts +0 -81
  252. package/telegram-plugin/tests/progress-card-draft-flag.test.ts +0 -80
  253. package/telegram-plugin/tests/progress-card-driver-eviction.test.ts +0 -215
  254. package/telegram-plugin/tests/progress-card-driver-fleet-shadow.test.ts +0 -123
  255. package/telegram-plugin/tests/progress-card-driver-force-complete-parent-done.test.ts +0 -76
  256. package/telegram-plugin/tests/progress-card-edit-timestamps-budget.test.ts +0 -62
  257. package/telegram-plugin/tests/progress-card-memory-bounds.test.ts +0 -84
  258. package/telegram-plugin/tests/progress-card-pin-failure-paths.test.ts +0 -139
  259. package/telegram-plugin/tests/progress-card-pin-manager.test.ts +0 -773
  260. package/telegram-plugin/tests/progress-card-pin-race-fast-turn.test.ts +0 -66
  261. package/telegram-plugin/tests/progress-card-pin-sidecar-partial-write.test.ts +0 -64
  262. package/telegram-plugin/tests/progress-card-pin-watchdog.test.ts +0 -190
  263. package/telegram-plugin/tests/progress-card-sigterm-pin-flush.test.ts +0 -146
  264. package/telegram-plugin/tests/real-gateway-f1-ladder-integrity.test.ts +0 -123
  265. package/telegram-plugin/tests/real-gateway-f2-instant-draft.test.ts +0 -82
  266. package/telegram-plugin/tests/real-gateway-f3-late-card.test.ts +0 -114
  267. package/telegram-plugin/tests/real-gateway-harness.ts +0 -699
  268. package/telegram-plugin/tests/real-gateway-i6-turn-flush-replay-dedup.test.ts +0 -313
  269. package/telegram-plugin/tests/real-gateway-ipc-lifecycle.test.ts +0 -299
  270. package/telegram-plugin/tests/real-gateway-spec.test.ts +0 -487
  271. package/telegram-plugin/tests/real-gateway.smoke.test.ts +0 -101
  272. package/telegram-plugin/tests/setup-flow.test.ts +0 -510
  273. package/telegram-plugin/tests/setup-state.test.ts +0 -146
  274. package/telegram-plugin/tests/sync-chat-running-subagents.test.ts +0 -116
  275. package/telegram-plugin/tests/turn-end-regressions.test.ts +0 -489
  276. package/telegram-plugin/tests/turn-flush-card-takeover.test.ts +0 -218
  277. package/telegram-plugin/tests/turn-flush-prose-recovery.test.ts +0 -78
  278. package/telegram-plugin/tests/two-zone-bg-carry-full-lifecycle.test.ts +0 -131
  279. package/telegram-plugin/tests/two-zone-bg-detection.test.ts +0 -120
  280. package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +0 -116
  281. package/telegram-plugin/tests/two-zone-bg-early-turn-end.test.ts +0 -87
  282. package/telegram-plugin/tests/two-zone-bg-survives-next-turn.test.ts +0 -211
  283. package/telegram-plugin/tests/two-zone-card-cap.test.ts +0 -62
  284. package/telegram-plugin/tests/two-zone-card-fleet-row.test.ts +0 -101
  285. package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +0 -78
  286. package/telegram-plugin/tests/two-zone-card-html-balance.test.ts +0 -110
  287. package/telegram-plugin/tests/two-zone-card-lifecycle.test.ts +0 -128
  288. package/telegram-plugin/tests/two-zone-card-sanitise.test.ts +0 -58
  289. package/telegram-plugin/tests/two-zone-card-snapshot.test.ts +0 -133
  290. package/telegram-plugin/tests/two-zone-concurrent-turns-isolation.test.ts +0 -155
  291. package/telegram-plugin/tests/two-zone-phasefor-precedence.test.ts +0 -117
  292. package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +0 -187
  293. package/telegram-plugin/tests/two-zone-stuck-edit-throttle.test.ts +0 -149
  294. package/telegram-plugin/tests/two-zone-stuck-header-escalation.test.ts +0 -101
  295. package/telegram-plugin/tests/two-zone-stuck-per-member.test.ts +0 -114
  296. package/telegram-plugin/tests/two-zone-stuck-recovery.test.ts +0 -105
  297. package/telegram-plugin/tests/waiting-ux-harness.ts +0 -381
  298. package/telegram-plugin/tests/waiting-ux.e2e.test.ts +0 -233
  299. package/telegram-plugin/turn-flush-prose-recovery.ts +0 -40
  300. package/telegram-plugin/two-zone-card.ts +0 -269
  301. package/telegram-plugin/uat/scenarios/smoke-clerk-reply.test.ts +0 -61
@@ -380,41 +380,11 @@ describe('fetchAccountQuota — cache + token resolution', () => {
380
380
  }
381
381
  })
382
382
 
383
- it('persists the snapshot under the supplied home, not the real homedir (issue #708 regression)', async () => {
384
- const home = makeAccountHome({
385
- 'work@example.com': { accessToken: 'tok' },
386
- })
387
- const fakeFetch = async () =>
388
- new Response('{}', {
389
- status: 200,
390
- headers: {
391
- 'anthropic-ratelimit-unified-5h-utilization': '0.42',
392
- 'anthropic-ratelimit-unified-7d-utilization': '0.17',
393
- },
394
- })
395
- try {
396
- const r = await fetchAccountQuota('work@example.com', {
397
- home,
398
- fetchImpl: fakeFetch as typeof fetch,
399
- })
400
- expect(r.ok).toBe(true)
401
- const snapPath = join(
402
- home,
403
- '.switchroom',
404
- 'accounts',
405
- 'work@example.com',
406
- 'quota.json',
407
- )
408
- // The bug: writeAccountQuota was called without opts.home, so the
409
- // snapshot landed under the real $HOME instead of the test home.
410
- expect(existsSync(snapPath)).toBe(true)
411
- const snap = JSON.parse(readFileSync(snapPath, 'utf-8'))
412
- expect(snap.fiveHourPct).toBeCloseTo(42, 0)
413
- expect(snap.sevenDayPct).toBeCloseTo(17, 0)
414
- } finally {
415
- rmSync(home, { recursive: true, force: true })
416
- }
417
- })
383
+ // Removed in RFC H: per-account quota.json disk persistence is gone.
384
+ // switchroom-auth-broker holds canonical quota state and exposes it
385
+ // via list-state; the gateway's in-process cache is enough between
386
+ // restarts (and the broker survives gateway restarts, so the state
387
+ // is preserved at the broker side anyway).
418
388
  })
419
389
 
420
390
  describe('getCachedAccountQuota + prefetchAccountQuotaIfStale', () => {
@@ -840,3 +840,182 @@ describe('Queue lifecycle: multiple queued messages overwrite notification', ()
840
840
  expect(secondNotif!.messageId).not.toBe(firstMsgId)
841
841
  })
842
842
  })
843
+
844
+ // ───────────────────────────────────────────────────────────────────────────
845
+ // #1067 — currentTurn atom snapshot-at-entry race regression
846
+ // ───────────────────────────────────────────────────────────────────────────
847
+ //
848
+ // Pre-#1067: gateway.ts had ~13 module-level `let`s holding the implicit
849
+ // "current turn" state (currentSessionChatId, currentTurnStartedAt, etc).
850
+ // Async session-event handlers read those singletons directly. When a
851
+ // handler captured a value, awaited (e.g. sendMessage / progress-card
852
+ // edit), and resumed, an intervening `enqueue` for a different chat could
853
+ // swap the singletons — and the resumed handler would mis-route its
854
+ // effect to the new chat (wrong-chat progress card, wrong-chat status
855
+ // reaction, wrong-chat answer-stream update).
856
+ //
857
+ // Fix: consolidate the singletons into one atom and capture it at handler
858
+ // entry: `const turn = currentTurn; if (turn == null) return;` — every
859
+ // subsequent read uses `turn.*` so the snapshot is stable across awaits.
860
+ //
861
+ // This test models the pattern with a minimal atom + handler harness and
862
+ // asserts the effect lands on the *original* turn's chat, not the
863
+ // usurper's. If the fix is ever undone, this test fails — making the
864
+ // reattribution race detectable in CI.
865
+
866
+ type _Test1067_CurrentTurn = {
867
+ sessionChatId: string
868
+ startedAt: number
869
+ }
870
+
871
+ interface _Test1067_Effect {
872
+ chatId: string
873
+ reason: 'tool_use' | 'turn_end'
874
+ }
875
+
876
+ describe('#1067 — currentTurn snapshot-at-entry prevents cross-chat reattribution', () => {
877
+ /** A synchronous swap of the atom — models the enqueue handler. */
878
+ function enqueue(state: { atom: _Test1067_CurrentTurn | null }, chatId: string, now: number): void {
879
+ state.atom = { sessionChatId: chatId, startedAt: now }
880
+ }
881
+
882
+ /**
883
+ * The pre-#1067 anti-pattern: re-read the singleton after the await.
884
+ * Any mid-await swap mis-attributes the effect to the new chat.
885
+ */
886
+ async function legacyToolUseHandler(
887
+ state: { atom: _Test1067_CurrentTurn | null },
888
+ effects: _Test1067_Effect[],
889
+ midAwait: () => Promise<void>,
890
+ ): Promise<void> {
891
+ const beforeChatId = state.atom?.sessionChatId
892
+ if (beforeChatId == null) return
893
+ // Simulate the await window (status-reaction edit, progress-card mutation,
894
+ // answer-stream API call — anything that yields the event loop).
895
+ await midAwait()
896
+ // Bug: re-read currentTurn after the await. A concurrent enqueue
897
+ // could have swapped the singletons; this effect now lands on the
898
+ // wrong chat.
899
+ const afterChatId = state.atom?.sessionChatId
900
+ if (afterChatId == null) return
901
+ effects.push({ chatId: afterChatId, reason: 'tool_use' })
902
+ }
903
+
904
+ /**
905
+ * The post-#1067 pattern: snapshot at handler entry, use the local
906
+ * through the rest of the handler — across awaits.
907
+ */
908
+ async function fixedToolUseHandler(
909
+ state: { atom: _Test1067_CurrentTurn | null },
910
+ effects: _Test1067_Effect[],
911
+ midAwait: () => Promise<void>,
912
+ ): Promise<void> {
913
+ const turn = state.atom // snapshot
914
+ if (turn == null) return
915
+ await midAwait()
916
+ // Use the captured `turn`, not state.atom. The effect goes to the
917
+ // chat that the handler was actually invoked for.
918
+ effects.push({ chatId: turn.sessionChatId, reason: 'tool_use' })
919
+ }
920
+
921
+ it('legacy pattern: mid-await enqueue reattributes the effect (regression baseline)', async () => {
922
+ const state: { atom: _Test1067_CurrentTurn | null } = { atom: null }
923
+ const effects: _Test1067_Effect[] = []
924
+
925
+ enqueue(state, 'A', 100)
926
+ // Start the handler; it will await midway.
927
+ let releaseAwait: () => void = () => {}
928
+ const blocker = new Promise<void>(resolve => { releaseAwait = resolve })
929
+ const handlerDone = legacyToolUseHandler(state, effects, () => blocker)
930
+
931
+ // While handler is suspended, an enqueue for chat B lands. Pre-#1067
932
+ // gateway would mutate the same singletons.
933
+ enqueue(state, 'B', 200)
934
+
935
+ releaseAwait()
936
+ await handlerDone
937
+
938
+ // Reattribution: the legacy pattern sends A's tool_use effect to B.
939
+ expect(effects).toEqual([{ chatId: 'B', reason: 'tool_use' }])
940
+ })
941
+
942
+ it('fixed pattern: snapshot-at-entry keeps the effect on the original chat', async () => {
943
+ const state: { atom: _Test1067_CurrentTurn | null } = { atom: null }
944
+ const effects: _Test1067_Effect[] = []
945
+
946
+ enqueue(state, 'A', 100)
947
+ let releaseAwait: () => void = () => {}
948
+ const blocker = new Promise<void>(resolve => { releaseAwait = resolve })
949
+ const handlerDone = fixedToolUseHandler(state, effects, () => blocker)
950
+
951
+ // Mid-handler enqueue for chat B — the race-trigger.
952
+ enqueue(state, 'B', 200)
953
+
954
+ releaseAwait()
955
+ await handlerDone
956
+
957
+ // The effect lands on A, the turn the handler was invoked for.
958
+ // This is the regression guard for #1067.
959
+ expect(effects).toEqual([{ chatId: 'A', reason: 'tool_use' }])
960
+ })
961
+
962
+ it('fixed pattern: if currentTurn is cleared mid-handler, the captured snapshot still wins', async () => {
963
+ // turn_end (or context-exhaustion bail-out) nulls the atom. A handler
964
+ // already in flight should still complete against its snapshot rather
965
+ // than dropping the effect — losing the snapshot here would
966
+ // re-introduce the cross-attribution failure mode if a NEW enqueue
967
+ // races the cleanup.
968
+ const state: { atom: _Test1067_CurrentTurn | null } = { atom: null }
969
+ const effects: _Test1067_Effect[] = []
970
+
971
+ enqueue(state, 'A', 100)
972
+ let releaseAwait: () => void = () => {}
973
+ const blocker = new Promise<void>(resolve => { releaseAwait = resolve })
974
+ const handlerDone = fixedToolUseHandler(state, effects, () => blocker)
975
+
976
+ // turn_end nulls the atom; then an immediate enqueue for B swaps in
977
+ // a fresh atom. The legacy reader would see B; the fixed reader
978
+ // still sees A.
979
+ state.atom = null
980
+ enqueue(state, 'B', 200)
981
+
982
+ releaseAwait()
983
+ await handlerDone
984
+
985
+ expect(effects).toEqual([{ chatId: 'A', reason: 'tool_use' }])
986
+ })
987
+
988
+ it('fixed pattern: multiple concurrent handlers each see their own turn', async () => {
989
+ // Pipeline: handler 1 starts under turn A and awaits. While it's
990
+ // suspended, turn_end + enqueue B happens. Handler 2 then starts
991
+ // under turn B and awaits. Both eventually resume. Each handler's
992
+ // effect must reflect the turn it started under.
993
+ const state: { atom: _Test1067_CurrentTurn | null } = { atom: null }
994
+ const effects: _Test1067_Effect[] = []
995
+
996
+ enqueue(state, 'A', 100)
997
+ let release1: () => void = () => {}
998
+ const block1 = new Promise<void>(resolve => { release1 = resolve })
999
+ const h1 = fixedToolUseHandler(state, effects, () => block1)
1000
+
1001
+ state.atom = null
1002
+ enqueue(state, 'B', 200)
1003
+
1004
+ let release2: () => void = () => {}
1005
+ const block2 = new Promise<void>(resolve => { release2 = resolve })
1006
+ const h2 = fixedToolUseHandler(state, effects, () => block2)
1007
+
1008
+ // Resume in reverse order: handler 2 first, then handler 1. This
1009
+ // proves the snapshot is not order-dependent.
1010
+ release2()
1011
+ await h2
1012
+ release1()
1013
+ await h1
1014
+
1015
+ // Both handlers landed effects on their respective turns; neither
1016
+ // got reattributed to the other's chat.
1017
+ expect(effects).toContainEqual({ chatId: 'A', reason: 'tool_use' })
1018
+ expect(effects).toContainEqual({ chatId: 'B', reason: 'tool_use' })
1019
+ expect(effects).toHaveLength(2)
1020
+ })
1021
+ })
@@ -0,0 +1,353 @@
1
+ /**
2
+ * End-to-end flow test for the reaction-trigger pipeline (#1074).
3
+ *
4
+ * Mirrors the gateway handler's decision flow (predicate → admin check
5
+ * → hour cap → debounce → InboundMessage build → dispatch) without
6
+ * pulling in the gateway module's heavy side-effects. The integration
7
+ * point that matters is the SHAPE of the InboundMessage emitted to the
8
+ * dispatcher — that's what the bridge sees as a synthetic turn.
9
+ *
10
+ * What this test pins:
11
+ * 1. A bot-authored 👎 dispatches a single inbound with
12
+ * `meta.source="reaction"` after the debounce window.
13
+ * 2. A ❤️ reaction (not in default allowlist) dispatches NOTHING.
14
+ * 3. A user-authored target message dispatches NOTHING.
15
+ * 4. Two reactions within the window collapse into one batched
16
+ * synthetic with the second emoji NOT lost.
17
+ * 5. The hour cap refuses past the limit (no inbound emitted).
18
+ * 6. Group + non-admin reacter dispatches NOTHING.
19
+ * 7. Group + admin reacter dispatches normally.
20
+ *
21
+ * Each scenario builds a fresh HourCap + DebounceBuffer so state never
22
+ * leaks across tests.
23
+ */
24
+
25
+ import { describe, it, expect } from 'bun:test'
26
+ import {
27
+ DebounceBuffer,
28
+ HourCap,
29
+ REACTIONS_DEFAULTS,
30
+ buildReactionInboundMeta,
31
+ buildReactionInboundText,
32
+ evaluateTriggerCandidate,
33
+ isGroupChat,
34
+ resolveReactionsConfig,
35
+ truncatePreview,
36
+ type PendingReaction,
37
+ type ReactionBatch,
38
+ type ReactionsResolvedConfig,
39
+ } from '../gateway/reaction-trigger.ts'
40
+
41
+ interface FakeInbound {
42
+ chatId: string
43
+ text: string
44
+ meta: Record<string, string>
45
+ userId: number
46
+ user: string
47
+ messageId: number
48
+ threadId?: number
49
+ }
50
+
51
+ /**
52
+ * Test driver that mirrors the gateway handler flow.
53
+ */
54
+ interface DriverState {
55
+ cfg: ReactionsResolvedConfig
56
+ cap: HourCap
57
+ buf: DebounceBuffer
58
+ sched: FakeScheduler
59
+ dispatched: FakeInbound[]
60
+ /** Map from message_id → role + text. */
61
+ history: Map<number, { role: 'user' | 'assistant'; text: string }>
62
+ /** Set of user_ids treated as group admin (consulted on group chats). */
63
+ admins: Set<number>
64
+ }
65
+
66
+ interface FakeScheduler {
67
+ pending: Array<{ id: number; fn: () => void }>
68
+ schedule: (fn: () => void, ms: number) => { id: number; fn: () => void }
69
+ cancel: (h: { id: number }) => void
70
+ flushAll: () => void
71
+ }
72
+
73
+ function makeScheduler(): FakeScheduler {
74
+ let next = 1
75
+ const pending: Array<{ id: number; fn: () => void }> = []
76
+ return {
77
+ pending,
78
+ schedule(fn) {
79
+ const h = { id: next++, fn }
80
+ pending.push(h)
81
+ return h
82
+ },
83
+ cancel(h) {
84
+ const i = pending.findIndex((p) => p.id === h.id)
85
+ if (i >= 0) pending.splice(i, 1)
86
+ },
87
+ flushAll() {
88
+ const snap = pending.splice(0)
89
+ for (const p of snap) p.fn()
90
+ },
91
+ }
92
+ }
93
+
94
+ function makeDriver(over: Partial<ReactionsResolvedConfig> = {}): DriverState {
95
+ const cfg = { ...REACTIONS_DEFAULTS, ...over }
96
+ const sched = makeScheduler()
97
+ const dispatched: FakeInbound[] = []
98
+ const buf = new DebounceBuffer(
99
+ cfg.debounceMs,
100
+ (b) => dispatchBatch(b, dispatched),
101
+ { schedule: sched.schedule as never, cancel: sched.cancel as never },
102
+ )
103
+ return {
104
+ cfg,
105
+ cap: new HourCap(cfg.perHourCap),
106
+ buf,
107
+ sched,
108
+ dispatched,
109
+ history: new Map(),
110
+ admins: new Set(),
111
+ }
112
+ }
113
+
114
+ function dispatchBatch(batch: ReactionBatch, sink: FakeInbound[]): void {
115
+ const head = batch.reactions[batch.reactions.length - 1]!
116
+ sink.push({
117
+ chatId: String(batch.chatId),
118
+ text: buildReactionInboundText(batch),
119
+ meta: buildReactionInboundMeta(batch),
120
+ userId: head.userId,
121
+ user: head.user,
122
+ messageId: Date.now(),
123
+ ...(head.threadId !== undefined ? { threadId: head.threadId } : {}),
124
+ })
125
+ }
126
+
127
+ /**
128
+ * Replays the gateway's handler logic against the driver.
129
+ *
130
+ * Returns the rejection reason (or 'enqueued') so the test can assert
131
+ * the exact branch taken without grepping stderr.
132
+ */
133
+ function feedReaction(
134
+ d: DriverState,
135
+ args: {
136
+ chatId: number
137
+ messageId: number
138
+ emoji: string | null
139
+ action: 'add' | 'change' | 'remove'
140
+ reacterId: number
141
+ reacterName?: string
142
+ threadId?: number
143
+ },
144
+ ):
145
+ | { kind: 'persisted_only'; reason: string }
146
+ | { kind: 'enqueued' } {
147
+ if (args.action === 'remove' || args.emoji === null) {
148
+ return { kind: 'persisted_only', reason: 'remove_or_null_emoji' }
149
+ }
150
+ const row = d.history.get(args.messageId)
151
+ const botAuthored = row?.role === 'assistant'
152
+ const preview = truncatePreview(row?.text ?? '')
153
+ const decision = evaluateTriggerCandidate(d.cfg, {
154
+ chatId: args.chatId,
155
+ messageId: args.messageId,
156
+ emoji: args.emoji,
157
+ action: args.action,
158
+ botAuthored,
159
+ })
160
+ if (!decision.ok) {
161
+ return { kind: 'persisted_only', reason: decision.reason }
162
+ }
163
+ if (d.cfg.groupAdminOnly && isGroupChat(args.chatId)) {
164
+ if (!d.admins.has(args.reacterId)) {
165
+ return { kind: 'persisted_only', reason: 'group_non_admin' }
166
+ }
167
+ }
168
+ if (!d.cap.tryConsume(String(args.chatId))) {
169
+ return { kind: 'persisted_only', reason: 'hour_cap_exhausted' }
170
+ }
171
+ const pending: PendingReaction = {
172
+ targetMessageId: args.messageId,
173
+ emoji: args.emoji,
174
+ action: args.action,
175
+ ts: Date.now(),
176
+ preview,
177
+ userId: args.reacterId,
178
+ user: args.reacterName ?? `u${args.reacterId}`,
179
+ ...(args.threadId !== undefined ? { threadId: args.threadId } : {}),
180
+ }
181
+ d.buf.enqueue(args.chatId, pending)
182
+ return { kind: 'enqueued' }
183
+ }
184
+
185
+ // ─── Scenarios ───────────────────────────────────────────────────────────
186
+
187
+ describe('reaction-trigger flow', () => {
188
+ it('bot-authored 👎 dispatches a synthetic inbound with meta.source="reaction"', () => {
189
+ const d = makeDriver()
190
+ d.history.set(42, { role: 'assistant', text: 'hello from bot' })
191
+ const r = feedReaction(d, {
192
+ chatId: 100, messageId: 42, emoji: '👎', action: 'add', reacterId: 7,
193
+ })
194
+ expect(r.kind).toBe('enqueued')
195
+ expect(d.dispatched.length).toBe(0)
196
+ d.sched.flushAll()
197
+ expect(d.dispatched.length).toBe(1)
198
+ const out = d.dispatched[0]!
199
+ expect(out.meta.source).toBe('reaction')
200
+ expect(out.meta.batched).toBe('false')
201
+ expect(out.meta.reaction_emoji).toBe('👎')
202
+ expect(out.meta.target_message_id).toBe('42')
203
+ expect(out.text).toContain('<channel source="reaction"')
204
+ expect(out.text).toContain('hello from bot')
205
+ expect(out.userId).toBe(7)
206
+ })
207
+
208
+ it('❤️ (not in default allowlist) dispatches NOTHING (negative)', () => {
209
+ const d = makeDriver()
210
+ d.history.set(42, { role: 'assistant', text: 'hi' })
211
+ const r = feedReaction(d, {
212
+ chatId: 100, messageId: 42, emoji: '❤️', action: 'add', reacterId: 7,
213
+ })
214
+ expect(r.kind).toBe('persisted_only')
215
+ if (r.kind === 'persisted_only') expect(r.reason).toBe('emoji_not_in_allowlist')
216
+ d.sched.flushAll()
217
+ expect(d.dispatched.length).toBe(0)
218
+ })
219
+
220
+ it('👎 on a USER-authored message dispatches NOTHING (no trigger)', () => {
221
+ const d = makeDriver()
222
+ d.history.set(42, { role: 'user', text: 'something the user said' })
223
+ const r = feedReaction(d, {
224
+ chatId: 100, messageId: 42, emoji: '👎', action: 'add', reacterId: 7,
225
+ })
226
+ expect(r.kind).toBe('persisted_only')
227
+ if (r.kind === 'persisted_only') expect(r.reason).toBe('not_bot_authored')
228
+ d.sched.flushAll()
229
+ expect(d.dispatched.length).toBe(0)
230
+ })
231
+
232
+ it('two qualifying reactions within window collapse into one batched synthetic', () => {
233
+ const d = makeDriver()
234
+ d.history.set(42, { role: 'assistant', text: 'first bot msg' })
235
+ d.history.set(43, { role: 'assistant', text: 'second bot msg' })
236
+ feedReaction(d, { chatId: 100, messageId: 42, emoji: '👎', action: 'add', reacterId: 7 })
237
+ feedReaction(d, { chatId: 100, messageId: 43, emoji: '✅', action: 'add', reacterId: 7 })
238
+ d.sched.flushAll()
239
+ expect(d.dispatched.length).toBe(1)
240
+ const out = d.dispatched[0]!
241
+ expect(out.meta.batched).toBe('true')
242
+ expect(out.meta.count).toBe('2')
243
+ // Inline list includes both target msg ids.
244
+ expect(out.text).toContain('on msg 42')
245
+ expect(out.text).toContain('on msg 43')
246
+ })
247
+
248
+ it('hour cap refuses past the limit (no inbound emitted)', () => {
249
+ const d = makeDriver({ perHourCap: 2, debounceMs: 100 })
250
+ d.history.set(42, { role: 'assistant', text: 'hi' })
251
+ // Three back-to-back, each in its own debounce window.
252
+ for (let i = 0; i < 3; i++) {
253
+ const r = feedReaction(d, {
254
+ chatId: 100, messageId: 42, emoji: '👎', action: 'add', reacterId: 7,
255
+ })
256
+ if (i < 2) expect(r.kind).toBe('enqueued')
257
+ else {
258
+ expect(r.kind).toBe('persisted_only')
259
+ if (r.kind === 'persisted_only') expect(r.reason).toBe('hour_cap_exhausted')
260
+ }
261
+ // Drain so each enqueue gets its own debounce flush.
262
+ d.sched.flushAll()
263
+ }
264
+ expect(d.dispatched.length).toBe(2)
265
+ })
266
+
267
+ it('group reaction by non-admin dispatches NOTHING (fail-closed)', () => {
268
+ const d = makeDriver()
269
+ d.history.set(42, { role: 'assistant', text: 'group bot msg' })
270
+ // Negative chat_id → group; admins set is empty.
271
+ const r = feedReaction(d, {
272
+ chatId: -1001234, messageId: 42, emoji: '👎', action: 'add', reacterId: 7,
273
+ })
274
+ expect(r.kind).toBe('persisted_only')
275
+ if (r.kind === 'persisted_only') expect(r.reason).toBe('group_non_admin')
276
+ d.sched.flushAll()
277
+ expect(d.dispatched.length).toBe(0)
278
+ })
279
+
280
+ it('group reaction by admin dispatches normally', () => {
281
+ const d = makeDriver()
282
+ d.admins.add(7)
283
+ d.history.set(42, { role: 'assistant', text: 'group bot msg' })
284
+ const r = feedReaction(d, {
285
+ chatId: -1001234, messageId: 42, emoji: '👎', action: 'add', reacterId: 7,
286
+ })
287
+ expect(r.kind).toBe('enqueued')
288
+ d.sched.flushAll()
289
+ expect(d.dispatched.length).toBe(1)
290
+ })
291
+
292
+ it('group reaction with group_admin_only=false ignores admin status', () => {
293
+ const d = makeDriver({ groupAdminOnly: false })
294
+ d.history.set(42, { role: 'assistant', text: 'group bot msg' })
295
+ const r = feedReaction(d, {
296
+ chatId: -1001234, messageId: 42, emoji: '👎', action: 'add', reacterId: 7,
297
+ })
298
+ expect(r.kind).toBe('enqueued')
299
+ d.sched.flushAll()
300
+ expect(d.dispatched.length).toBe(1)
301
+ })
302
+
303
+ it('reaction-remove (old: [👎], new: []) is persistence-only (action=remove)', () => {
304
+ const d = makeDriver()
305
+ d.history.set(42, { role: 'assistant', text: 'hi' })
306
+ // action=remove is filtered out before predicate evaluation.
307
+ const r = feedReaction(d, {
308
+ chatId: 100, messageId: 42, emoji: null, action: 'remove', reacterId: 7,
309
+ })
310
+ expect(r.kind).toBe('persisted_only')
311
+ d.sched.flushAll()
312
+ expect(d.dispatched.length).toBe(0)
313
+ })
314
+
315
+ it('reaction on a message not in history (no row) fails closed — no trigger', () => {
316
+ const d = makeDriver()
317
+ // No history entry for 42 — bot-authored is unknown.
318
+ const r = feedReaction(d, {
319
+ chatId: 100, messageId: 42, emoji: '👎', action: 'add', reacterId: 7,
320
+ })
321
+ expect(r.kind).toBe('persisted_only')
322
+ if (r.kind === 'persisted_only') expect(r.reason).toBe('not_bot_authored')
323
+ d.sched.flushAll()
324
+ expect(d.dispatched.length).toBe(0)
325
+ })
326
+
327
+ it('enabled=false dispatches NOTHING even on default allowlist match', () => {
328
+ const d = makeDriver({ enabled: false })
329
+ d.history.set(42, { role: 'assistant', text: 'hi' })
330
+ const r = feedReaction(d, {
331
+ chatId: 100, messageId: 42, emoji: '👎', action: 'add', reacterId: 7,
332
+ })
333
+ expect(r.kind).toBe('persisted_only')
334
+ if (r.kind === 'persisted_only') expect(r.reason).toBe('disabled')
335
+ d.sched.flushAll()
336
+ expect(d.dispatched.length).toBe(0)
337
+ })
338
+
339
+ it('cascade: trigger_emojis: [] disables triggering without flipping enabled', () => {
340
+ // Operator narrows to empty allowlist — every qualifying emoji is
341
+ // now a miss. This is the "kill switch without bigger hammer" case.
342
+ const cfg = resolveReactionsConfig({ trigger_emojis: [] })
343
+ const d = makeDriver(cfg)
344
+ d.history.set(42, { role: 'assistant', text: 'hi' })
345
+ const r = feedReaction(d, {
346
+ chatId: 100, messageId: 42, emoji: '👎', action: 'add', reacterId: 7,
347
+ })
348
+ expect(r.kind).toBe('persisted_only')
349
+ if (r.kind === 'persisted_only') expect(r.reason).toBe('emoji_not_in_allowlist')
350
+ d.sched.flushAll()
351
+ expect(d.dispatched.length).toBe(0)
352
+ })
353
+ })