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
@@ -0,0 +1,397 @@
1
+ /**
2
+ * Unit tests for the reaction-trigger primitives (#1074).
3
+ *
4
+ * Covers the synchronous predicate, the per-chat hour cap, the
5
+ * debounce buffer's single/collapse/batch behaviours, and the inbound
6
+ * text/meta builders. The integration test that exercises the full
7
+ * gateway handler lives in `reaction-trigger.gateway.test.ts`.
8
+ */
9
+
10
+ import { describe, it, expect } from 'bun:test'
11
+ import {
12
+ BATCH_INLINE_LIMIT,
13
+ DebounceBuffer,
14
+ HourCap,
15
+ REACTIONS_DEFAULTS,
16
+ buildReactionInboundMeta,
17
+ buildReactionInboundText,
18
+ evaluateTriggerCandidate,
19
+ isGroupChat,
20
+ resolveReactionsConfig,
21
+ truncatePreview,
22
+ type PendingReaction,
23
+ type ReactionBatch,
24
+ type ReactionsResolvedConfig,
25
+ } from '../gateway/reaction-trigger.ts'
26
+
27
+ // Helper — minimal candidate factory.
28
+ function candidate(
29
+ over: Partial<Parameters<typeof evaluateTriggerCandidate>[1]> = {},
30
+ ): Parameters<typeof evaluateTriggerCandidate>[1] {
31
+ return {
32
+ chatId: 123,
33
+ messageId: 42,
34
+ emoji: '👎',
35
+ action: 'add',
36
+ botAuthored: true,
37
+ ...over,
38
+ }
39
+ }
40
+
41
+ const FULL_CFG: ReactionsResolvedConfig = REACTIONS_DEFAULTS
42
+
43
+ describe('resolveReactionsConfig', () => {
44
+ it('returns built-in defaults for undefined / null input', () => {
45
+ expect(resolveReactionsConfig(undefined)).toBe(REACTIONS_DEFAULTS)
46
+ expect(resolveReactionsConfig(null)).toBe(REACTIONS_DEFAULTS)
47
+ })
48
+
49
+ it('falls through to defaults for missing fields', () => {
50
+ const r = resolveReactionsConfig({ debounce_ms: 5000 })
51
+ expect(r.debounceMs).toBe(5000)
52
+ expect(r.enabled).toBe(REACTIONS_DEFAULTS.enabled)
53
+ expect(r.perHourCap).toBe(REACTIONS_DEFAULTS.perHourCap)
54
+ expect(r.triggerEmojis).toBe(REACTIONS_DEFAULTS.triggerEmojis)
55
+ })
56
+
57
+ it('REPLACES trigger_emojis (not unions)', () => {
58
+ const r = resolveReactionsConfig({ trigger_emojis: ['🔥'] })
59
+ expect([...r.triggerEmojis]).toEqual(['🔥'])
60
+ // 👎 (a default) should NOT be present.
61
+ expect(r.triggerEmojis.has('👎')).toBe(false)
62
+ })
63
+
64
+ it('supports trigger_emojis: [] as the empty allowlist', () => {
65
+ const r = resolveReactionsConfig({ trigger_emojis: [] })
66
+ expect(r.triggerEmojis.size).toBe(0)
67
+ })
68
+
69
+ it('defaults match the locked design (Ken approved 2026-05-12)', () => {
70
+ expect(REACTIONS_DEFAULTS.enabled).toBe(true)
71
+ expect(REACTIONS_DEFAULTS.debounceMs).toBe(30_000)
72
+ expect(REACTIONS_DEFAULTS.perHourCap).toBe(10)
73
+ expect(REACTIONS_DEFAULTS.groupAdminOnly).toBe(true)
74
+ expect([...REACTIONS_DEFAULTS.triggerEmojis].sort()).toEqual(
75
+ ['👍', '👎', '✅', '❌'].sort(),
76
+ )
77
+ })
78
+ })
79
+
80
+ describe('evaluateTriggerCandidate', () => {
81
+ it('accepts a bot-authored 👎 (default allowlist)', () => {
82
+ expect(evaluateTriggerCandidate(FULL_CFG, candidate())).toEqual({ ok: true })
83
+ })
84
+
85
+ it('rejects when enabled=false (master switch)', () => {
86
+ const cfg = resolveReactionsConfig({ enabled: false })
87
+ expect(evaluateTriggerCandidate(cfg, candidate())).toEqual({
88
+ ok: false,
89
+ reason: 'disabled',
90
+ })
91
+ })
92
+
93
+ it('rejects reactions on user-authored messages (no trigger)', () => {
94
+ expect(evaluateTriggerCandidate(FULL_CFG, candidate({ botAuthored: false }))).toEqual({
95
+ ok: false,
96
+ reason: 'not_bot_authored',
97
+ })
98
+ })
99
+
100
+ it('rejects emoji not in the allowlist (e.g. ❤️ on a bot reply)', () => {
101
+ expect(evaluateTriggerCandidate(FULL_CFG, candidate({ emoji: '❤️' }))).toEqual({
102
+ ok: false,
103
+ reason: 'emoji_not_in_allowlist',
104
+ })
105
+ })
106
+
107
+ it('rejects null emoji (custom emoji / non-emoji reaction)', () => {
108
+ expect(evaluateTriggerCandidate(FULL_CFG, candidate({ emoji: null }))).toEqual({
109
+ ok: false,
110
+ reason: 'no_emoji',
111
+ })
112
+ })
113
+
114
+ it('accepts each default-allowlist emoji', () => {
115
+ for (const e of ['👎', '❌', '👍', '✅']) {
116
+ expect(evaluateTriggerCandidate(FULL_CFG, candidate({ emoji: e }))).toEqual({
117
+ ok: true,
118
+ })
119
+ }
120
+ })
121
+
122
+ it('narrowed allowlist rejects previously-accepted emojis', () => {
123
+ const cfg = resolveReactionsConfig({ trigger_emojis: ['👎'] })
124
+ expect(evaluateTriggerCandidate(cfg, candidate({ emoji: '👍' }))).toEqual({
125
+ ok: false,
126
+ reason: 'emoji_not_in_allowlist',
127
+ })
128
+ expect(evaluateTriggerCandidate(cfg, candidate({ emoji: '👎' }))).toEqual({
129
+ ok: true,
130
+ })
131
+ })
132
+
133
+ it('empty-allowlist effectively disables triggering without enabled=false', () => {
134
+ const cfg = resolveReactionsConfig({ trigger_emojis: [] })
135
+ expect(evaluateTriggerCandidate(cfg, candidate({ emoji: '👎' }))).toEqual({
136
+ ok: false,
137
+ reason: 'emoji_not_in_allowlist',
138
+ })
139
+ })
140
+ })
141
+
142
+ describe('isGroupChat', () => {
143
+ it('treats negative chat ids as groups (Bot API convention)', () => {
144
+ expect(isGroupChat(-100123)).toBe(true)
145
+ expect(isGroupChat(-1)).toBe(true)
146
+ })
147
+ it('treats positive chat ids as DMs', () => {
148
+ expect(isGroupChat(987654)).toBe(false)
149
+ expect(isGroupChat(1)).toBe(false)
150
+ })
151
+ })
152
+
153
+ describe('HourCap', () => {
154
+ it('admits up to `cap` events then refuses, scoped per chat', () => {
155
+ const cap = new HourCap(3)
156
+ expect(cap.tryConsume('A')).toBe(true)
157
+ expect(cap.tryConsume('A')).toBe(true)
158
+ expect(cap.tryConsume('A')).toBe(true)
159
+ expect(cap.tryConsume('A')).toBe(false)
160
+ expect(cap.tryConsume('A')).toBe(false)
161
+ // Different chat — independent budget.
162
+ expect(cap.tryConsume('B')).toBe(true)
163
+ })
164
+
165
+ it('cap=0 always refuses', () => {
166
+ const cap = new HourCap(0)
167
+ expect(cap.tryConsume('A')).toBe(false)
168
+ expect(cap.tryConsume('A')).toBe(false)
169
+ })
170
+
171
+ it('rolls forward after the 1-hour window passes', () => {
172
+ let now = 1_000_000
173
+ const cap = new HourCap(2, () => now)
174
+ expect(cap.tryConsume('A')).toBe(true)
175
+ expect(cap.tryConsume('A')).toBe(true)
176
+ expect(cap.tryConsume('A')).toBe(false)
177
+ // Roll past the hour window.
178
+ now += 60 * 60 * 1000 + 1
179
+ expect(cap.tryConsume('A')).toBe(true)
180
+ expect(cap.tryConsume('A')).toBe(true)
181
+ expect(cap.tryConsume('A')).toBe(false)
182
+ })
183
+
184
+ it('reports a trailing-hour count via size()', () => {
185
+ let now = 0
186
+ const cap = new HourCap(5, () => now)
187
+ cap.tryConsume('A')
188
+ cap.tryConsume('A')
189
+ expect(cap.size('A')).toBe(2)
190
+ now += 60 * 60 * 1000 + 1
191
+ expect(cap.size('A')).toBe(0)
192
+ })
193
+ })
194
+
195
+ describe('DebounceBuffer', () => {
196
+ // Fake scheduler — exposes timers so the test drives the clock.
197
+ function makeScheduler(): {
198
+ schedule: (fn: () => void, ms: number) => { id: number; fn: () => void; ms: number }
199
+ cancel: (h: { id: number }) => void
200
+ flushAll: () => void
201
+ pending: { id: number; fn: () => void; ms: number }[]
202
+ nextId: number
203
+ } {
204
+ let nextId = 1
205
+ const pending: { id: number; fn: () => void; ms: number }[] = []
206
+ return {
207
+ schedule(fn: () => void, ms: number) {
208
+ const h = { id: nextId++, fn, ms }
209
+ pending.push(h)
210
+ return h
211
+ },
212
+ cancel(h: { id: number }) {
213
+ const i = pending.findIndex((p) => p.id === h.id)
214
+ if (i >= 0) pending.splice(i, 1)
215
+ },
216
+ flushAll() {
217
+ // Snapshot then drain — running fn() may enqueue more.
218
+ const snap = pending.splice(0)
219
+ for (const p of snap) p.fn()
220
+ },
221
+ pending,
222
+ nextId,
223
+ }
224
+ }
225
+
226
+ function pending(over: Partial<PendingReaction> = {}): PendingReaction {
227
+ return {
228
+ targetMessageId: 7,
229
+ emoji: '👎',
230
+ action: 'add',
231
+ ts: 0,
232
+ preview: 'hello',
233
+ userId: 99,
234
+ user: 'tester',
235
+ ...over,
236
+ }
237
+ }
238
+
239
+ it('single enqueue fires the sink with batched=false after window', () => {
240
+ const sched = makeScheduler()
241
+ const batches: ReactionBatch[] = []
242
+ const buf = new DebounceBuffer(30_000, (b) => batches.push(b), {
243
+ schedule: sched.schedule as never,
244
+ cancel: sched.cancel as never,
245
+ })
246
+ buf.enqueue(123, pending({ emoji: '👎', targetMessageId: 1 }))
247
+ expect(batches.length).toBe(0)
248
+ sched.flushAll()
249
+ expect(batches.length).toBe(1)
250
+ expect(batches[0]!.batched).toBe(false)
251
+ expect(batches[0]!.reactions.length).toBe(1)
252
+ expect(batches[0]!.reactions[0]!.emoji).toBe('👎')
253
+ })
254
+
255
+ it('two enqueues within window collapse into batched=true with 2 entries', () => {
256
+ const sched = makeScheduler()
257
+ const batches: ReactionBatch[] = []
258
+ const buf = new DebounceBuffer(30_000, (b) => batches.push(b), {
259
+ schedule: sched.schedule as never,
260
+ cancel: sched.cancel as never,
261
+ })
262
+ buf.enqueue(123, pending({ emoji: '👎', targetMessageId: 1 }))
263
+ buf.enqueue(123, pending({ emoji: '✅', targetMessageId: 2 }))
264
+ expect(batches.length).toBe(0)
265
+ sched.flushAll()
266
+ expect(batches.length).toBe(1)
267
+ expect(batches[0]!.batched).toBe(true)
268
+ expect(batches[0]!.reactions.length).toBe(2)
269
+ })
270
+
271
+ it('separate chats do not collapse into each other', () => {
272
+ const sched = makeScheduler()
273
+ const batches: ReactionBatch[] = []
274
+ const buf = new DebounceBuffer(30_000, (b) => batches.push(b), {
275
+ schedule: sched.schedule as never,
276
+ cancel: sched.cancel as never,
277
+ })
278
+ buf.enqueue(123, pending({ targetMessageId: 1 }))
279
+ buf.enqueue(456, pending({ targetMessageId: 2 }))
280
+ sched.flushAll()
281
+ expect(batches.length).toBe(2)
282
+ expect(batches.map((b) => b.chatId).sort()).toEqual([123, 456])
283
+ })
284
+
285
+ it('manual flush() before timer is a no-op double-flush', () => {
286
+ const sched = makeScheduler()
287
+ const batches: ReactionBatch[] = []
288
+ const buf = new DebounceBuffer(30_000, (b) => batches.push(b), {
289
+ schedule: sched.schedule as never,
290
+ cancel: sched.cancel as never,
291
+ })
292
+ buf.enqueue(123, pending())
293
+ buf.flush(123)
294
+ expect(batches.length).toBe(1)
295
+ // No pending timer is left to fire.
296
+ sched.flushAll()
297
+ expect(batches.length).toBe(1)
298
+ })
299
+
300
+ it('caps unbounded growth — extra entries past maxPending are dropped', () => {
301
+ const sched = makeScheduler()
302
+ const batches: ReactionBatch[] = []
303
+ const buf = new DebounceBuffer(
304
+ 30_000,
305
+ (b) => batches.push(b),
306
+ {
307
+ schedule: sched.schedule as never,
308
+ cancel: sched.cancel as never,
309
+ maxPending: 3,
310
+ },
311
+ )
312
+ for (let i = 0; i < 10; i++) buf.enqueue(123, pending({ targetMessageId: i }))
313
+ sched.flushAll()
314
+ expect(batches.length).toBe(1)
315
+ expect(batches[0]!.reactions.length).toBe(3)
316
+ })
317
+ })
318
+
319
+ describe('truncatePreview', () => {
320
+ it('returns "" for null / undefined / ""', () => {
321
+ expect(truncatePreview(null)).toBe('')
322
+ expect(truncatePreview(undefined)).toBe('')
323
+ expect(truncatePreview('')).toBe('')
324
+ })
325
+ it('returns short strings unchanged', () => {
326
+ expect(truncatePreview('hi')).toBe('hi')
327
+ })
328
+ it('truncates with ellipsis past 200 chars', () => {
329
+ const s = 'x'.repeat(500)
330
+ const out = truncatePreview(s)
331
+ expect(out.length).toBe(200)
332
+ expect(out.endsWith('…')).toBe(true)
333
+ })
334
+ })
335
+
336
+ describe('buildReactionInboundText / Meta', () => {
337
+ function batchOf(reactions: PendingReaction[]): ReactionBatch {
338
+ return { chatId: 1, reactions, batched: reactions.length > 1 }
339
+ }
340
+ function p(emoji: string, mid: number, preview = 'hi'): PendingReaction {
341
+ return {
342
+ targetMessageId: mid, emoji, action: 'add', ts: 0,
343
+ preview, userId: 1, user: 'u',
344
+ }
345
+ }
346
+
347
+ it('single produces a <channel source="reaction"> envelope', () => {
348
+ const text = buildReactionInboundText(batchOf([p('👎', 42, 'the bot said something')]))
349
+ expect(text).toContain('<channel source="reaction"')
350
+ expect(text).toContain('emoji="👎"')
351
+ expect(text).toContain('action="add"')
352
+ expect(text).toContain('target_message_id="42"')
353
+ expect(text).toContain('the bot said something')
354
+ expect(text.endsWith('</channel>')).toBe(true)
355
+ })
356
+
357
+ it('single meta carries the discriminators', () => {
358
+ const meta = buildReactionInboundMeta(batchOf([p('👍', 7, 'ok')]))
359
+ expect(meta.source).toBe('reaction')
360
+ expect(meta.batched).toBe('false')
361
+ expect(meta.count).toBe('1')
362
+ expect(meta.reaction_emoji).toBe('👍')
363
+ expect(meta.target_message_id).toBe('7')
364
+ expect(meta.target_message_preview).toBe('ok')
365
+ })
366
+
367
+ it('batched lists each reaction inline up to the limit + "+N more"', () => {
368
+ const reactions = Array.from({ length: BATCH_INLINE_LIMIT + 3 }, (_, i) =>
369
+ p('👎', i + 1, `m${i + 1}`),
370
+ )
371
+ const text = buildReactionInboundText(batchOf(reactions))
372
+ expect(text).toContain('batched="true"')
373
+ expect(text).toContain(`count="${reactions.length}"`)
374
+ expect(text).toContain('+3 more')
375
+ // Inline-listed first N entries.
376
+ expect(text).toContain('on msg 1')
377
+ expect(text).toContain(`on msg ${BATCH_INLINE_LIMIT}`)
378
+ // Past-limit entries are NOT inlined (only count is propagated).
379
+ expect(text).not.toContain(`on msg ${BATCH_INLINE_LIMIT + 1}`)
380
+ })
381
+
382
+ it('escapes < and > in preview body and emoji attr', () => {
383
+ const text = buildReactionInboundText(
384
+ batchOf([p('👎', 1, '<script>alert(1)</script>')]),
385
+ )
386
+ expect(text).not.toContain('<script>')
387
+ expect(text).toContain('&lt;script&gt;')
388
+ })
389
+
390
+ it('persistence path is unchanged — buildReactionInboundText is pure', () => {
391
+ // Smoke check that the function does not throw / mutate inputs.
392
+ const b = batchOf([p('👍', 1)])
393
+ Object.freeze(b)
394
+ Object.freeze(b.reactions)
395
+ expect(() => buildReactionInboundText(b)).not.toThrow()
396
+ })
397
+ })
@@ -10,7 +10,12 @@
10
10
 
