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
@@ -18,6 +18,7 @@ import { describe, it, expect, vi } from 'vitest'
18
18
  import {
19
19
  isBootNetworkError,
20
20
  gatewayStartupRetry,
21
+ classifyStartupError,
21
22
  STARTUP_RETRY_DELAYS_MS,
22
23
  } from '../gateway/startup-network-retry'
23
24
 
@@ -76,6 +77,57 @@ describe('isBootNetworkError', () => {
76
77
  })
77
78
  })
78
79
 
80
+ // ── classifyStartupError ──────────────────────────────────────────────────────
81
+
82
+ describe('classifyStartupError', () => {
83
+ it('classifies grammy 401 (GrammyError, error_code=401) as unauthorized', () => {
84
+ const err = Object.assign(new Error('Unauthorized'), {
85
+ name: 'GrammyError',
86
+ error_code: 401,
87
+ })
88
+ expect(classifyStartupError(err)).toBe('unauthorized')
89
+ })
90
+
91
+ it('classifies an Unauthorized-message error as unauthorized — defence in depth', () => {
92
+ expect(classifyStartupError(new Error('Unauthorized'))).toBe('unauthorized')
93
+ })
94
+
95
+ it('does NOT mis-classify a network error mentioning "401" port as unauthorized', () => {
96
+ // Hypothetical message that happens to contain "401" but isn't a
97
+ // 401 status. classifyStartupError matches on the literal token
98
+ // "Unauthorized" rather than the substring "401" to avoid this.
99
+ expect(classifyStartupError(new Error('connect ECONNREFUSED 10.0.0.1:401'))).toBe('network')
100
+ })
101
+
102
+ it('classifies HttpError as network', () => {
103
+ const err = Object.assign(new Error('Network request failed'), {
104
+ name: 'HttpError',
105
+ })
106
+ expect(classifyStartupError(err)).toBe('network')
107
+ })
108
+
109
+ it('classifies ETIMEDOUT as network', () => {
110
+ expect(classifyStartupError(new Error('connect ETIMEDOUT 1.2.3.4:443'))).toBe('network')
111
+ })
112
+
113
+ it('classifies a bare app error as other', () => {
114
+ expect(classifyStartupError(new Error('something else'))).toBe('other')
115
+ })
116
+
117
+ it('classifies non-Error values as other', () => {
118
+ expect(classifyStartupError('string')).toBe('other')
119
+ expect(classifyStartupError(null)).toBe('other')
120
+ })
121
+
122
+ it('classifies a GrammyError 403 (kicked) as other — surfaces as a fatal rethrow', () => {
123
+ const err = Object.assign(new Error('Forbidden: bot was kicked'), {
124
+ name: 'GrammyError',
125
+ error_code: 403,
126
+ })
127
+ expect(classifyStartupError(err)).toBe('other')
128
+ })
129
+ })
130
+
79
131
  // ── gatewayStartupRetry ───────────────────────────────────────────────────────
80
132
 
