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,116 +0,0 @@
1
- /**
2
- * Unit tests for syncChatRunningSubagents (issue #399).
3
- *
4
- * Tests the helper directly with synthetic ProgressCardState objects so the
5
- * registry-update logic can be verified in isolation from the driver.
6
- */
7
- import { describe, it, expect } from 'vitest'
8
- import { syncChatRunningSubagents } from '../progress-card-driver.js'
9
- import { initialState } from '../progress-card.js'
10
- import type { SubAgentState } from '../progress-card.js'
11
-
12
- function makeState(agents: Record<string, 'running' | 'done' | 'failed'> = {}) {
13
- const state = initialState()
14
- const subAgents = new Map<string, SubAgentState>()
15
- for (const [id, agentState] of Object.entries(agents)) {
16
- subAgents.set(id, {
17
- agentId: id,
18
- state: agentState,
19
- startedAt: 1000,
20
- finishedAt: agentState !== 'running' ? 2000 : undefined,
21
- firstPromptText: 'test',
22
- pendingPreamble: null,
23
- spawnedByToolUseId: undefined,
24
- orphan: false,
25
- } as SubAgentState)
26
- }
27
- return { ...state, subAgents }
28
- }
29
-
30
- describe('syncChatRunningSubagents (unit)', () => {
31
- it('removes agents that transition running -> done', () => {
32
- const prev = makeState({ agent1: 'running' })
33
- const next = makeState({ agent1: 'done' })
34
- const registry = new Map<string, Map<string, SubAgentState>>()
35
- registry.set('chat1', new Map([['agent1', prev.subAgents.get('agent1')!]]))
36
-
37
- syncChatRunningSubagents(prev, next, 'chat1', registry)
38
-
39
- expect(registry.get('chat1')?.has('agent1')).toBe(false)
40
- })
41
-
42
- it('removes agents that transition running -> failed', () => {
43
- const prev = makeState({ agent1: 'running' })
44
- const next = makeState({ agent1: 'failed' })
45
- const registry = new Map<string, Map<string, SubAgentState>>()
46
- registry.set('chat1', new Map([['agent1', prev.subAgents.get('agent1')!]]))
47
-
48
- syncChatRunningSubagents(prev, next, 'chat1', registry)
49
-
50
- expect(registry.get('chat1')?.has('agent1')).toBe(false)
51
- })
52
-
53
- it('does NOT remove agents still running', () => {
54
- const prev = makeState({ agent1: 'running' })
55
- // next also has agent1 still running (state didn't change)
56
- const next = { ...prev, subAgents: new Map(prev.subAgents) }
57
- const registry = new Map<string, Map<string, SubAgentState>>()
58
- registry.set('chat1', new Map([['agent1', prev.subAgents.get('agent1')!]]))
59
-
60
- syncChatRunningSubagents(prev, next, 'chat1', registry)
61
-
62
- expect(registry.get('chat1')?.has('agent1')).toBe(true)
63
- })
64
-
65
- it('is a no-op when prev.subAgents and next.subAgents are the same object', () => {
66
- const prev = makeState({ agent1: 'running' })
67
- // Same object reference — no change happened.
68
- const next = prev
69
- const registry = new Map<string, Map<string, SubAgentState>>()
70
- registry.set('chat1', new Map([['agent1', prev.subAgents.get('agent1')!]]))
71
-
72
- syncChatRunningSubagents(prev, next, 'chat1', registry)
73
-
74
- // Nothing should change since subAgents is the same reference.
75
- expect(registry.get('chat1')?.has('agent1')).toBe(true)
76
- })
77
-
78
- it('is a no-op when chatRunningSubagents has no entry for the cBaseKey', () => {
79
- const prev = makeState({ agent1: 'running' })
80
- const next = makeState({ agent1: 'done' })
81
- // Registry has an entry for 'other-chat', not 'chat1'.
82
- const registry = new Map<string, Map<string, SubAgentState>>()
83
- registry.set('other-chat', new Map([['agent1', prev.subAgents.get('agent1')!]]))
84
-
85
- // Should not throw and should not touch other-chat.
86
- syncChatRunningSubagents(prev, next, 'chat1', registry)
87
-
88
- // other-chat entry must be untouched.
89
- expect(registry.get('other-chat')?.has('agent1')).toBe(true)
90
- // chat1 was never in registry — still absent.
91
- expect(registry.has('chat1')).toBe(false)
92
- })
93
-
94
- it('adds newly-running agents to the registry', () => {
95
- const prev = makeState({})
96
- const next = makeState({ agent1: 'running' })
97
- const registry = new Map<string, Map<string, SubAgentState>>()
98
-
99
- syncChatRunningSubagents(prev, next, 'chat1', registry)
100
-
101
- expect(registry.get('chat1')?.has('agent1')).toBe(true)
102
- expect(registry.get('chat1')?.get('agent1')?.state).toBe('running')
103
- })
104
-
105
- it('removes agents deleted from subAgents entirely', () => {
106
- const prev = makeState({ agent1: 'running' })
107
- // next has an empty subAgents map (agent removed).
108
- const next = makeState({})
109
- const registry = new Map<string, Map<string, SubAgentState>>()
110
- registry.set('chat1', new Map([['agent1', prev.subAgents.get('agent1')!]]))
111
-
112
- syncChatRunningSubagents(prev, next, 'chat1', registry)
113
-
114
- expect(registry.get('chat1')?.has('agent1')).toBe(false)
115
- })
116
- })
@@ -1,489 +0,0 @@
1
- /**
2
- * Regression tests for two bugs reported 2026-04-13 / 2026-04-14:
3
- *
4
- * Bug 1 (TG-DONE): the progress-card message stays stuck on
5
- * "⚙️ Working…" after a turn completes. Root cause: session-tail
6
- * onEvent ran `handleSessionEvent` (which calls `closeProgressLane`
7
- * and deletes + finalizes the progress-lane stream) BEFORE
8
- * `progressDriver.ingest` (which synchronously emits the final
9
- * "Done" render via handleStreamReply). By the time the driver
10
- * tried to edit the existing stream, it was already gone.
11
- *
12
- * Bug 2 (TG-IDLE-LEAK): bare text emitted by the model after
13
- * `stream_reply(done=true)` (e.g. "Idle; awaiting next instruction.")
14
- * leaks as a separate Telegram message. Root cause: server.ts
15
- * identified its own MCP tools by exact-prefix match against
16
- * `mcp__switchroom-telegram__…`, but Claude Code prefixes tool
17
- * names with whatever registration key the host .mcp.json used.
18
- * Existing agents still register as `clerk-telegram`, so no
19
- * `reply`-family tool call ever set `currentTurnReplyCalled` and
20
- * the orphaned-reply backstop fired on every post-reply idle text.
21
- */
22
-
23
- import { describe, it, expect } from 'vitest'
24
- import { readFileSync } from 'fs'
25
- import { join, dirname } from 'path'
26
- import { fileURLToPath } from 'url'
27
- import { createProgressDriver } from '../progress-card-driver.js'
28
- import type { SessionEvent } from '../session-tail.js'
29
- import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
30
-
31
- const __dirname = dirname(fileURLToPath(import.meta.url))
32
-
33
- // ─── Bug 1 integration harness ────────────────────────────────────────────
34
- //
35
- // Simulates the server.ts wiring: one session-tail event callback that
36
- // fans out to (a) progressDriver.ingest and (b) a lightweight stand-in
37
- // for handleSessionEvent's closeProgressLane. The real bug showed up as
38
- // an ordering race between these two consumers, so the harness exercises
39
- // both orders and asserts which one delivers the Done render.
40
-
41
- interface FakeStream {
42
- updates: string[]
43
- finalized: boolean
44
- finalize(): void
45
- update(text: string): void
46
- }
47
-
48
- function makeStream(): FakeStream {
49
- const s: FakeStream = {
50
- updates: [],
51
- finalized: false,
52
- finalize() {
53
- this.finalized = true
54
- },
55
- update(text: string) {
56
- if (this.finalized) return
57
- this.updates.push(text)
58
- },
59
- }
60
- return s
61
- }
62
-
63
- function wireServer(order: 'driver-first' | 'handler-first') {
64
- const streams = new Map<string, FakeStream>()
65
- const allStreams: FakeStream[] = [] // never forgets — includes deleted ones
66
- const events: SessionEvent[] = []
67
-
68
- // Stand-in for handleStreamReply's progress-lane emit: if a stream for
69
- // this key already exists, push the update into it; otherwise create
70
- // one. Creating a fresh stream here simulates "posted a NEW message
71
- // instead of updating the original card" — the user-visible bug 1
72
- // symptom.
73
- const emit = (args: { chatId: string; threadId?: string; html: string; done: boolean }): void => {
74
- const key = `${args.chatId}:${args.threadId ?? '_'}:progress`
75
- let s = streams.get(key)
76
- if (!s) {
77
- s = makeStream()
78
- streams.set(key, s)
79
- allStreams.push(s)
80
- }
81
- s.update(args.html)
82
- if (args.done) s.finalize()
83
- }
84
-
85
- const driver = createProgressDriver({ emit, minIntervalMs: 0, coalesceMs: 0, initialDelayMs: 0 })
86
-
87
- // Emulate server.ts closeProgressLane: delete + finalize.
88
- function closeProgressLane(chatId: string, threadId?: string): void {
89
- const key = `${chatId}:${threadId ?? '_'}:progress`
90
- const s = streams.get(key)
91
- if (!s) return
92
- streams.delete(key)
93
- s.finalize()
94
- }
95
-
96
- function handleSessionEvent(ev: SessionEvent): void {
97
- if (ev.kind === 'turn_end') {
98
- // This is the line that caused bug 1: closeProgressLane deletes +
99
- // finalizes before the driver has emitted its Done render.
100
- closeProgressLane('c1')
101
- }
102
- }
103
-
104
- // The fix is to run progressDriver.ingest FIRST on turn_end so the
105
- // driver's synchronous flush reaches the existing stream before the
106
- // handler tears it down. Expose both orders so the test can compare.
107
- const onEvent = (ev: SessionEvent): void => {
108
- events.push(ev)
109
- if (order === 'driver-first') {
110
- driver.ingest(ev, null)
111
- handleSessionEvent(ev)
112
- } else {
113
- handleSessionEvent(ev)
114
- driver.ingest(ev, null)
115
- }
116
- }
117
-
118
- return { onEvent, streams, allStreams, events, driver }
119
- }
120
-
121
- // #235 Wave 3 F4: server.ts monolith removed. The bug-1 driver-first
122
- // wiring pin (was a regex match on the onEvent callback in server.ts)
123
- // no longer applies — gateway.ts is the only file with the wiring,
124
- // and its driver-first ordering is pinned by the wireServer-driver-first
125
- // behaviour tests below.
126
-
127
- describe('bug 1 — Done transition reaches the original progress card', () => {
128
- it('driver-first order: Done render lands in the ORIGINAL stream', () => {
129
- const { onEvent, allStreams, driver } = wireServer('driver-first')
130
-
131
- // Minimal turn: enqueue → one tool_use+result → turn_end
132
- onEvent({
133
- kind: 'enqueue',
134
- chatId: 'c1',
135
- messageId: '1',
136
- threadId: null,
137
- rawContent: '<channel chat_id="c1">hi</channel>',
138
- })
139
- onEvent({ kind: 'tool_use', toolName: 'Read', toolUseId: 't1', input: { file_path: '/x' } })
140
- onEvent({ kind: 'tool_result', toolUseId: 't1', toolName: 'Read' })
141
- // Issue #132: simulate the agent calling the reply tool so the final
142
- // render lands on "✅ Done" rather than "🙊 Ended without reply". This
143
- // test is about the bug-1 ordering between handler and driver, not
144
- // the silent-end UX, so we explicitly opt into the happy path.
145
- onEvent({ kind: 'tool_use', toolName: 'mcp__switchroom-telegram__reply', toolUseId: 't2' })
146
- onEvent({ kind: 'tool_result', toolUseId: 't2', toolName: 'mcp__switchroom-telegram__reply' })
147
- // Issue #137: also simulate a successful outbound delivery so the renderer
148
- // doesn't downgrade to "⚠️ Reply attempted but not delivered" — gateway's
149
- // executeReply path calls recordOutboundDelivered after the message lands.
150
- driver.recordOutboundDelivered('c1')
151
- onEvent({ kind: 'turn_end', durationMs: 500 })
152
-
153
- // With driver-first, only ONE stream is ever created — the original
154
- // card. The handler's closeProgressLane runs AFTER the driver has
155
- // already pushed the Done render into it, so no duplicate message
156
- // is needed.
157
- expect(allStreams).toHaveLength(1)
158
- const [stream] = allStreams
159
- expect(stream.finalized).toBe(true)
160
- const lastUpdate = stream.updates.at(-1) ?? ''
161
- expect(lastUpdate).toContain('✅ <b>Done</b>')
162
- expect(lastUpdate).not.toContain('⚙️ <b>Working…</b>')
163
- })
164
-
165
- it('handler-first order reproduces bug 1: Done lands on a NEW stream', () => {
166
- // This is the PRE-FIX behaviour — kept as a negative control so the
167
- // fix's value is visible. The handler tears down the stream
168
- // synchronously on turn_end; the driver's subsequent emit can't find
169
- // the original stream, so the emit callback has to create a new
170
- // stream (new Telegram message) — leaving the original progress
171
- // card stuck on "⚙️ Working…".
172
- const { onEvent, allStreams } = wireServer('handler-first')
173
-
174
- onEvent({
175
- kind: 'enqueue',
176
- chatId: 'c1',
177
- messageId: '1',
178
- threadId: null,
179
- rawContent: '<channel chat_id="c1">hi</channel>',
180
- })
181
- onEvent({ kind: 'tool_use', toolName: 'Read', toolUseId: 't1', input: { file_path: '/x' } })
182
- onEvent({ kind: 'tool_result', toolUseId: 't1', toolName: 'Read' })
183
- const originalSnapshotUpdates = [...(allStreams[0]?.updates ?? [])]
184
-
185
- onEvent({ kind: 'turn_end', durationMs: 500 })
186
-
187
- // Bug surface: the ORIGINAL stream got NO further updates past the
188
- // pre-turn_end render, so its last frame still says Working.
189
- const original = allStreams[0]
190
- expect(original).toBeDefined()
191
- const originalLast = original.updates.at(-1) ?? ''
192
- expect(originalLast).toContain('⚙️ <b>Working…</b>')
193
- expect(originalLast).not.toContain('✅ <b>Done</b>')
194
- // And a SECOND stream had to be created for the Done render —
195
- // visible to the user as a duplicate/new card.
196
- expect(allStreams.length).toBeGreaterThanOrEqual(2)
197
- expect(originalSnapshotUpdates.length).toBe(original.updates.length)
198
- })
199
- })
200
-
201
- // ─── Bug 2 unit tests for isTelegramReplyTool ─────────────────────────────
202
-
203
- // #235 Wave 3 F4: server.ts monolith removed. The bug-2 import + dead-
204
- // branch pin no longer applies — gateway.ts owns the classification
205
- // path now, exercised by the unit tests below.
206
-
207
- describe('bug 2 — telegram tool-name classification is robust to MCP registration key', () => {
208
- it('matches the historical `clerk-telegram` registration', () => {
209
- expect(isTelegramReplyTool('mcp__clerk-telegram__reply')).toBe(true)
210
- expect(isTelegramReplyTool('mcp__clerk-telegram__stream_reply')).toBe(true)
211
- expect(isTelegramSurfaceTool('mcp__clerk-telegram__edit_message')).toBe(true)
212
- expect(isTelegramSurfaceTool('mcp__clerk-telegram__react')).toBe(true)
213
- })
214
-
215
- it('matches the current `switchroom-telegram` registration', () => {
216
- expect(isTelegramReplyTool('mcp__switchroom-telegram__reply')).toBe(true)
217
- expect(isTelegramReplyTool('mcp__switchroom-telegram__stream_reply')).toBe(true)
218
- })
219
-
220
- it('matches fork-style registration keys that still contain `telegram`', () => {
221
- expect(isTelegramReplyTool('mcp__my-fork-telegram__stream_reply')).toBe(true)
222
- })
223
-
224
- it('does NOT match unrelated MCP tool names', () => {
225
- expect(isTelegramReplyTool('mcp__hindsight__recall')).toBe(false)
226
- expect(isTelegramReplyTool('Read')).toBe(false)
227
- expect(isTelegramReplyTool('mcp__switchroom-telegram__download_attachment')).toBe(false)
228
- expect(isTelegramSurfaceTool('mcp__hindsight__retain')).toBe(false)
229
- })
230
-
231
- it("does NOT match the plugin's own non-reply tools (bugs past and future)", () => {
232
- // `get_recent_messages`, `send_typing`, `pin_message`, `forward_message`,
233
- // `delete_message`, `download_attachment` — none of these own the
234
- // answer surface, so they must NOT set currentTurnReplyCalled, or the
235
- // backstop would fail to fire for a turn that only called one of
236
- // them and then emitted text.
237
- expect(isTelegramReplyTool('mcp__clerk-telegram__get_recent_messages')).toBe(false)
238
- expect(isTelegramReplyTool('mcp__clerk-telegram__send_typing')).toBe(false)
239
- expect(isTelegramReplyTool('mcp__clerk-telegram__pin_message')).toBe(false)
240
- expect(isTelegramReplyTool('mcp__clerk-telegram__forward_message')).toBe(false)
241
- expect(isTelegramReplyTool('mcp__clerk-telegram__delete_message')).toBe(false)
242
- })
243
- })
244
-
245
- // ─── Bug 2 end-to-end simulation of the turn-end backstop decision ────────
246
- //
247
- // The real server-side code path we need to guard: after a
248
- // `stream_reply(done=true)` tool_use event arrives in the session-tail,
249
- // the tool_use handler must set `currentTurnReplyCalled = true`. If it
250
- // doesn't, a subsequent `text` event (e.g. "Idle; awaiting next
251
- // instruction.") gets captured, and at turn_end the backstop fires and
252
- // forwards it as a separate Telegram message.
253
- //
254
- // We simulate just the flag transition here — it's the cleanest way to
255
- // pin down the exact bug without importing server.ts (which has
256
- // top-level side-effects that require env vars).
257
-
258
- describe('bug 2 — stream_reply tool call sets reply-called flag regardless of MCP key', () => {
259
- // Mirror of the real session-tail tool_use event shape
260
- type ToolUseEv = { kind: 'tool_use'; toolName: string }
261
-
262
- function simulateTurn(events: Array<ToolUseEv | { kind: 'text'; text: string } | { kind: 'turn_end' }>): {
263
- replyCalled: boolean
264
- capturedText: string[]
265
- wouldFireBackstop: boolean
266
- } {
267
- let replyCalled = false
268
- const captured: string[] = []
269
- for (const ev of events) {
270
- if (ev.kind === 'tool_use' && isTelegramReplyTool(ev.toolName)) {
271
- replyCalled = true
272
- } else if (ev.kind === 'text') {
273
- captured.push(ev.text)
274
- }
275
- }
276
- return {
277
- replyCalled,
278
- capturedText: captured,
279
- // This mirrors the exact server.ts turn_end guard.
280
- wouldFireBackstop: !replyCalled && captured.length > 0,
281
- }
282
- }
283
-
284
- it('with `clerk-telegram` key: stream_reply followed by idle text does NOT fire the backstop', () => {
285
- const result = simulateTurn([
286
- { kind: 'tool_use', toolName: 'mcp__clerk-telegram__stream_reply' },
287
- { kind: 'text', text: 'Idle; awaiting next instruction.' },
288
- { kind: 'turn_end' },
289
- ])
290
- expect(result.replyCalled).toBe(true)
291
- // Post-fix: backstop does NOT fire, so no duplicate message leaks.
292
- expect(result.wouldFireBackstop).toBe(false)
293
- })
294
-
295
- it('with `switchroom-telegram` key: unchanged — reply-called recognized', () => {
296
- const result = simulateTurn([
297
- { kind: 'tool_use', toolName: 'mcp__switchroom-telegram__stream_reply' },
298
- { kind: 'text', text: 'idle' },
299
- { kind: 'turn_end' },
300
- ])
301
- expect(result.replyCalled).toBe(true)
302
- expect(result.wouldFireBackstop).toBe(false)
303
- })
304
-
305
- it('genuine orphan (NO reply tool call, only text) — backstop is DISABLED (issues #251, #269)', () => {
306
- // The orphaned-reply backstop was removed in issue #269. All Telegram
307
- // delivery now goes through the MCP reply tool, so a turn that ends
308
- // without calling the reply tool is treated as a silent turn (no message
309
- // sent), not a forwarding opportunity.
310
- const result = simulateTurn([
311
- { kind: 'tool_use', toolName: 'Read' },
312
- { kind: 'text', text: "here's the answer" },
313
- { kind: 'turn_end' },
314
- ])
315
- expect(result.replyCalled).toBe(false)
316
- // The wouldFireBackstop flag reflects the old logic; the actual backstop
317
- // in server.ts is now commented out. This value is kept for diagnostic
318
- // purposes but the block no longer fires.
319
- expect(result.wouldFireBackstop).toBe(true) // still true logically, but never executed
320
- })
321
- })
322
-
323
- // ─── Bug 3 — orphan progress card (TG-ORPHAN-CARD) ───────────────────────
324
- //
325
- // Progress cards stayed pinned in "Working…" when turn_end was delayed,
326
- // missed, or arrived after the model already sent its final reply.
327
- //
328
- // Fix: unpin fires on the FIRST of (turn_end, stream_reply(done=true), reply())
329
- // via a shared `unpinProgressCard` helper guarded by `unpinnedTurnKeys`.
330
- //
331
- // These tests simulate the pin/unpin lifecycle that server.ts manages.
332
- // They exercise the guard logic directly rather than importing server.ts
333
- // (which has top-level side-effects requiring env vars).
334
-
335
- describe('bug 3 — progress-card unpin fires on first of turn_end / reply / stream_reply(done)', () => {
336
- /**
337
- * Miniature simulation of the pin/unpin lifecycle from server.ts.
338
- * Models the progressPinnedMsgIds map, unpinnedTurnKeys set, and the
339
- * unpinProgressCard + unpinProgressCardForChat helpers.
340
- */
341
- function makeUnpinHarness() {
342
- const progressPinnedMsgIds = new Map<string, number>()
343
- const unpinnedTurnKeys = new Set<string>()
344
- const unpinCalls: Array<{ turnKey: string; pinnedId: number }> = []
345
-
346
- function unpinProgressCard(turnKey: string, _chatId: string, pinnedId: number): void {
347
- if (unpinnedTurnKeys.has(turnKey)) return
348
- unpinnedTurnKeys.add(turnKey)
349
- progressPinnedMsgIds.delete(turnKey)
350
- unpinCalls.push({ turnKey, pinnedId })
351
- }
352
-
353
- function unpinProgressCardForChat(chatId: string, threadId: number | undefined): void {
354
- const base = threadId != null ? `${chatId}:${threadId}` : chatId
355
- for (const [turnKey, pinnedId] of progressPinnedMsgIds) {
356
- if (turnKey.startsWith(`${base}:`)) {
357
- unpinProgressCard(turnKey, chatId, pinnedId)
358
- }
359
- }
360
- }
361
-
362
- function pinCard(turnKey: string, chatId: string, messageId: number): void {
363
- progressPinnedMsgIds.set(turnKey, messageId)
364
- }
365
-
366
- function onTurnComplete(turnKey: string, chatId: string): void {
367
- const pinnedId = progressPinnedMsgIds.get(turnKey)
368
- if (pinnedId != null) {
369
- unpinProgressCard(turnKey, chatId, pinnedId)
370
- }
371
- unpinnedTurnKeys.delete(turnKey)
372
- }
373
-
374
- return {
375
- pinCard,
376
- unpinProgressCardForChat,
377
- onTurnComplete,
378
- unpinCalls,
379
- unpinnedTurnKeys,
380
- }
381
- }
382
-
383
- it('reply() triggers unpin before turn_end arrives', () => {
384
- const h = makeUnpinHarness()
385
- // Card pinned at turn start
386
- h.pinCard('100:1', '100', 42)
387
-
388
- // model calls reply() → early unpin
389
- h.unpinProgressCardForChat('100', undefined)
390
-
391
- expect(h.unpinCalls).toHaveLength(1)
392
- expect(h.unpinCalls[0]).toMatchObject({ turnKey: '100:1', pinnedId: 42 })
393
- })
394
-
395
- it('stream_reply(done=true) triggers unpin before turn_end arrives', () => {
396
- const h = makeUnpinHarness()
397
- h.pinCard('200:1', '200', 55)
398
-
399
- // model calls stream_reply(done=true) → early unpin via chat lookup
400
- h.unpinProgressCardForChat('200', undefined)
401
-
402
- expect(h.unpinCalls).toHaveLength(1)
403
- expect(h.unpinCalls[0]).toMatchObject({ turnKey: '200:1', pinnedId: 55 })
404
- })
405
-
406
- it('turn_end after reply-triggered unpin is a no-op (double-unpin guard)', () => {
407
- const h = makeUnpinHarness()
408
- h.pinCard('300:1', '300', 77)
409
-
410
- // reply() fires first
411
- h.unpinProgressCardForChat('300', undefined)
412
- expect(h.unpinCalls).toHaveLength(1)
413
-
414
- // turn_end fires later — onTurnComplete guard should make it a no-op
415
- h.onTurnComplete('300:1', '300')
416
-
417
- // Still only one unpin call
418
- expect(h.unpinCalls).toHaveLength(1)
419
- // Guard entry cleaned up by onTurnComplete
420
- expect(h.unpinnedTurnKeys.has('300:1')).toBe(false)
421
- })
422
-
423
- it('turn_end fires first (normal path) — guard prevents double-unpin on late reply()', () => {
424
- const h = makeUnpinHarness()
425
- h.pinCard('400:1', '400', 88)
426
-
427
- // turn_end fires first (normal path)
428
- h.onTurnComplete('400:1', '400')
429
- expect(h.unpinCalls).toHaveLength(1)
430
- // Guard cleaned up by onTurnComplete
431
- expect(h.unpinnedTurnKeys.has('400:1')).toBe(false)
432
-
433
- // Late reply() fires (e.g. async handler after turn_end)
434
- // unpinProgressCardForChat checks progressPinnedMsgIds which is already empty
435
- h.unpinProgressCardForChat('400', undefined)
436
- // No second unpin
437
- expect(h.unpinCalls).toHaveLength(1)
438
- })
439
-
440
- it('concurrent turns on same chat each get their own unpin', () => {
441
- const h = makeUnpinHarness()
442
- // Two concurrent turns on chat 500 (e.g. two parallel sub-agents)
443
- h.pinCard('500:1', '500', 10)
444
- h.pinCard('500:2', '500', 11)
445
-
446
- // reply() for the chat — unpins all matching cards
447
- h.unpinProgressCardForChat('500', undefined)
448
-
449
- expect(h.unpinCalls).toHaveLength(2)
450
- const turnKeys = h.unpinCalls.map((c) => c.turnKey)
451
- expect(turnKeys).toContain('500:1')
452
- expect(turnKeys).toContain('500:2')
453
- })
454
-
455
- it('unpinProgressCardForChat only unpins cards for the matching chat+thread', () => {
456
- const h = makeUnpinHarness()
457
- h.pinCard('600:1', '600', 20) // chat 600, no thread
458
- h.pinCard('601:1', '601', 21) // chat 601, no thread (different chat)
459
-
460
- // Unpin for chat 600 only
461
- h.unpinProgressCardForChat('600', undefined)
462
-
463
- expect(h.unpinCalls).toHaveLength(1)
464
- expect(h.unpinCalls[0]!.turnKey).toBe('600:1')
465
- })
466
-
467
- it('unpinProgressCardForChat scopes to thread when threadId is set', () => {
468
- const h = makeUnpinHarness()
469
- h.pinCard('700:99:1', '700', 30) // chat 700, thread 99
470
- h.pinCard('700:88:1', '700', 31) // chat 700, thread 88
471
-
472
- // Unpin for thread 99 only
473
- h.unpinProgressCardForChat('700', 99)
474
-
475
- expect(h.unpinCalls).toHaveLength(1)
476
- expect(h.unpinCalls[0]!.turnKey).toBe('700:99:1')
477
- })
478
-
479
- // #235 Wave 3 F4: server.ts monolith removed. The maxIdleMs +
480
- // early-unpin source-text pins for server.ts no longer apply —
481
- // gateway.ts is the source of truth, pinned by the gateway.ts
482
- // assertion below.
483
-
484
- it('gateway.ts executeReply/executeStreamReply do NOT early-unpin', () => {
485
- const gatewaySrc = readFileSync(join(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
486
- expect(gatewaySrc).not.toContain('unpinProgressCardForChat?.(chat_id, threadId)')
487
- expect(gatewaySrc).not.toContain('unpinProgressCardForChat?.(srChatId, srThreadId)')
488
- })
489
- })