11
11
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
12
12
  import { GrammyError } from 'grammy'
13
- import { createRetryApiCall, type RetryObserver } from '../retry-api-call.js'
13
+ import {
14
+ createRetryApiCall,
15
+ createSwallowingRetryApiCall,
16
+ retryWithThreadFallback,
17
+ type RetryObserver,
18
+ } from '../retry-api-call.js'
14
19
  import { errors, makeGrammyError } from './fake-bot-api.js'
15
20
 
16
21
  // vitest's vi.advanceTimersByTimeAsync isn't implemented by Bun's test runner.
@@ -285,3 +290,149 @@ describe('retryApiCall', () => {
285
290
  })
286
291
  })
287
292
  })
293
+
294
+ // #1075 — coverage for the swallow + thread-fallback helpers that wrap
295
+ // the retry policy for the six previously-unwrapped outbound surfaces.
296
+ describe('createSwallowingRetryApiCall (#1075)', () => {
297
+ beforeEach(() => {
298
+ vi.useFakeTimers()
299
+ })
300
+ afterEach(() => {
301
+ vi.useRealTimers()
302
+ })
303
+
304
+ it('resolves to the underlying value on success', async () => {
305
+ const retry = createRetryApiCall()
306
+ const swallow = createSwallowingRetryApiCall(retry)
307
+ const fn = vi.fn<() => Promise<string>>().mockResolvedValue('ok')
308
+ const result = await swallow(fn)
309
+ expect(result).toBe('ok')
310
+ })
311
+
312
+ it('returns undefined and logs when THREAD_NOT_FOUND fires', async () => {
313
+ const retry = createRetryApiCall()
314
+ const log = vi.fn()
315
+ const swallow = createSwallowingRetryApiCall(retry, log)
316
+ const fn = vi.fn<() => Promise<void>>().mockRejectedValueOnce(errors.threadNotFound())
317
+ const result = await swallow(fn, { threadId: 42, chat_id: 'c', verb: 'test.send' })
318
+ expect(result).toBeUndefined()
319
+ expect(log).toHaveBeenCalledWith(expect.stringMatching(/test\.send.*THREAD_NOT_FOUND/))
320
+ expect(fn).toHaveBeenCalledTimes(1)
321
+ })
322
+
323
+ it('returns undefined and logs on 403 forbidden', async () => {
324
+ const retry = createRetryApiCall()
325
+ const log = vi.fn()
326
+ const swallow = createSwallowingRetryApiCall(retry, log)
327
+ const fn = vi.fn<() => Promise<void>>().mockRejectedValueOnce(errors.forbidden())
328
+ const result = await swallow(fn, { chat_id: 'c', verb: 'forbidden.send' })
329
+ expect(result).toBeUndefined()
330
+ expect(log).toHaveBeenCalled()
331
+ })
332
+
333
+ it('returns undefined on benign not-modified (passes through retry semantics)', async () => {
334
+ const retry = createRetryApiCall()
335
+ const swallow = createSwallowingRetryApiCall(retry)
336
+ const fn = vi
337
+ .fn<() => Promise<void>>()
338
+ .mockRejectedValueOnce(errors.badRequest('Bad Request: message is not modified'))
339
+ const result = await swallow(fn)
340
+ // retry already swallows benign 400s to undefined, so swallowing wrapper
341
+ // resolves to undefined cleanly with NO error log fired.
342
+ expect(result).toBeUndefined()
343
+ })
344
+ })
345
+
346
+ describe('retryWithThreadFallback (#1075)', () => {
347
+ beforeEach(() => {
348
+ vi.useFakeTimers()
349
+ })
350
+ afterEach(() => {
351
+ vi.useRealTimers()
352
+ })
353
+
354
+ it('resolves on success with threadId passed through', async () => {
355
+ const retry = createRetryApiCall()
356
+ const send = vi
357
+ .fn<(tid: number | undefined) => Promise<{ message_id: number }>>()
358
+ .mockResolvedValue({ message_id: 1 })
359
+ const result = await retryWithThreadFallback(retry, send, {
360
+ threadId: 42,
361
+ chat_id: 'c',
362
+ verb: 'fallback.test',
363
+ })
364
+ expect(result.message_id).toBe(1)
365
+ expect(send).toHaveBeenCalledWith(42)
366
+ })
367
+
368
+ it('drops the thread id and retries once on THREAD_NOT_FOUND', async () => {
369
+ const retry = createRetryApiCall()
370
+ const send = vi
371
+ .fn<(tid: number | undefined) => Promise<{ message_id: number }>>()
372
+ .mockRejectedValueOnce(errors.threadNotFound())
373
+ .mockResolvedValueOnce({ message_id: 2 })
374
+ const result = await retryWithThreadFallback(retry, send, {
375
+ threadId: 42,
376
+ chat_id: 'c',
377
+ })
378
+ expect(result.message_id).toBe(2)
379
+ expect(send).toHaveBeenCalledTimes(2)
380
+ expect(send.mock.calls[0][0]).toBe(42)
381
+ // Fallback call must drop the thread id.
382
+ expect(send.mock.calls[1][0]).toBeUndefined()
383
+ })
384
+
385
+ it('propagates non-thread-not-found errors without retry', async () => {
386
+ const retry = createRetryApiCall()
387
+ const send = vi
388
+ .fn<(tid: number | undefined) => Promise<{ message_id: number }>>()
389
+ .mockRejectedValueOnce(errors.forbidden())
390
+ await expect(
391
+ retryWithThreadFallback(retry, send, { threadId: 42, chat_id: 'c' }),
392
+ ).rejects.toMatchObject({ error_code: 403 })
393
+ expect(send).toHaveBeenCalledTimes(1)
394
+ })
395
+
396
+ it('handles delete on a thread-bearing message (THREAD_NOT_FOUND coverage for delete)', async () => {
397
+ const retry = createRetryApiCall()
398
+ const send = vi
399
+ .fn<(tid: number | undefined) => Promise<boolean>>()
400
+ .mockRejectedValueOnce(errors.threadNotFound())
401
+ .mockResolvedValueOnce(true)
402
+ const result = await retryWithThreadFallback(retry, send, {
403
+ threadId: 99,
404
+ chat_id: 'c',
405
+ verb: 'deleteMessage',
406
+ })
407
+ expect(result).toBe(true)
408
+ expect(send).toHaveBeenCalledTimes(2)
409
+ })
410
+
411
+ it('handles pin on a thread-bearing message', async () => {
412
+ const retry = createRetryApiCall()
413
+ const send = vi
414
+ .fn<(tid: number | undefined) => Promise<boolean>>()
415
+ .mockRejectedValueOnce(errors.threadNotFound())
416
+ .mockResolvedValueOnce(true)
417
+ const result = await retryWithThreadFallback(retry, send, {
418
+ threadId: 7,
419
+ chat_id: 'c',
420
+ verb: 'pinChatMessage',
421
+ })
422
+ expect(result).toBe(true)
423
+ })
424
+
425
+ it('handles edit on a thread-bearing message', async () => {
426
+ const retry = createRetryApiCall()
427
+ const send = vi
428
+ .fn<(tid: number | undefined) => Promise<boolean>>()
429
+ .mockRejectedValueOnce(errors.threadNotFound())
430
+ .mockResolvedValueOnce(true)
431
+ const result = await retryWithThreadFallback(retry, send, {
432
+ threadId: 7,
433
+ chat_id: 'c',
434
+ verb: 'editMessageText',
435
+ })
436
+ expect(result).toBe(true)
437
+ })
438
+ })
@@ -0,0 +1,145 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { mkdtempSync, readFileSync, rmSync, existsSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import {
6
+ emitRuntimeMetric,
7
+ __setRuntimeMetricsPathForTests,
8
+ __getRuntimeMetricsPathForTests,
9
+ __isJsonlEnabledForTests,
10
+ } from '../runtime-metrics.js'
11
+
12
+ let tmpDir: string
13
+ let metricsPath: string
14
+ const ORIGINAL_TELEMETRY = process.env.SWITCHROOM_TELEMETRY_DISABLED
15
+ const ORIGINAL_JSONL_DISABLED = process.env.SWITCHROOM_RUNTIME_METRICS_JSONL_DISABLED
16
+
17
+ beforeEach(() => {
18
+ tmpDir = mkdtempSync(join(tmpdir(), 'runtime-metrics-test-'))
19
+ metricsPath = join(tmpDir, 'runtime-metrics.jsonl')
20
+ __setRuntimeMetricsPathForTests(metricsPath)
21
+ // Disable PostHog for unit tests — we only want to exercise the JSONL sink.
22
+ // Real PostHog wiring is covered indirectly by analytics-posthog itself.
23
+ process.env.SWITCHROOM_TELEMETRY_DISABLED = '1'
24
+ delete process.env.SWITCHROOM_RUNTIME_METRICS_JSONL_DISABLED
25
+ })
26
+
27
+ afterEach(() => {
28
+ __setRuntimeMetricsPathForTests(null)
29
+ rmSync(tmpDir, { recursive: true, force: true })
30
+ if (ORIGINAL_TELEMETRY != null) process.env.SWITCHROOM_TELEMETRY_DISABLED = ORIGINAL_TELEMETRY
31
+ else delete process.env.SWITCHROOM_TELEMETRY_DISABLED
32
+ if (ORIGINAL_JSONL_DISABLED != null) process.env.SWITCHROOM_RUNTIME_METRICS_JSONL_DISABLED = ORIGINAL_JSONL_DISABLED
33
+ else delete process.env.SWITCHROOM_RUNTIME_METRICS_JSONL_DISABLED
34
+ })
35
+
36
+ describe('runtime-metrics — JSONL sink', () => {
37
+ it('writes one JSON line per event', () => {
38
+ emitRuntimeMetric({
39
+ kind: 'inbound_status_query',
40
+ chat_id: '123',
41
+ message_id: 42,
42
+ thread_id: null,
43
+ text_length: 7,
44
+ prior_turn_in_flight: true,
45
+ seconds_since_turn_start: 12,
46
+ })
47
+ emitRuntimeMetric({
48
+ kind: 'turn_started',
49
+ chat_id: '123',
50
+ message_id: 43,
51
+ thread_id: null,
52
+ inbound_classified_as_status_query: false,
53
+ })
54
+ const raw = readFileSync(metricsPath, 'utf-8')
55
+ const lines = raw.trim().split('\n')
56
+ expect(lines).toHaveLength(2)
57
+ const first = JSON.parse(lines[0]!)
58
+ expect(first.kind).toBe('inbound_status_query')
59
+ expect(first.chat_id).toBe('123')
60
+ expect(first.message_id).toBe(42)
61
+ expect(first.text_length).toBe(7)
62
+ expect(typeof first.ts).toBe('number')
63
+ })
64
+
65
+ it('turn_ended carries TTFO + outbound gap fields', () => {
66
+ emitRuntimeMetric({
67
+ kind: 'turn_ended',
68
+ chat_id: 'c1',
69
+ thread_id: 7,
70
+ duration_ms: 8400,
71
+ ttfo_ms: 1200,
72
+ outbound_count: 3,
73
+ longest_silent_gap_ms: 5500,
74
+ ended_via: 'reply',
75
+ })
76
+ const raw = readFileSync(metricsPath, 'utf-8')
77
+ const parsed = JSON.parse(raw.trim())
78
+ expect(parsed.kind).toBe('turn_ended')
79
+ expect(parsed.ttfo_ms).toBe(1200)
80
+ expect(parsed.outbound_count).toBe(3)
81
+ expect(parsed.longest_silent_gap_ms).toBe(5500)
82
+ expect(parsed.ended_via).toBe('reply')
83
+ })
84
+
85
+ it('appends — does not overwrite — across calls', () => {
86
+ for (let i = 0; i < 5; i++) {
87
+ emitRuntimeMetric({
88
+ kind: 'turn_started',
89
+ chat_id: 'c1',
90
+ message_id: i,
91
+ thread_id: null,
92
+ inbound_classified_as_status_query: false,
93
+ })
94
+ }
95
+ const raw = readFileSync(metricsPath, 'utf-8')
96
+ const lines = raw.trim().split('\n')
97
+ expect(lines).toHaveLength(5)
98
+ })
99
+
100
+ it('creates the parent directory if missing', () => {
101
+ const nested = join(tmpDir, 'a', 'b', 'c', 'runtime-metrics.jsonl')
102
+ __setRuntimeMetricsPathForTests(nested)
103
+ emitRuntimeMetric({
104
+ kind: 'turn_started',
105
+ chat_id: 'c1',
106
+ message_id: 1,
107
+ thread_id: null,
108
+ inbound_classified_as_status_query: false,
109
+ })
110
+ expect(existsSync(nested)).toBe(true)
111
+ })
112
+
113
+ it('SWITCHROOM_RUNTIME_METRICS_JSONL_DISABLED=1 skips the JSONL write', () => {
114
+ process.env.SWITCHROOM_RUNTIME_METRICS_JSONL_DISABLED = '1'
115
+ expect(__isJsonlEnabledForTests()).toBe(false)
116
+ emitRuntimeMetric({
117
+ kind: 'turn_started',
118
+ chat_id: 'c1',
119
+ message_id: 1,
120
+ thread_id: null,
121
+ inbound_classified_as_status_query: false,
122
+ })
123
+ expect(existsSync(metricsPath)).toBe(false)
124
+ })
125
+
126
+ it('resolves SWITCHROOM_RUNTIME_METRICS_PATH override', () => {
127
+ const overridePath = join(tmpDir, 'override.jsonl')
128
+ __setRuntimeMetricsPathForTests(overridePath)
129
+ expect(__getRuntimeMetricsPathForTests()).toBe(overridePath)
130
+ })
131
+
132
+ it('emit never throws even if all sinks are disabled', () => {
133
+ process.env.SWITCHROOM_RUNTIME_METRICS_JSONL_DISABLED = '1'
134
+ process.env.SWITCHROOM_TELEMETRY_DISABLED = '1'
135
+ expect(() => {
136
+ emitRuntimeMetric({
137
+ kind: 'turn_started',
138
+ chat_id: 'c1',
139
+ message_id: 1,
140
+ thread_id: null,
141
+ inbound_classified_as_status_query: false,
142
+ })
143
+ }).not.toThrow()
144
+ })
145
+ })