81
133
  describe('gatewayStartupRetry', () => {
@@ -162,6 +214,58 @@ describe('gatewayStartupRetry', () => {
162
214
  expect(STARTUP_RETRY_DELAYS_MS[STARTUP_RETRY_DELAYS_MS.length - 1]).toBe(64_000)
163
215
  })
164
216
 
217
+ it('calls onUnauthorized (not onExhausted, not rethrow) on a 401 — #1076', async () => {
218
+ // Grammy surfaces 401 via GrammyError with error_code=401.
219
+ const authErr = Object.assign(new Error('Unauthorized'), {
220
+ name: 'GrammyError',
221
+ error_code: 401,
222
+ })
223
+ const fn = vi.fn().mockRejectedValue(authErr)
224
+ const onUnauthorized = vi.fn(() => {
225
+ throw new Error('__quarantined__')
226
+ }) as unknown as (err: unknown) => never
227
+ const onExhausted = vi.fn(() => {
228
+ throw new Error('__exhausted__')
229
+ }) as unknown as (err: unknown) => never
230
+ const sleep = vi.fn().mockResolvedValue(undefined)
231
+
232
+ await expect(
233
+ gatewayStartupRetry(fn, {
234
+ delaysMs: [100, 200, 400],
235
+ sleep,
236
+ onUnauthorized,
237
+ onExhausted,
238
+ log: noopLog,
239
+ }),
240
+ ).rejects.toThrow('__quarantined__')
241
+
242
+ // 401 short-circuits — only one fn() call, no retries, no exhaustion path.
243
+ expect(fn).toHaveBeenCalledTimes(1)
244
+ expect(sleep).not.toHaveBeenCalled()
245
+ expect(onExhausted).not.toHaveBeenCalled()
246
+ expect(onUnauthorized).toHaveBeenCalledTimes(1)
247
+ expect(onUnauthorized).toHaveBeenCalledWith(authErr)
248
+ })
249
+
250
+ it('classifies a 401-message-only error (no error_code) as unauthorized — defence in depth', async () => {
251
+ // Some fetch wrappers / test fixtures surface 401 only in the message.
252
+ const authErr = new Error('Unauthorized')
253
+ const fn = vi.fn().mockRejectedValue(authErr)
254
+ const onUnauthorized = vi.fn(() => {
255
+ throw new Error('__quarantined__')
256
+ }) as unknown as (err: unknown) => never
257
+
258
+ await expect(
259
+ gatewayStartupRetry(fn, {
260
+ delaysMs: [1, 2],
261
+ sleep: vi.fn(),
262
+ onUnauthorized,
263
+ log: noopLog,
264
+ }),
265
+ ).rejects.toThrow('__quarantined__')
266
+ expect(onUnauthorized).toHaveBeenCalledTimes(1)
267
+ })
268
+
165
269
  it('logs retry progress before each sleep', async () => {
166
270
  const networkErr = Object.assign(new Error('Network request failed'), {
167
271
  name: 'HttpError',
@@ -0,0 +1,378 @@
1
+ /**
2
+ * Tests for the history reaper (#1073).
3
+ *
4
+ * Covers:
5
+ * - `pruneSubagentsOlderThan` / `pruneTurnsOlderThan` correctness:
6
+ * rows below the cutoff are deleted, rows above are preserved,
7
+ * coalesce semantics (ended_at | last_activity_at | started_at)
8
+ * are honored.
9
+ * - Batch-loop bounded scan: 6000-row backlog drains across multiple
10
+ * batches when batchLimit=2000.
11
+ * - WAL checkpoint runs (file-backed DB; assert .db-wal exists and
12
+ * shrinks, or at least that checkpoint reports success).
13
+ * - `resolveRetentionDays`: env > access > default precedence, plus
14
+ * guards against invalid env values.
15
+ * - `pruneMessagesOlderThanDays` on the history DB respects the
16
+ * batch cap and preserves recent rows.
17
+ *
18
+ * Runs under bun (uses bun:sqlite via the schema modules' lazy loader).
19
+ */
20
+
21
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
22
+ import { mkdtempSync, rmSync, existsSync, statSync } from 'fs'
23
+ import { tmpdir } from 'os'
24
+ import { join } from 'path'
25
+ import {
26
+ pruneSubagentsOlderThan,
27
+ pruneTurnsOlderThan,
28
+ runRegistryReaper,
29
+ resolveRetentionDays,
30
+ DEFAULT_RETENTION_DAYS,
31
+ } from '../registry/reaper.js'
32
+ import {
33
+ openSubagentsDbInMemory,
34
+ recordSubagentStart,
35
+ } from '../registry/subagents-schema.js'
36
+ import {
37
+ initHistory,
38
+ recordInbound,
39
+ pruneMessagesOlderThanDays,
40
+ query as queryHistory,
41
+ _resetForTests as resetHistory,
42
+ } from '../history.js'
43
+
44
+ // `bun:sqlite` for direct file-backed DB tests (WAL inspection).
45
+ // Same lazy-load pattern as the schemas use, but inline here.
46
+ type SqliteDatabase = {
47
+ exec(sql: string): void
48
+ prepare(sql: string): {
49
+ run(...params: unknown[]): unknown
50
+ all(...params: unknown[]): unknown[]
51
+ get(...params: unknown[]): unknown
52
+ }
53
+ close(): void
54
+ }
55
+ type SqliteDatabaseCtor = new (path: string, opts?: { create?: boolean }) => SqliteDatabase
56
+ function bunSqlite(): SqliteDatabaseCtor {
57
+ const metaRequire = (import.meta as { require?: (id: string) => unknown }).require
58
+ if (typeof metaRequire !== 'function') throw new Error('bun runtime required')
59
+ const mod = metaRequire('bun:sqlite') as { Database: SqliteDatabaseCtor }
60
+ return mod.Database
61
+ }
62
+
63
+ const DAY_MS = 86_400_000
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // pruneSubagentsOlderThan
67
+ // ---------------------------------------------------------------------------
68
+
69
+ describe('pruneSubagentsOlderThan', () => {
70
+ it('deletes rows older than the cutoff and preserves recent ones', () => {
71
+ const db = openSubagentsDbInMemory()
72
+ const now = 1_000_000_000_000
73
+ // Three rows: 30 days ago, 5 days ago, 1 day ago.
74
+ recordSubagentStart(db, { id: 'old', background: true, startedAt: now - 30 * DAY_MS })
75
+ recordSubagentStart(db, { id: 'mid', background: true, startedAt: now - 5 * DAY_MS })
76
+ recordSubagentStart(db, { id: 'new', background: true, startedAt: now - 1 * DAY_MS })
77
+
78
+ // Cutoff: 14 days ago. Only `old` should go.
79
+ const cutoff = now - 14 * DAY_MS
80
+ const result = pruneSubagentsOlderThan(db, cutoff)
81
+ expect(result.deleted).toBe(1)
82
+
83
+ const remaining = db.prepare('SELECT id FROM subagents ORDER BY started_at').all() as Array<{ id: string }>
84
+ expect(remaining.map((r) => r.id)).toEqual(['mid', 'new'])
85
+ })
86
+
87
+ it('uses COALESCE(ended_at, last_activity_at, started_at)', () => {
88
+ const db = openSubagentsDbInMemory()
89
+ const now = 1_000_000_000_000
90
+ // started_at is ancient but last_activity_at is recent — should NOT prune.
91
+ recordSubagentStart(db, { id: 'still-active', background: true, startedAt: now - 60 * DAY_MS })
92
+ db.prepare('UPDATE subagents SET last_activity_at = ? WHERE id = ?').run(now - 2 * DAY_MS, 'still-active')
93
+
94
+ // started_at + last_activity_at ancient but ended_at recent — should NOT prune.
95
+ recordSubagentStart(db, { id: 'recent-end', background: true, startedAt: now - 60 * DAY_MS })
96
+ db.prepare('UPDATE subagents SET last_activity_at = ?, ended_at = ?, status = ? WHERE id = ?')
97
+ .run(now - 50 * DAY_MS, now - 2 * DAY_MS, 'completed', 'recent-end')
98
+
99
+ // Truly old — all three timestamps are ancient.
100
+ recordSubagentStart(db, { id: 'truly-old', background: true, startedAt: now - 60 * DAY_MS })
101
+ db.prepare('UPDATE subagents SET last_activity_at = ?, ended_at = ?, status = ? WHERE id = ?')
102
+ .run(now - 59 * DAY_MS, now - 58 * DAY_MS, 'completed', 'truly-old')
103
+
104
+ const cutoff = now - 14 * DAY_MS
105
+ const result = pruneSubagentsOlderThan(db, cutoff)
106
+ expect(result.deleted).toBe(1)
107
+
108
+ const ids = (db.prepare('SELECT id FROM subagents ORDER BY id').all() as Array<{ id: string }>).map((r) => r.id)
109
+ expect(ids).toEqual(['recent-end', 'still-active'])
110
+ })
111
+
112
+ it('batches a large backlog and drains it across iterations', () => {
113
+ const db = openSubagentsDbInMemory()
114
+ const now = 1_000_000_000_000
115
+ const insert = db.prepare(`
116
+ INSERT INTO subagents
117
+ (id, background, started_at, last_activity_at, status)
118
+ VALUES (?, 1, ?, ?, 'running')
119
+ `)
120
+ const tx = (db as unknown as { transaction: (fn: (n: number) => void) => (n: number) => void })
121
+ .transaction((n: number) => {
122
+ for (let i = 0; i < n; i++) {
123
+ insert.run(`old-${i}`, now - 30 * DAY_MS, now - 30 * DAY_MS)
124
+ }
125
+ })
126
+ tx(6000)
127
+ // Plus 50 recent rows.
128
+ for (let i = 0; i < 50; i++) {
129
+ insert.run(`new-${i}`, now - 1 * DAY_MS, now - 1 * DAY_MS)
130
+ }
131
+
132
+ const cutoff = now - 14 * DAY_MS
133
+ const result = pruneSubagentsOlderThan(db, cutoff, 2000)
134
+ expect(result.deleted).toBe(6000)
135
+ // 6000 / 2000 = 3 full batches + a final 0-row sentinel batch.
136
+ expect(result.batches).toBeGreaterThanOrEqual(3)
137
+
138
+ const remaining = db.prepare('SELECT COUNT(*) as c FROM subagents').get() as { c: number }
139
+ expect(remaining.c).toBe(50)
140
+ })
141
+ })
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // pruneTurnsOlderThan
145
+ // ---------------------------------------------------------------------------
146
+
147
+ describe('pruneTurnsOlderThan', () => {
148
+ it('deletes turns older than the cutoff, preserves recent and open turns', () => {
149
+ const db = openSubagentsDbInMemory()
150
+ const now = 1_000_000_000_000
151
+
152
+ const insert = db.prepare(`
153
+ INSERT INTO turns
154
+ (turn_key, chat_id, started_at, ended_at, created_at, updated_at)
155
+ VALUES (?, ?, ?, ?, ?, ?)
156
+ `)
157
+ // ancient + ended → prune
158
+ insert.run('old-ended', '-100', now - 60 * DAY_MS, now - 59 * DAY_MS, now - 60 * DAY_MS, now - 59 * DAY_MS)
159
+ // ancient + open → COALESCE falls through to started_at, also prunes
160
+ insert.run('old-open', '-100', now - 60 * DAY_MS, null, now - 60 * DAY_MS, now - 60 * DAY_MS)
161
+ // recent + ended → preserve
162
+ insert.run('recent', '-100', now - 1 * DAY_MS, now - 1 * DAY_MS, now - 1 * DAY_MS, now - 1 * DAY_MS)
163
+ // ancient started but ended recently → COALESCE picks ended_at, preserves
164
+ insert.run('long-running', '-100', now - 60 * DAY_MS, now - 1 * DAY_MS, now - 60 * DAY_MS, now - 1 * DAY_MS)
165
+
166
+ const cutoff = now - 14 * DAY_MS
167
+ const result = pruneTurnsOlderThan(db, cutoff)
168
+ expect(result.deleted).toBe(2)
169
+
170
+ const keys = (db.prepare('SELECT turn_key FROM turns ORDER BY turn_key').all() as Array<{ turn_key: string }>).map((r) => r.turn_key)
171
+ expect(keys).toEqual(['long-running', 'recent'])
172
+ })
173
+ })
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // runRegistryReaper + WAL checkpoint
177
+ // ---------------------------------------------------------------------------
178
+
179
+ describe('runRegistryReaper', () => {
180
+ let tmpDir: string
181
+
182
+ beforeEach(() => {
183
+ tmpDir = mkdtempSync(join(tmpdir(), 'reaper-test-'))
184
+ })
185
+
186
+ afterEach(() => {
187
+ if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true })
188
+ })
189
+
190
+ it('prunes both tables and reports a successful WAL checkpoint', () => {
191
+ const Database = bunSqlite()
192
+ const dbPath = join(tmpDir, 'registry.db')
193
+ const db = new Database(dbPath, { create: true })
194
+ db.exec('PRAGMA journal_mode = WAL')
195
+ // Apply both schemas inline (mirrors openTurnsDb + applySubagentsSchema).
196
+ db.exec(`
197
+ CREATE TABLE turns (
198
+ turn_key TEXT PRIMARY KEY,
199
+ chat_id TEXT NOT NULL,
200
+ thread_id TEXT,
201
+ started_at INTEGER NOT NULL,
202
+ ended_at INTEGER,
203
+ ended_via TEXT,
204
+ last_assistant_msg_id TEXT,
205
+ last_assistant_done INTEGER,
206
+ last_user_msg_id TEXT,
207
+ user_prompt_preview TEXT,
208
+ assistant_reply_preview TEXT,
209
+ tool_call_count INTEGER,
210
+ created_at INTEGER NOT NULL,
211
+ updated_at INTEGER NOT NULL
212
+ );
213
+ CREATE TABLE subagents (
214
+ id TEXT PRIMARY KEY,
215
+ parent_session_id TEXT,
216
+ parent_turn_key TEXT,
217
+ agent_type TEXT,
218
+ description TEXT,
219
+ background INTEGER NOT NULL,
220
+ started_at INTEGER NOT NULL,
221
+ last_activity_at INTEGER,
222
+ ended_at INTEGER,
223
+ status TEXT NOT NULL,
224
+ result_summary TEXT,
225
+ jsonl_agent_id TEXT
226
+ );
227
+ `)
228
+
229
+ const now = Date.now()
230
+ db.prepare(`
231
+ INSERT INTO subagents (id, background, started_at, last_activity_at, status)
232
+ VALUES ('old', 1, ?, ?, 'running')
233
+ `).run(now - 30 * DAY_MS, now - 30 * DAY_MS)
234
+ db.prepare(`
235
+ INSERT INTO turns (turn_key, chat_id, started_at, ended_at, created_at, updated_at)
236
+ VALUES ('old-turn', '-1', ?, ?, ?, ?)
237
+ `).run(now - 30 * DAY_MS, now - 30 * DAY_MS, now - 30 * DAY_MS, now - 30 * DAY_MS)
238
+
239
+ // Force some WAL activity before the checkpoint so there's something to flush.
240
+ db.prepare(`INSERT INTO turns (turn_key, chat_id, started_at, created_at, updated_at) VALUES ('recent', '-1', ?, ?, ?)`)
241
+ .run(now, now, now)
242
+
243
+ const walPath = `${dbPath}-wal`
244
+ expect(existsSync(walPath)).toBe(true)
245
+ const walSizeBefore = statSync(walPath).size
246
+ expect(walSizeBefore).toBeGreaterThan(0)
247
+
248
+ const result = runRegistryReaper(db, { retentionDays: 14, now })
249
+ expect(result.subagents.deleted).toBe(1)
250
+ expect(result.turns.deleted).toBe(1)
251
+ expect(result.walCheckpointed).toBe(true)
252
+
253
+ // After TRUNCATE checkpoint, the WAL file is truncated to zero bytes.
254
+ // (Strict equality may vary across SQLite builds, but the post-truncate
255
+ // size must be strictly less than the pre-truncate size.)
256
+ const walSizeAfter = statSync(walPath).size
257
+ expect(walSizeAfter).toBeLessThan(walSizeBefore)
258
+
259
+ db.close()
260
+ })
261
+ })
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // resolveRetentionDays
265
+ // ---------------------------------------------------------------------------
266
+
267
+ describe('resolveRetentionDays', () => {
268
+ const savedEnv = process.env.HISTORY_RETENTION_DAYS
269
+
270
+ afterEach(() => {
271
+ if (savedEnv === undefined) delete process.env.HISTORY_RETENTION_DAYS
272
+ else process.env.HISTORY_RETENTION_DAYS = savedEnv
273
+ })
274
+
275
+ it('returns DEFAULT_RETENTION_DAYS when nothing is set', () => {
276
+ delete process.env.HISTORY_RETENTION_DAYS
277
+ expect(resolveRetentionDays()).toBe(DEFAULT_RETENTION_DAYS)
278
+ expect(DEFAULT_RETENTION_DAYS).toBe(14)
279
+ })
280
+
281
+ it('prefers env over access', () => {
282
+ process.env.HISTORY_RETENTION_DAYS = '1'
283
+ expect(resolveRetentionDays(30)).toBe(1)
284
+ })
285
+
286
+ it('falls back to access when env is missing', () => {
287
+ delete process.env.HISTORY_RETENTION_DAYS
288
+ expect(resolveRetentionDays(7)).toBe(7)
289
+ })
290
+
291
+ it('rejects invalid env values', () => {
292
+ process.env.HISTORY_RETENTION_DAYS = 'abc'
293
+ expect(resolveRetentionDays(7)).toBe(7)
294
+ process.env.HISTORY_RETENTION_DAYS = '0'
295
+ expect(resolveRetentionDays(7)).toBe(7)
296
+ process.env.HISTORY_RETENTION_DAYS = '-5'
297
+ expect(resolveRetentionDays(7)).toBe(7)
298
+ })
299
+
300
+ it('rejects invalid access values', () => {
301
+ delete process.env.HISTORY_RETENTION_DAYS
302
+ expect(resolveRetentionDays(0)).toBe(DEFAULT_RETENTION_DAYS)
303
+ expect(resolveRetentionDays(-1)).toBe(DEFAULT_RETENTION_DAYS)
304
+ expect(resolveRetentionDays(NaN)).toBe(DEFAULT_RETENTION_DAYS)
305
+ })
306
+ })
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // history.ts: pruneMessagesOlderThanDays
310
+ // ---------------------------------------------------------------------------
311
+
312
+ describe('pruneMessagesOlderThanDays', () => {
313
+ let stateDir: string
314
+
315
+ beforeEach(() => {
316
+ stateDir = mkdtempSync(join(tmpdir(), 'history-reaper-test-'))
317
+ initHistory(stateDir, 0) // 0 disables the init-time prune so we can seed cleanly
318
+ })
319
+
320
+ afterEach(() => {
321
+ resetHistory()
322
+ if (existsSync(stateDir)) rmSync(stateDir, { recursive: true, force: true })
323
+ })
324
+
325
+ it('deletes only messages older than retentionDays', () => {
326
+ const nowSec = 2_000_000_000
327
+ // Old (60d ago), borderline (15d ago), recent (1d ago).
328
+ recordInbound({
329
+ chat_id: '-100', thread_id: null, message_id: 1, user: 'u', user_id: '1',
330
+ ts: nowSec - 60 * 86400, text: 'old',
331
+ })
332
+ recordInbound({
333
+ chat_id: '-100', thread_id: null, message_id: 2, user: 'u', user_id: '1',
334
+ ts: nowSec - 15 * 86400, text: 'mid',
335
+ })
336
+ recordInbound({
337
+ chat_id: '-100', thread_id: null, message_id: 3, user: 'u', user_id: '1',
338
+ ts: nowSec - 1 * 86400, text: 'new',
339
+ })
340
+
341
+ const deleted = pruneMessagesOlderThanDays(14, nowSec)
342
+ expect(deleted).toBe(2)
343
+
344
+ const remaining = queryHistory({ chat_id: '-100' })
345
+ expect(remaining.map((r) => r.text)).toEqual(['new'])
346
+ })
347
+
348
+ it('batches a >5k backlog and drains it', () => {
349
+ const nowSec = 2_000_000_000
350
+ for (let i = 0; i < 6000; i++) {
351
+ recordInbound({
352
+ chat_id: '-100', thread_id: null, message_id: i + 1, user: 'u', user_id: '1',
353
+ ts: nowSec - 60 * 86400, text: `old-${i}`,
354
+ })
355
+ }
356
+ for (let i = 0; i < 30; i++) {
357
+ recordInbound({
358
+ chat_id: '-100', thread_id: null, message_id: 100_000 + i, user: 'u', user_id: '1',
359
+ ts: nowSec - 1 * 86400, text: `new-${i}`,
360
+ })
361
+ }
362
+ const deleted = pruneMessagesOlderThanDays(14, nowSec, 2000)
363
+ expect(deleted).toBe(6000)
364
+ const remaining = queryHistory({ chat_id: '-100', limit: 50 })
365
+ expect(remaining.length).toBe(30)
366
+ })
367
+
368
+ it('respects retentionDays <= 0 as disabled', () => {
369
+ const nowSec = 2_000_000_000
370
+ recordInbound({
371
+ chat_id: '-100', thread_id: null, message_id: 1, user: 'u', user_id: '1',
372
+ ts: nowSec - 60 * 86400, text: 'old',
373
+ })
374
+ expect(pruneMessagesOlderThanDays(0, nowSec)).toBe(0)
375
+ expect(pruneMessagesOlderThanDays(-1, nowSec)).toBe(0)
376
+ expect(queryHistory({ chat_id: '-100' })).toHaveLength(1)
377
+ })
378
+ })
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Unit tests for `hostd-dispatch.ts` — the gateway's helper that routes
3
+ * self-restart slash-commands through the hostd UDS when enabled.
4
+ *
5
+ * The config-loading branches are validated by mocking
6
+ * `loadSwitchroomConfig` (the schema's complexity isn't this test's
7
+ * concern — we just need to feed it a known value). The wire-error
8
+ * branch is validated by pointing the helper at a nonexistent socket.
9
+ *
10
+ * The "actually hits a real hostd" path is covered in
11
+ * `tests/host-control/server.test.ts` end-to-end — we don't re-test
12
+ * the server here.
13
+ */
14
+
15
+ import {
16
+ describe,
17
+ it,
18
+ expect,
19
+ beforeEach,
20
+ afterEach,
21
+ vi,
22
+ } from "vitest";
23
+
24
+ const loadConfigMock = vi.fn();
25
+ vi.mock("../../src/config/loader.js", () => ({
26
+ loadConfig: loadConfigMock,
27
+ }));
28
+
29
+ // Import AFTER the mock so the module captures the mocked function.
30
+ const {
31
+ tryHostdDispatch,
32
+ hostdWillBeUsed,
33
+ isHostdEnabled,
34
+ hostdSocketPath,
35
+ _resetHostdEnabledCache,
36
+ } = await import("../gateway/hostd-dispatch.js");
37
+
38
+ beforeEach(() => {
39
+ _resetHostdEnabledCache();
40
+ loadConfigMock.mockReset();
41
+ });
42
+
43
+ afterEach(() => {
44
+ _resetHostdEnabledCache();
45
+ });
46
+
47
+ describe("isHostdEnabled() — config gate", () => {
48
+ it("returns false when host_control absent", () => {
49
+ loadConfigMock.mockReturnValue({});
50
+ expect(isHostdEnabled()).toBe(false);
51
+ });
52
+
53
+ it("returns false when host_control.enabled is false", () => {
54
+ loadConfigMock.mockReturnValue({ host_control: { enabled: false } });
55
+ expect(isHostdEnabled()).toBe(false);
56
+ });
57
+
58
+ it("returns true when host_control.enabled is true", () => {
59
+ loadConfigMock.mockReturnValue({ host_control: { enabled: true } });
60
+ expect(isHostdEnabled()).toBe(true);
61
+ });
62
+
63
+ it("returns false on config-load throw (best-effort fallback)", () => {
64
+ // Gateway runs in environments where the config may not be
65
+ // readable yet (very-early-boot, broken symlink). The helper must
66
+ // not propagate — it just disables the hostd path.
67
+ loadConfigMock.mockImplementation(() => {
68
+ throw new Error("config: file not found");
69
+ });
70
+ expect(isHostdEnabled()).toBe(false);
71
+ });
72
+
73
+ it("caches the result across calls (no re-read)", () => {
74
+ loadConfigMock.mockReturnValue({ host_control: { enabled: true } });
75
+ expect(isHostdEnabled()).toBe(true);
76
+ expect(isHostdEnabled()).toBe(true);
77
+ expect(isHostdEnabled()).toBe(true);
78
+ expect(loadConfigMock).toHaveBeenCalledTimes(1);
79
+ });
80
+ });
81
+
82
+ describe("hostdWillBeUsed() — config + socket existence", () => {
83
+ it("false when hostd disabled even if socket would be present", () => {
84
+ loadConfigMock.mockReturnValue({});
85
+ expect(hostdWillBeUsed("klanker")).toBe(false);
86
+ });
87
+
88
+ it("false when hostd enabled but per-agent socket isn't bound", () => {
89
+ loadConfigMock.mockReturnValue({ host_control: { enabled: true } });
90
+ // hostdSocketPath() is hard-coded to /run/switchroom/hostd/<name>/sock
91
+ // — that path doesn't exist in the test env, so existsSync returns
92
+ // false and hostdWillBeUsed is false.
93
+ expect(hostdWillBeUsed("klanker-no-such-agent")).toBe(false);
94
+ });
95
+ });
96
+
97
+ describe("tryHostdDispatch()", () => {
98
+ it("returns 'not-configured' when hostd disabled", async () => {
99
+ loadConfigMock.mockReturnValue({});
100
+ const result = await tryHostdDispatch("klanker", {
101
+ v: 1,
102
+ op: "agent_restart",
103
+ request_id: "test-1",
104
+ args: { name: "klanker", force: true },
105
+ });
106
+ expect(result).toBe("not-configured");
107
+ });
108
+
109
+ it("returns 'not-configured' when socket absent", async () => {
110
+ loadConfigMock.mockReturnValue({ host_control: { enabled: true } });
111
+ const result = await tryHostdDispatch("nonexistent-agent", {
112
+ v: 1,
113
+ op: "agent_restart",
114
+ request_id: "test-2",
115
+ args: { name: "nonexistent-agent", force: true },
116
+ });
117
+ expect(result).toBe("not-configured");
118
+ });
119
+
120
+ it("locks the socket-path contract", () => {
121
+ // RFC C pins this path. If the gateway and the compose generator
122
+ // drift apart on the bind path, the mount silently goes nowhere
123
+ // and every dispatch returns "not-configured". Catch any rename
124
+ // in lockstep with the compose-generator test.
125
+ expect(hostdSocketPath("klanker")).toBe(
126
+ "/run/switchroom/hostd/klanker/sock",
127
+ );
128
+ });
129
+ });