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
@@ -1,773 +0,0 @@
1
- /**
2
- * Integration tests for the pin-lifecycle manager.
3
- *
4
- * Previously this logic lived inline in gateway.ts (progressDriver
5
- * setup block) and was unreachable from tests — the full
6
- * first-emit → pin → edit → turn-end → unpin sequence had no direct
7
- * coverage. This suite pins all the behaviors the gateway depends on,
8
- * plus failure branches that only exist in production until now.
9
- */
10
-
11
- import { describe, it, expect, vi, beforeEach } from 'vitest'
12
- import {
13
- createPinManager,
14
- type PinManager,
15
- type PinManagerDeps,
16
- type ActivePinEntry,
17
- type TimerHandle,
18
- } from '../progress-card-pin-manager.js'
19
- import { errors } from './fake-bot-api.js'
20
-
21
- interface PendingTimer {
22
- fn: () => void
23
- ms: number
24
- cancelled: boolean
25
- fired: boolean
26
- }
27
-
28
- interface Harness {
29
- mgr: PinManager
30
- deps: {
31
- pin: ReturnType<typeof vi.fn>
32
- unpin: ReturnType<typeof vi.fn>
33
- deleteMessage: ReturnType<typeof vi.fn>
34
- addPin: ReturnType<typeof vi.fn>
35
- removePin: ReturnType<typeof vi.fn>
36
- log: ReturnType<typeof vi.fn>
37
- }
38
- /** Recorded sidecar state — tests assert on this directly. */
39
- sidecar: ActivePinEntry[]
40
- /** Captured pin-delay timers — tests fire them manually. */
41
- timers: PendingTimer[]
42
- /** Fire every pending (not-yet-fired, not-cancelled) timer. */
43
- fireTimers(): void
44
- }
45
-
46
- /** Build a harness with sensible defaults. `now` is fixed at 10_000. */
47
- function mkHarness(overrides: Partial<PinManagerDeps> = {}): Harness {
48
- const sidecar: ActivePinEntry[] = []
49
- const timers: PendingTimer[] = []
50
-
51
- const deps = {
52
- pin: vi.fn(async () => true),
53
- unpin: vi.fn(async () => true),
54
- deleteMessage: vi.fn(async () => true),
55
- addPin: vi.fn((entry: ActivePinEntry) => {
56
- sidecar.push(entry)
57
- }),
58
- removePin: vi.fn((chatId: string, messageId: number) => {
59
- const idx = sidecar.findIndex((e) => e.chatId === chatId && e.messageId === messageId)
60
- if (idx >= 0) sidecar.splice(idx, 1)
61
- }),
62
- log: vi.fn(),
63
- }
64
-
65
- const scheduleTimer = (fn: () => void, ms: number): TimerHandle => {
66
- const entry: PendingTimer = { fn, ms, cancelled: false, fired: false }
67
- timers.push(entry)
68
- return {
69
- cancel() {
70
- entry.cancelled = true
71
- },
72
- }
73
- }
74
-
75
- const mgr = createPinManager({
76
- ...deps,
77
- now: () => 10_000,
78
- scheduleTimer,
79
- ...overrides,
80
- })
81
-
82
- const fireTimers = (): void => {
83
- // Snapshot so timers pushed during firing don't run this pass.
84
- const snapshot = [...timers]
85
- for (const t of snapshot) {
86
- if (t.cancelled || t.fired) continue
87
- t.fired = true
88
- t.fn()
89
- }
90
- }
91
-
92
- return { mgr, deps, sidecar, timers, fireTimers }
93
- }
94
-
95
- describe('createPinManager', () => {
96
- describe('considerPin — first emit', () => {
97
- it('pins the message, records the sidecar entry, tracks the turnKey', async () => {
98
- const h = mkHarness()
99
- h.mgr.considerPin({
100
- chatId: 'chat-1',
101
- threadId: '42',
102
- turnKey: 'chat-1:42:1',
103
- messageId: 500,
104
- isFirstEmit: true,
105
- })
106
- h.fireTimers()
107
- await h.mgr.drainInFlight()
108
-
109
- // Bot API was called with the exact shape the gateway used inline.
110
- expect(h.deps.pin).toHaveBeenCalledWith('chat-1', 500, { disable_notification: true })
111
- expect(h.deps.pin).toHaveBeenCalledTimes(1)
112
- // Sidecar recorded the pin with the injected clock.
113
- expect(h.sidecar).toEqual([
114
- { chatId: 'chat-1', messageId: 500, turnKey: 'chat-1:42:1', pinnedAt: 10_000, agentId: '__parent__' },
115
- ])
116
- // In-memory index reflects the pin.
117
- expect(h.mgr.pinnedTurnKeys()).toEqual(['chat-1:42:1'])
118
- expect(h.mgr.pinnedMessageId('chat-1:42:1')).toBe(500)
119
- })
120
-
121
- it('ignores emits where isFirstEmit=false', async () => {
122
- const h = mkHarness()
123
- h.mgr.considerPin({
124
- chatId: 'chat-1',
125
- turnKey: 'chat-1:1',
126
- messageId: 500,
127
- isFirstEmit: false,
128
- })
129
- h.fireTimers()
130
- await h.mgr.drainInFlight()
131
-
132
- expect(h.deps.pin).not.toHaveBeenCalled()
133
- expect(h.deps.addPin).not.toHaveBeenCalled()
134
- expect(h.mgr.pinnedTurnKeys()).toEqual([])
135
- })
136
-
137
- it('is idempotent — a second isFirstEmit for the same turnKey does nothing', async () => {
138
- const h = mkHarness()
139
- const c = {
140
- chatId: 'c',
141
- turnKey: 'c:1',
142
- messageId: 500,
143
- isFirstEmit: true,
144
- }
145
- h.mgr.considerPin(c)
146
- h.mgr.considerPin({ ...c, messageId: 501 })
147
- h.fireTimers()
148
- await h.mgr.drainInFlight()
149
-
150
- // Only the first pin landed.
151
- expect(h.deps.pin).toHaveBeenCalledTimes(1)
152
- expect(h.deps.pin).toHaveBeenCalledWith('c', 500, { disable_notification: true })
153
- expect(h.deps.addPin).toHaveBeenCalledTimes(1)
154
- expect(h.mgr.pinnedMessageId('c:1')).toBe(500)
155
- })
156
-
157
- it('different turnKeys pin independently', async () => {
158
- const h = mkHarness()
159
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
160
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:2', messageId: 501, isFirstEmit: true })
161
- h.fireTimers()
162
- await h.mgr.drainInFlight()
163
-
164
- expect(h.deps.pin).toHaveBeenCalledTimes(2)
165
- expect(h.mgr.pinnedTurnKeys().sort()).toEqual(['c:1', 'c:2'])
166
- })
167
-
168
- it('works without a sidecar (no agentDir in production = no addPin wired)', async () => {
169
- const h = mkHarness({ addPin: undefined, removePin: undefined })
170
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
171
- h.fireTimers()
172
- await h.mgr.drainInFlight()
173
-
174
- expect(h.deps.pin).toHaveBeenCalled()
175
- expect(h.mgr.pinnedMessageId('c:1')).toBe(500)
176
- })
177
- })
178
-
179
- describe('considerPin — failure rollback', () => {
180
- it('firePin API rejection deletes from pinned map and clears sidecar', async () => {
181
- const h = mkHarness()
182
- h.deps.pin.mockRejectedValueOnce(errors.forbidden('pinChatMessage'))
183
-
184
- h.mgr.considerPin({
185
- chatId: 'c',
186
- turnKey: 'c:1',
187
- messageId: 500,
188
- isFirstEmit: true,
189
- })
190
- h.fireTimers()
191
- await h.mgr.drainInFlight()
192
-
193
- // Sidecar rolled back.
194
- expect(h.deps.removePin).toHaveBeenCalledWith('c', 500)
195
- expect(h.sidecar).toEqual([])
196
- // Log captured the failure.
197
- expect(h.deps.log).toHaveBeenCalledWith(
198
- expect.stringMatching(/progress-card pin failed/),
199
- )
200
- // In-memory entry is dropped — the pin never landed, so a later
201
- // completeTurn must not issue an unpin against a non-existent pin.
202
- expect(h.mgr.pinnedTurnKeys()).toEqual([])
203
- expect(h.mgr.pinnedMessageId('c:1')).toBeUndefined()
204
-
205
- // Trigger an unpin and assert deps.unpin was NOT called — the key
206
- // was already removed from `pinned`, so completeTurn is a no-op.
207
- h.mgr.completeTurn({ chatId: 'c', turnKey: 'c:1' })
208
- await h.mgr.drainInFlight()
209
- expect(h.deps.unpin).not.toHaveBeenCalled()
210
- })
211
-
212
- it('pin rejection with 429: log line still fires, no retry', async () => {
213
- const h = mkHarness()
214
- h.deps.pin.mockRejectedValueOnce(errors.floodWait(3, 'pinChatMessage'))
215
-
216
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
217
- h.fireTimers()
218
- await h.mgr.drainInFlight()
219
-
220
- expect(h.deps.pin).toHaveBeenCalledTimes(1)
221
- expect(h.deps.log).toHaveBeenCalled()
222
- })
223
- })
224
-
225
- describe('completeTurn — unpin', () => {
226
- it('unpins the pinned message and clears the sidecar', async () => {
227
- const h = mkHarness()
228
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
229
- h.fireTimers()
230
- await h.mgr.drainInFlight()
231
- expect(h.sidecar).toHaveLength(1)
232
-
233
- h.mgr.completeTurn({ chatId: 'c', turnKey: 'c:1' })
234
- await h.mgr.drainInFlight()
235
-
236
- expect(h.deps.unpin).toHaveBeenCalledWith('c', 500)
237
- expect(h.sidecar).toEqual([])
238
- expect(h.mgr.pinnedMessageId('c:1')).toBeUndefined()
239
- })
240
-
241
- it('no-op when the turn was never pinned', async () => {
242
- const h = mkHarness()
243
- h.mgr.completeTurn({ chatId: 'c', turnKey: 'c:never' })
244
- await h.mgr.drainInFlight()
245
- expect(h.deps.unpin).not.toHaveBeenCalled()
246
- })
247
-
248
- it('duplicate completeTurn does not double-unpin', async () => {
249
- const h = mkHarness()
250
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
251
- h.fireTimers()
252
- await h.mgr.drainInFlight()
253
-
254
- h.mgr.completeTurn({ chatId: 'c', turnKey: 'c:1' })
255
- h.mgr.completeTurn({ chatId: 'c', turnKey: 'c:1' })
256
- await h.mgr.drainInFlight()
257
-
258
- expect(h.deps.unpin).toHaveBeenCalledTimes(1)
259
- expect(h.deps.removePin).toHaveBeenCalledTimes(1)
260
- })
261
-
262
- it('unpin rejection still removes the sidecar entry', async () => {
263
- const h = mkHarness()
264
- h.deps.unpin.mockRejectedValueOnce(errors.badRequest('chat not found', 'unpinChatMessage'))
265
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
266
- h.fireTimers()
267
- await h.mgr.drainInFlight()
268
-
269
- h.mgr.completeTurn({ chatId: 'c', turnKey: 'c:1' })
270
- await h.mgr.drainInFlight()
271
-
272
- // Sidecar is cleared on unpin-attempt regardless of outcome —
273
- // the sidecar exists for crash recovery, so leaving stale entries
274
- // would cause duplicate unpins on the next boot.
275
- expect(h.sidecar).toEqual([])
276
- expect(h.deps.log).toHaveBeenCalledWith(
277
- expect.stringMatching(/progress-card unpin failed/),
278
- )
279
- })
280
- })
281
-
282
- describe('unpinForChat — external cancellation hook', () => {
283
- it('unpins every pinned turn matching a chat+thread', async () => {
284
- const h = mkHarness()
285
- h.mgr.considerPin({ chatId: 'c', threadId: '42', turnKey: 'c:42:1', messageId: 500, isFirstEmit: true })
286
- h.mgr.considerPin({ chatId: 'c', threadId: '42', turnKey: 'c:42:2', messageId: 501, isFirstEmit: true })
287
- h.mgr.considerPin({ chatId: 'c', threadId: '99', turnKey: 'c:99:1', messageId: 502, isFirstEmit: true })
288
- h.fireTimers()
289
- await h.mgr.drainInFlight()
290
- expect(h.mgr.pinnedTurnKeys()).toHaveLength(3)
291
-
292
- h.mgr.unpinForChat('c', 42)
293
- await h.mgr.drainInFlight()
294
-
295
- // Thread 42's pins were cleared, thread 99's remains.
296
- expect(h.deps.unpin).toHaveBeenCalledWith('c', 500)
297
- expect(h.deps.unpin).toHaveBeenCalledWith('c', 501)
298
- expect(h.deps.unpin).not.toHaveBeenCalledWith('c', 502)
299
- expect(h.mgr.pinnedTurnKeys()).toEqual(['c:99:1'])
300
- })
301
-
302
- it('unpinForChat with no threadId matches chat-root turns only', async () => {
303
- const h = mkHarness()
304
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
305
- h.mgr.considerPin({ chatId: 'c', threadId: '42', turnKey: 'c:42:1', messageId: 501, isFirstEmit: true })
306
- h.fireTimers()
307
- await h.mgr.drainInFlight()
308
-
309
- h.mgr.unpinForChat('c', undefined)
310
- await h.mgr.drainInFlight()
311
-
312
- // Only the chat-root turn (prefix "c:") was unpinned. The threaded
313
- // turn (prefix "c:42:") also starts with "c:" in string terms —
314
- // verify behaviour carefully. By current design, unpinForChat
315
- // with no thread matches `c:` prefix — including threaded turns.
316
- // This is the contract the gateway had before extraction.
317
- // If we wanted to change it to chat-root-only, that'd be a
318
- // deliberate spec change.
319
- expect(h.mgr.pinnedTurnKeys()).toEqual([])
320
- })
321
-
322
- it('unpinForChat on an empty manager is safe', async () => {
323
- const h = mkHarness()
324
- h.mgr.unpinForChat('c', 42)
325
- await h.mgr.drainInFlight()
326
- expect(h.deps.unpin).not.toHaveBeenCalled()
327
- })
328
-
329
- it('unpinForChat is safe mid-iteration when pins mutate the map', async () => {
330
- const h = mkHarness()
331
- for (let i = 1; i <= 5; i++) {
332
- h.mgr.considerPin({ chatId: 'c', threadId: '42', turnKey: `c:42:${i}`, messageId: 499 + i, isFirstEmit: true })
333
- }
334
- h.fireTimers()
335
- await h.mgr.drainInFlight()
336
-
337
- // Snapshot-before-iterate is important: the doUnpin path mutates
338
- // the pinned map. If iteration used the live map directly, we'd
339
- // miss entries.
340
- h.mgr.unpinForChat('c', 42)
341
- await h.mgr.drainInFlight()
342
-
343
- expect(h.deps.unpin).toHaveBeenCalledTimes(5)
344
- expect(h.mgr.pinnedTurnKeys()).toEqual([])
345
- })
346
- })
347
-
348
- describe('multi-turn lifecycle', () => {
349
- it('pin → complete → new turn with same chat keeps things independent', async () => {
350
- const h = mkHarness()
351
-
352
- // Turn 1
353
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
354
- h.fireTimers()
355
- await h.mgr.drainInFlight()
356
- h.mgr.completeTurn({ chatId: 'c', turnKey: 'c:1' })
357
- await h.mgr.drainInFlight()
358
-
359
- expect(h.mgr.pinnedTurnKeys()).toEqual([])
360
-
361
- // Turn 2 on the same chat
362
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:2', messageId: 501, isFirstEmit: true })
363
- h.fireTimers()
364
- await h.mgr.drainInFlight()
365
- h.mgr.completeTurn({ chatId: 'c', turnKey: 'c:2' })
366
- await h.mgr.drainInFlight()
367
-
368
- expect(h.deps.pin).toHaveBeenCalledTimes(2)
369
- expect(h.deps.unpin).toHaveBeenCalledTimes(2)
370
- expect(h.deps.pin.mock.calls[0][1]).toBe(500)
371
- expect(h.deps.pin.mock.calls[1][1]).toBe(501)
372
- })
373
-
374
- it('concurrent turns across chats: each turn is pinned + unpinned independently', async () => {
375
- const h = mkHarness()
376
-
377
- h.mgr.considerPin({ chatId: 'A', turnKey: 'A:1', messageId: 500, isFirstEmit: true })
378
- h.mgr.considerPin({ chatId: 'B', turnKey: 'B:1', messageId: 501, isFirstEmit: true })
379
- h.fireTimers()
380
- await h.mgr.drainInFlight()
381
- expect(h.mgr.pinnedTurnKeys().sort()).toEqual(['A:1', 'B:1'])
382
-
383
- h.mgr.completeTurn({ chatId: 'A', turnKey: 'A:1' })
384
- await h.mgr.drainInFlight()
385
- expect(h.mgr.pinnedTurnKeys()).toEqual(['B:1'])
386
-
387
- h.mgr.completeTurn({ chatId: 'B', turnKey: 'B:1' })
388
- await h.mgr.drainInFlight()
389
- expect(h.mgr.pinnedTurnKeys()).toEqual([])
390
- })
391
-
392
- it('reused turnKey after complete starts fresh (unpinned set was cleared)', async () => {
393
- const h = mkHarness()
394
-
395
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
396
- h.fireTimers()
397
- await h.mgr.drainInFlight()
398
- h.mgr.completeTurn({ chatId: 'c', turnKey: 'c:1' })
399
- await h.mgr.drainInFlight()
400
-
401
- // Unlikely but defensive: if the driver ever reuses the same
402
- // turnKey, the manager starts clean.
403
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 777, isFirstEmit: true })
404
- h.fireTimers()
405
- await h.mgr.drainInFlight()
406
-
407
- expect(h.deps.pin).toHaveBeenCalledTimes(2)
408
- expect(h.mgr.pinnedMessageId('c:1')).toBe(777)
409
- })
410
- })
411
-
412
- describe('captureServiceMessage — pin-service-msg deletion', () => {
413
- it('deletes the service message when it wraps a tracked pin', async () => {
414
- const h = mkHarness()
415
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
416
- h.fireTimers()
417
- await h.mgr.drainInFlight()
418
-
419
- h.mgr.captureServiceMessage({ chatId: 'c', pinnedMessageId: 500, serviceMessageId: 9001 })
420
- await h.mgr.drainInFlight()
421
-
422
- expect(h.deps.deleteMessage).toHaveBeenCalledWith('c', 9001)
423
- })
424
-
425
- it('ignores service messages wrapping pins we did not track', async () => {
426
- const h = mkHarness()
427
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
428
- h.fireTimers()
429
- await h.mgr.drainInFlight()
430
-
431
- h.mgr.captureServiceMessage({ chatId: 'c', pinnedMessageId: 999, serviceMessageId: 9001 })
432
- await h.mgr.drainInFlight()
433
-
434
- expect(h.deps.deleteMessage).not.toHaveBeenCalled()
435
- })
436
-
437
- it('issue #94: deletes service messages for externally-tracked pins (worker card)', async () => {
438
- // Worker / sub-agent cards are pinned via the gateway directly,
439
- // not through `considerPin`. They register with `trackExternalPin`
440
- // so `captureServiceMessage` recognises their service messages and
441
- // suppresses the "Clerk pinned …" system noise (matching the main
442
- // card's behaviour). Without this branch the worker card's pin
443
- // event would slip through unmatched.
444
- const h = mkHarness()
445
- h.mgr.trackExternalPin('c', 777)
446
-
447
- h.mgr.captureServiceMessage({ chatId: 'c', pinnedMessageId: 777, serviceMessageId: 9002 })
448
- await h.mgr.drainInFlight()
449
-
450
- expect(h.deps.deleteMessage).toHaveBeenCalledWith('c', 9002)
451
- })
452
-
453
- it('issue #94: untrackExternalPin stops further captures', async () => {
454
- const h = mkHarness()
455
- h.mgr.trackExternalPin('c', 777)
456
- h.mgr.untrackExternalPin('c', 777)
457
-
458
- h.mgr.captureServiceMessage({ chatId: 'c', pinnedMessageId: 777, serviceMessageId: 9002 })
459
- await h.mgr.drainInFlight()
460
-
461
- // Once untracked, the manager treats the pin as unknown again and
462
- // declines to delete — same shape as the "ignores untracked pins"
463
- // test above.
464
- expect(h.deps.deleteMessage).not.toHaveBeenCalled()
465
- })
466
-
467
- it('no-op when deleteMessage is not wired', async () => {
468
- const h = mkHarness({ deleteMessage: undefined })
469
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
470
- h.fireTimers()
471
- await h.mgr.drainInFlight()
472
-
473
- expect(() => {
474
- h.mgr.captureServiceMessage({ chatId: 'c', pinnedMessageId: 500, serviceMessageId: 9001 })
475
- }).not.toThrow()
476
- await h.mgr.drainInFlight()
477
- })
478
-
479
- it('deleteMessage rejection is logged and does not throw', async () => {
480
- const h = mkHarness()
481
- h.deps.deleteMessage.mockRejectedValueOnce(errors.badRequest('message to delete not found', 'deleteMessage'))
482
-
483
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
484
- h.fireTimers()
485
- await h.mgr.drainInFlight()
486
-
487
- h.mgr.captureServiceMessage({ chatId: 'c', pinnedMessageId: 500, serviceMessageId: 9001 })
488
- await h.mgr.drainInFlight()
489
-
490
- expect(h.deps.log).toHaveBeenCalledWith(
491
- expect.stringMatching(/pin service-msg delete failed/),
492
- )
493
- })
494
-
495
- it('unpin deletes a service message that was captured but not yet deleted', async () => {
496
- // Simulate: capture arrives, but deleteMessage is pending forever —
497
- // then an unpin fires. Because captureServiceMessage already
498
- // attempted the delete and removed its entry, unpin won't double-fire;
499
- // this guards the inverse scenario where capture never arrived.
500
- // Here we test the safety-net path: no capture → no stray delete.
501
- const h = mkHarness()
502
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
503
- h.fireTimers()
504
- await h.mgr.drainInFlight()
505
-
506
- // No captureServiceMessage call — simulates a lost/unmatched update.
507
- h.mgr.completeTurn({ chatId: 'c', turnKey: 'c:1' })
508
- await h.mgr.drainInFlight()
509
-
510
- expect(h.deps.unpin).toHaveBeenCalledWith('c', 500)
511
- expect(h.deps.deleteMessage).not.toHaveBeenCalled()
512
- })
513
- })
514
-
515
- describe('drainInFlight', () => {
516
- it('resolves even when no promises are pending', async () => {
517
- const h = mkHarness()
518
- await expect(h.mgr.drainInFlight()).resolves.toBeUndefined()
519
- })
520
-
521
- it('awaits both the pin catch-chain and the unpin finally-chain', async () => {
522
- const h = mkHarness()
523
- // Slow pin + slow unpin → drainInFlight should cover both.
524
- let resolvePin!: () => void
525
- let resolveUnpin!: () => void
526
- h.deps.pin.mockImplementationOnce(() => new Promise<true>((r) => { resolvePin = () => r(true) }))
527
- h.deps.unpin.mockImplementationOnce(() => new Promise<true>((r) => { resolveUnpin = () => r(true) }))
528
-
529
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
530
- h.fireTimers()
531
- h.mgr.completeTurn({ chatId: 'c', turnKey: 'c:1' })
532
-
533
- const drained = h.mgr.drainInFlight()
534
- resolvePin()
535
- resolveUnpin()
536
- await drained
537
-
538
- // After drain, removePin should have fired (from unpin's finally).
539
- expect(h.deps.removePin).toHaveBeenCalled()
540
- })
541
- })
542
-
543
- describe('deferred pin timing — fast turns stay silent', () => {
544
- it('considerPin does not call pin synchronously — timer is scheduled', async () => {
545
- const h = mkHarness()
546
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
547
- await h.mgr.drainInFlight()
548
-
549
- // Timer scheduled, but not fired → no pin yet. Default pinDelayMs
550
- // is now 0 (fast-turn suppression is owned upstream by the
551
- // driver's initialDelayMs); the setTimeout indirection remains so
552
- // completeTurn can still cancel a pin that hasn't landed yet.
553
- expect(h.timers).toHaveLength(1)
554
- expect(h.timers[0].ms).toBe(0)
555
- expect(h.deps.pin).not.toHaveBeenCalled()
556
- expect(h.deps.addPin).not.toHaveBeenCalled()
557
- expect(h.mgr.pinnedTurnKeys()).toEqual([])
558
- })
559
-
560
- it('fast turn: completeTurn before timer fires → never pins, never unpins', async () => {
561
- const h = mkHarness()
562
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
563
- // Turn completes before pinDelayMs elapses. The timer is cancelled
564
- // and no pin/unpin ever touches Telegram.
565
- h.mgr.completeTurn({ chatId: 'c', turnKey: 'c:1' })
566
- // Even if the timer somehow fires later (belt-and-braces), it should
567
- // be marked cancelled and skipped.
568
- h.fireTimers()
569
- await h.mgr.drainInFlight()
570
-
571
- expect(h.deps.pin).not.toHaveBeenCalled()
572
- expect(h.deps.unpin).not.toHaveBeenCalled()
573
- expect(h.deps.addPin).not.toHaveBeenCalled()
574
- expect(h.sidecar).toEqual([])
575
- })
576
-
577
- it('slow turn: pin fires when timer elapses, then completeTurn unpins', async () => {
578
- const h = mkHarness()
579
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
580
- await h.mgr.drainInFlight()
581
- expect(h.deps.pin).not.toHaveBeenCalled()
582
-
583
- // Timer elapses → pin lands.
584
- h.fireTimers()
585
- await h.mgr.drainInFlight()
586
- expect(h.deps.pin).toHaveBeenCalledWith('c', 500, { disable_notification: true })
587
- expect(h.mgr.pinnedMessageId('c:1')).toBe(500)
588
-
589
- // completeTurn after the pin → unpin lands.
590
- h.mgr.completeTurn({ chatId: 'c', turnKey: 'c:1' })
591
- await h.mgr.drainInFlight()
592
- expect(h.deps.unpin).toHaveBeenCalledWith('c', 500)
593
- expect(h.mgr.pinnedMessageId('c:1')).toBeUndefined()
594
- })
595
-
596
- it('unpinForChat cancels pending (not-yet-fired) timers', async () => {
597
- const h = mkHarness()
598
- h.mgr.considerPin({ chatId: 'c', threadId: '42', turnKey: 'c:42:1', messageId: 500, isFirstEmit: true })
599
- h.mgr.considerPin({ chatId: 'c', threadId: '42', turnKey: 'c:42:2', messageId: 501, isFirstEmit: true })
600
-
601
- // Clear pending pins before any timer fires.
602
- h.mgr.unpinForChat('c', 42)
603
- h.fireTimers()
604
- await h.mgr.drainInFlight()
605
-
606
- // No pins, no unpins — the timers were cancelled and never fired.
607
- expect(h.deps.pin).not.toHaveBeenCalled()
608
- expect(h.deps.unpin).not.toHaveBeenCalled()
609
- })
610
-
611
- it('unpinForChat cancels pending timers AND unpins already-fired pins', async () => {
612
- const h = mkHarness()
613
- // First pin: fire its timer → already pinned.
614
- h.mgr.considerPin({ chatId: 'c', threadId: '42', turnKey: 'c:42:1', messageId: 500, isFirstEmit: true })
615
- h.fireTimers()
616
- await h.mgr.drainInFlight()
617
- expect(h.deps.pin).toHaveBeenCalledTimes(1)
618
-
619
- // Second pin: timer still pending.
620
- h.mgr.considerPin({ chatId: 'c', threadId: '42', turnKey: 'c:42:2', messageId: 501, isFirstEmit: true })
621
-
622
- h.mgr.unpinForChat('c', 42)
623
- h.fireTimers() // noop — second timer was cancelled.
624
- await h.mgr.drainInFlight()
625
-
626
- // First pin got unpinned; second never pinned.
627
- expect(h.deps.pin).toHaveBeenCalledTimes(1)
628
- expect(h.deps.unpin).toHaveBeenCalledWith('c', 500)
629
- expect(h.deps.unpin).not.toHaveBeenCalledWith('c', 501)
630
- })
631
-
632
- it('custom pinDelayMs overrides the default', async () => {
633
- const h = mkHarness({ pinDelayMs: 5_000 })
634
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
635
-
636
- expect(h.timers).toHaveLength(1)
637
- expect(h.timers[0].ms).toBe(5_000)
638
- })
639
-
640
- it('pinDelayMs=0 still defers through the timer (no sync pin)', async () => {
641
- // Guards against a tempting optimization: "if pinDelayMs === 0,
642
- // pin synchronously." We pass it through the timer anyway so the
643
- // contract is uniform (considerPin never blocks, never pins
644
- // before control returns).
645
- const h = mkHarness({ pinDelayMs: 0 })
646
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
647
-
648
- expect(h.deps.pin).not.toHaveBeenCalled()
649
- expect(h.timers).toHaveLength(1)
650
-
651
- h.fireTimers()
652
- await h.mgr.drainInFlight()
653
- expect(h.deps.pin).toHaveBeenCalledTimes(1)
654
- })
655
- })
656
-
657
- describe('per-agent composite key — one pin per (turnKey, agentId)', () => {
658
- it('distinct agentIds under the same turnKey pin independently', async () => {
659
- const h = mkHarness()
660
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true, agentId: 'parent' })
661
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 501, isFirstEmit: true, agentId: 'sub-a' })
662
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 502, isFirstEmit: true, agentId: 'sub-b' })
663
- h.fireTimers()
664
- await h.mgr.drainInFlight()
665
-
666
- expect(h.deps.pin).toHaveBeenCalledTimes(3)
667
- expect(h.mgr.pinnedTurnKeys()).toEqual(['c:1'])
668
- expect(h.mgr.pinnedAgentIds('c:1').sort()).toEqual(['parent', 'sub-a', 'sub-b'])
669
- expect(h.mgr.pinnedMessageId('c:1', 'parent')).toBe(500)
670
- expect(h.mgr.pinnedMessageId('c:1', 'sub-a')).toBe(501)
671
- expect(h.mgr.pinnedMessageId('c:1', 'sub-b')).toBe(502)
672
- })
673
-
674
- it('idempotent within a (turnKey, agentId) — second considerPin is a no-op', async () => {
675
- const h = mkHarness()
676
- const c = { chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true, agentId: 'sub-a' }
677
- h.mgr.considerPin(c)
678
- h.mgr.considerPin({ ...c, messageId: 999 })
679
- h.fireTimers()
680
- await h.mgr.drainInFlight()
681
-
682
- expect(h.deps.pin).toHaveBeenCalledTimes(1)
683
- expect(h.deps.pin).toHaveBeenCalledWith('c', 500, { disable_notification: true })
684
- expect(h.mgr.pinnedMessageId('c:1', 'sub-a')).toBe(500)
685
- })
686
-
687
- it('completeTurn for one agentId leaves siblings under the same turnKey untouched', async () => {
688
- const h = mkHarness()
689
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true, agentId: 'parent' })
690
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 501, isFirstEmit: true, agentId: 'sub-a' })
691
- h.fireTimers()
692
- await h.mgr.drainInFlight()
693
-
694
- h.mgr.completeTurn({ chatId: 'c', turnKey: 'c:1', agentId: 'sub-a' })
695
- await h.mgr.drainInFlight()
696
-
697
- expect(h.deps.unpin).toHaveBeenCalledTimes(1)
698
- expect(h.deps.unpin).toHaveBeenCalledWith('c', 501)
699
- expect(h.mgr.pinnedAgentIds('c:1')).toEqual(['parent'])
700
- expect(h.mgr.pinnedMessageId('c:1', 'parent')).toBe(500)
701
- })
702
-
703
- it('legacy callers (no agentId) get the parent-sentinel default', async () => {
704
- const h = mkHarness()
705
- // Old call shape: no agentId. Uses PARENT_AGENT_ID under the hood.
706
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
707
- h.fireTimers()
708
- await h.mgr.drainInFlight()
709
-
710
- expect(h.mgr.pinnedAgentIds('c:1')).toEqual(['__parent__'])
711
- // pinnedMessageId without agentId resolves the parent sentinel.
712
- expect(h.mgr.pinnedMessageId('c:1')).toBe(500)
713
- // completeTurn without agentId targets the parent sentinel too.
714
- h.mgr.completeTurn({ chatId: 'c', turnKey: 'c:1' })
715
- await h.mgr.drainInFlight()
716
- expect(h.deps.unpin).toHaveBeenCalledWith('c', 500)
717
- })
718
-
719
- it('parent and a sub-agent for the legacy turnKey pin under different sentinels', async () => {
720
- // Mixed call shape: parent uses no agentId (sentinel), sub-agent
721
- // passes an explicit one. They must not collide.
722
- const h = mkHarness()
723
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true })
724
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 501, isFirstEmit: true, agentId: 'sub-a' })
725
- h.fireTimers()
726
- await h.mgr.drainInFlight()
727
-
728
- expect(h.deps.pin).toHaveBeenCalledTimes(2)
729
- expect(h.mgr.pinnedAgentIds('c:1').sort()).toEqual(['__parent__', 'sub-a'])
730
- })
731
- })
732
-
733
- describe('completeAllForTurn — catastrophic cleanup', () => {
734
- it('unpins every agentId pinned under a turnKey', async () => {
735
- const h = mkHarness()
736
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true, agentId: 'parent' })
737
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 501, isFirstEmit: true, agentId: 'sub-a' })
738
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 502, isFirstEmit: true, agentId: 'sub-b' })
739
- // A different turn — must not be affected.
740
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:2', messageId: 600, isFirstEmit: true, agentId: 'parent' })
741
- h.fireTimers()
742
- await h.mgr.drainInFlight()
743
-
744
- h.mgr.completeAllForTurn({ chatId: 'c', turnKey: 'c:1' })
745
- await h.mgr.drainInFlight()
746
-
747
- expect(h.deps.unpin).toHaveBeenCalledTimes(3)
748
- const unpinnedIds = h.deps.unpin.mock.calls.map((args) => args[1]).sort((a, b) => Number(a) - Number(b))
749
- expect(unpinnedIds).toEqual([500, 501, 502])
750
- expect(h.mgr.pinnedTurnKeys()).toEqual(['c:2'])
751
- })
752
-
753
- it('cancels pending (not-yet-fired) timers under the turnKey', async () => {
754
- const h = mkHarness()
755
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 500, isFirstEmit: true, agentId: 'parent' })
756
- h.mgr.considerPin({ chatId: 'c', turnKey: 'c:1', messageId: 501, isFirstEmit: true, agentId: 'sub-a' })
757
- // Timers scheduled but not fired yet.
758
- h.mgr.completeAllForTurn({ chatId: 'c', turnKey: 'c:1' })
759
- h.fireTimers() // noop — both timers were cancelled
760
- await h.mgr.drainInFlight()
761
-
762
- expect(h.deps.pin).not.toHaveBeenCalled()
763
- expect(h.deps.unpin).not.toHaveBeenCalled()
764
- })
765
-
766
- it('safe on a turnKey with no pins', async () => {
767
- const h = mkHarness()
768
- h.mgr.completeAllForTurn({ chatId: 'c', turnKey: 'c:never' })
769
- await h.mgr.drainInFlight()
770
- expect(h.deps.unpin).not.toHaveBeenCalled()
771
- })
772
- })
773
- })