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,81 +0,0 @@
1
- /**
2
- * PR-C2 — `dispose({ preservePending: true })` must NOT remove chats
3
- * whose `pendingCompletion === true`.
4
- *
5
- * Regression: commit 4c0186d introduced a dispose() that wiped all
6
- * in-flight card state on every bridge disconnect. The selective
7
- * dispose path was added to keep cards with running background
8
- * sub-agents alive across the disconnect/reconnect cycle.
9
- *
10
- * fails when: dispose's preservePending branch unconditionally clears
11
- * `chats`, OR forgets to leave the heartbeat running while a pending
12
- * chat survives.
13
- */
14
- import { describe, it, expect } from 'vitest'
15
- import { makeHarness, enqueue } from './_progress-card-harness.js'
16
-
17
- describe('PR-C2: dispose({ preservePending: true })', () => {
18
- it('chat with pendingCompletion survives dispose; heartbeat-driven completion still fires after a "reconnect"', () => {
19
- const completions: string[] = []
20
- const { driver, advance } = makeHarness({
21
- minIntervalMs: 0,
22
- coalesceMs: 0,
23
- heartbeatMs: 1_000,
24
- maxIdleMs: 999_999,
25
- deferredCompletionTimeoutMs: 5_000,
26
- promoteAfterMs: 999_999,
27
- onTurnComplete: (s) => completions.push(s.turnKey),
28
- })
29
- const maps = driver._debugGetMaps!()
30
- const CHAT = 'cA'
31
-
32
- // Set up a turn with a background sub-agent so parent turn_end
33
- // produces pendingCompletion=true.
34
- driver.ingest(enqueue(CHAT), null)
35
- driver.ingest(
36
- {
37
- kind: 'tool_use',
38
- toolName: 'Agent',
39
- toolUseId: 'tu1',
40
- input: { prompt: 'bg', run_in_background: true },
41
- },
42
- CHAT,
43
- )
44
- driver.ingest({ kind: 'sub_agent_started', agentId: 'saBG', firstPromptText: 'bg' }, CHAT)
45
- driver.ingest({ kind: 'tool_use', toolName: 'mcp__switchroom-telegram__reply' }, CHAT)
46
- driver.recordOutboundDelivered(CHAT)
47
- driver.ingest({ kind: 'turn_end', durationMs: 100 }, CHAT)
48
-
49
- // Confirm pendingCompletion shape.
50
- expect(maps.chats.size).toBe(1)
51
- const csBefore = [...maps.chats.values()][0] as { pendingCompletion: boolean }
52
- expect(csBefore.pendingCompletion).toBe(true)
53
-
54
- // Bridge disconnect: dispose preserving pending.
55
- driver.dispose!({ preservePending: true })
56
-
57
- // Chat must survive.
58
- expect(maps.chats.size).toBe(1)
59
- const csAfter = [...maps.chats.values()][0] as { pendingCompletion: boolean }
60
- expect(csAfter.pendingCompletion).toBe(true)
61
-
62
- // Now simulate "bridge reconnect" — nothing to do at the driver level
63
- // for that, but the heartbeat must still be wired so the deferred
64
- // completion timeout eventually fires.
65
- advance(15_000)
66
-
67
- // Stalled-cards heartbeat branch should have closed the chat by now.
68
- expect(maps.chats.size).toBe(0)
69
- expect(completions.length).toBe(1)
70
- })
71
-
72
- it('chats WITHOUT pendingCompletion are dropped by preservePending dispose', () => {
73
- const { driver } = makeHarness()
74
- const maps = driver._debugGetMaps!()
75
- driver.ingest(enqueue('cActive'), null)
76
- expect(maps.chats.size).toBe(1)
77
-
78
- driver.dispose!({ preservePending: true })
79
- expect(maps.chats.size).toBe(0)
80
- })
81
- })
@@ -1,80 +0,0 @@
1
- /**
2
- * Pin the wiring for #354's PROGRESS_CARD_DRAFT_TRANSPORT env flag.
3
- *
4
- * Two regressions this guards against:
5
- *
6
- * 1. The flag drifting silently — someone refactors the progress-
7
- * card emit and forgets to thread isPrivateChat / sendMessageDraft
8
- * when the flag is on. Result: the card stays on the legacy edit
9
- * path even though the operator opted in.
10
- *
11
- * 2. The flag turning on by default before the spike unknowns are
12
- * validated. Default-OFF is load-bearing: until we know drafts
13
- * can be pinned and survive bot crashes, the legacy path is the
14
- * safe one.
15
- *
16
- * Source-level pinning rather than behavioural — the gateway emit
17
- * lives inside the bot startup wiring and is hard to drive in
18
- * isolation. The contract here is the literal env-var check + the
19
- * fact that draft deps are conditional on it.
20
- */
21
-
22
- import { describe, it, expect } from 'vitest'
23
- import { readFileSync } from 'node:fs'
24
- import { resolve } from 'node:path'
25
-
26
- const gatewaySrc = readFileSync(
27
- resolve(__dirname, '..', 'gateway', 'gateway.ts'),
28
- 'utf-8',
29
- )
30
-
31
- describe('progress-card draft transport flag (#354)', () => {
32
- it('is gated behind PROGRESS_CARD_DRAFT_TRANSPORT=1 (default OFF)', () => {
33
- // The flag check must be an explicit `=== '1'` not a truthy check
34
- // — `process.env.X === '1'` is the project convention, and a
35
- // truthy check would mis-fire on `=0` or `=false`.
36
- expect(gatewaySrc).toMatch(
37
- /process\.env\.PROGRESS_CARD_DRAFT_TRANSPORT\s*===\s*['"]1['"]/,
38
- )
39
- })
40
-
41
- it('only enables draft when the chat is a DM (no threads, isDmChatId)', () => {
42
- // Drafts don't support forum topics. The flag-check block must
43
- // gate on isDmChatId(chatId) and threadId == null — otherwise a
44
- // forum-topic message would be sent via draft and Telegram would
45
- // reject with DRAFT_CHAT_UNSUPPORTED.
46
- const block = extractDraftBlock(gatewaySrc)
47
- expect(block).toMatch(/isDmChatId\(chatId\)/)
48
- expect(block).toMatch(/threadId\s*==\s*null/)
49
- })
50
-
51
- it('passes both isPrivateChat AND sendMessageDraft when eligible', () => {
52
- // Without sendMessageDraft, stream-reply-handler resolves transport
53
- // to 'message' (line 432: `isForumTopic || deps.sendMessageDraft == null`).
54
- // Both deps must be threaded together for the draft path to fire.
55
- const block = extractDraftBlock(gatewaySrc)
56
- expect(block).toMatch(/isPrivateChat:\s*true/)
57
- expect(block).toMatch(/sendMessageDraft:\s*sendMessageDraftFn/)
58
- })
59
-
60
- it('documents the spike unknowns from #354 inline so an operator can validate', () => {
61
- // The flag exists because pinning + crash behavior are unverified.
62
- // The block comment must call out both so a future contributor
63
- // doesn't flip the default to ON without doing the spike first.
64
- const block = extractDraftBlock(gatewaySrc)
65
- expect(block.toLowerCase()).toMatch(/pin/)
66
- expect(block.toLowerCase()).toMatch(/spike|unknown/)
67
- })
68
- })
69
-
70
- /** Pull the #354 spike block out of gateway.ts for source-level assertions. */
71
- function extractDraftBlock(src: string): string {
72
- // The block starts at the spike comment marker and ends at the next
73
- // `handleStreamReply(` invocation. Big enough to span the gate +
74
- // the conditional draft-deps spread.
75
- const start = src.indexOf('// #354 spike')
76
- expect(start, '#354 spike block not found in gateway.ts').toBeGreaterThan(0)
77
- const end = src.indexOf(').then', start)
78
- expect(end, '#354 spike block end not found').toBeGreaterThan(start)
79
- return src.slice(start, end)
80
- }
@@ -1,215 +0,0 @@
1
- /**
2
- * Regression: TTL eviction of internal dedup maps must NOT depend on the
3
- * heartbeat tick. The heartbeat stops whenever `chats.size === 0`, so any
4
- * eviction inside it leaves `seenEnqueueMsgIds` and `pendingSyncEchoes` to
5
- * grow unbounded across idle periods. Outer-base-key entries
6
- * (`chatRunningSubagents`, `baseTurnSeqs`) likewise need an explicit
7
- * cleanup hook on chat-close because nothing else ever drops them.
8
- *
9
- * Fix shape: an inline throttled `maybeEvict(now)` runs at the top of
10
- * every public ingress, and `completeTurnFully` calls
11
- * `cleanupBaseKeyIfUnused` after `chats.delete`.
12
- */
13
- import { describe, it, expect } from 'vitest'
14
- import { createProgressDriver } from '../progress-card-driver.js'
15
- import type { SessionEvent } from '../session-tail.js'
16
-
17
- let nextMsgId = 9000
18
-
19
- function harness() {
20
- let now = 1000
21
- const timers: Array<{ fireAt: number; fn: () => void; ref: number; repeat?: number }> = []
22
- let nextRef = 0
23
- const emits: Array<{ chatId: string; threadId?: string; turnKey: string; html: string; done: boolean }> = []
24
-
25
- const driver = createProgressDriver({
26
- emit: (a) => emits.push(a),
27
- minIntervalMs: 0,
28
- coalesceMs: 0,
29
- initialDelayMs: 0,
30
- heartbeatMs: 5000,
31
- now: () => now,
32
- setTimeout: (fn, ms) => {
33
- const ref = nextRef++
34
- timers.push({ fireAt: now + ms, fn, ref })
35
- return { ref }
36
- },
37
- clearTimeout: (handle) => {
38
- const target = (handle as { ref: number }).ref
39
- const idx = timers.findIndex((t) => t.ref === target)
40
- if (idx !== -1) timers.splice(idx, 1)
41
- },
42
- setInterval: (fn, ms) => {
43
- const ref = nextRef++
44
- timers.push({ fireAt: now + ms, fn, ref, repeat: ms })
45
- return { ref }
46
- },
47
- clearInterval: (handle) => {
48
- const target = (handle as { ref: number }).ref
49
- const idx = timers.findIndex((t) => t.ref === target)
50
- if (idx !== -1) timers.splice(idx, 1)
51
- },
52
- })
53
-
54
- const advance = (ms: number): void => {
55
- now += ms
56
- for (;;) {
57
- timers.sort((a, b) => a.fireAt - b.fireAt)
58
- const next = timers[0]
59
- if (!next || next.fireAt > now) break
60
- if (next.repeat != null) {
61
- next.fireAt += next.repeat
62
- next.fn()
63
- } else {
64
- timers.shift()
65
- next.fn()
66
- }
67
- }
68
- }
69
-
70
- return { driver, emits, advance, getNow: () => now }
71
- }
72
-
73
- function enqueue(chatId: string, threadId?: string): SessionEvent {
74
- return {
75
- kind: 'enqueue',
76
- chatId,
77
- messageId: String(nextMsgId++),
78
- threadId: threadId ?? null,
79
- rawContent: `<channel chat_id="${chatId}">hi</channel>`,
80
- }
81
- }
82
-
83
- describe('progress-card-driver: TTL eviction off the heartbeat', () => {
84
- it('seenEnqueueMsgIds and pendingSyncEchoes stay bounded across idle periods (chats.size==0)', () => {
85
- const { driver, advance } = harness()
86
- const maps = driver._debugGetMaps!()
87
-
88
- // Drive 20 turn enqueue->complete cycles, advancing past the 60s TTL
89
- // between cycles. Critically, chats.size returns to 0 between cycles,
90
- // so the heartbeat stops — exposing the leak the fix targets.
91
- for (let i = 0; i < 20; i++) {
92
- driver.ingest(enqueue('chatA'), null)
93
- driver.ingest({ kind: 'turn_end', durationMs: 100 }, 'chatA')
94
- // Advance well past both TTLs (60s for messageIds, 30s for echoes)
95
- // and past the eviction throttle (30s) so the next ingest evicts.
96
- advance(65_000)
97
- expect(maps.chats.size).toBe(0)
98
- }
99
-
100
- // After 20 cycles spread across >20 minutes of fake time, both dedup
101
- // maps must be tiny — they should never accumulate stale entries.
102
- expect(maps.seenEnqueueMsgIds.size).toBeLessThanOrEqual(1)
103
- expect(maps.pendingSyncEchoes.size).toBeLessThanOrEqual(1)
104
- })
105
-
106
- it('chatRunningSubagents and baseTurnSeqs drop their base-key on full chat close', () => {
107
- const { driver, advance } = harness()
108
- const maps = driver._debugGetMaps!()
109
-
110
- for (let i = 0; i < 20; i++) {
111
- driver.ingest(enqueue('chatA'), null)
112
- driver.ingest({ kind: 'sub_agent_started', agentId: `agent-${i}`, firstPromptText: 'x' }, 'chatA')
113
- driver.ingest({ kind: 'sub_agent_turn_end', agentId: `agent-${i}`, durationMs: 50 }, 'chatA')
114
- driver.ingest({ kind: 'turn_end', durationMs: 100 }, 'chatA')
115
- advance(65_000)
116
- // Between turns, no chat is alive — outer base-key entries must be
117
- // gone too.
118
- expect(maps.chats.size).toBe(0)
119
- expect(maps.chatRunningSubagents.size).toBe(0)
120
- expect(maps.baseTurnSeqs.size).toBe(0)
121
- }
122
- })
123
-
124
- it('two chats sharing a baseKey: closing one does NOT delete the shared outer key', () => {
125
- // baseKey collapses (chatId, threadId) pairs with no thread to a single
126
- // string. Two threads on the same chat share a base only if they have
127
- // the same threadId; two distinct chatIds always have distinct bases.
128
- // Within a single chat, multiple concurrent turn-keys share the same
129
- // base — closing one of them must NOT prematurely drop the outer key
130
- // while the other turn is still alive.
131
- const { driver } = harness()
132
- const maps = driver._debugGetMaps!()
133
-
134
- // Start turn 1 on chatA — synthesises an enqueue and creates the card.
135
- driver.startTurn({ chatId: 'chatA', userText: 'first' })
136
- expect(maps.chats.size).toBe(1)
137
- expect(maps.baseTurnSeqs.get('chatA')).toBeGreaterThanOrEqual(1)
138
-
139
- // A second startTurn on the same chat force-closes turn 1 and creates
140
- // turn 2 — there is exactly one chat live again, but baseTurnSeqs has
141
- // ticked to 2. That outer entry must remain because turn 2 is alive.
142
- driver.startTurn({ chatId: 'chatA', userText: 'second' })
143
- expect(maps.chats.size).toBe(1)
144
- const seqAfter = maps.baseTurnSeqs.get('chatA')
145
- expect(seqAfter).toBeGreaterThanOrEqual(2)
146
-
147
- // End turn 2 → chats empty → base-key cleanup must run.
148
- driver.ingest({ kind: 'turn_end', durationMs: 100 }, 'chatA')
149
- expect(maps.chats.size).toBe(0)
150
- expect(maps.baseTurnSeqs.has('chatA')).toBe(false)
151
- expect(maps.chatRunningSubagents.has('chatA')).toBe(false)
152
- })
153
-
154
- it('two distinct chats: closing one does NOT touch the other base-key', () => {
155
- // The driver routes turn_end via `currentTurnKey`, not `chatIdMaybe` —
156
- // a quirk of the session-tail single-stream design. To close a specific
157
- // chat from a test we use `forceCompleteTurn`, which is the path the
158
- // gateway invokes for explicit per-chat fan-out.
159
- const { driver } = harness()
160
- const maps = driver._debugGetMaps!()
161
-
162
- driver.ingest(enqueue('chatA'), null)
163
- driver.ingest(enqueue('chatB'), null)
164
- expect(maps.chats.size).toBe(2)
165
- expect(maps.baseTurnSeqs.has('chatA')).toBe(true)
166
- expect(maps.baseTurnSeqs.has('chatB')).toBe(true)
167
-
168
- // Close A only — must not touch chatB's base-key.
169
- driver.forceCompleteTurn({ chatId: 'chatA' })
170
- expect(maps.baseTurnSeqs.has('chatA')).toBe(false)
171
- expect(maps.baseTurnSeqs.has('chatB')).toBe(true)
172
-
173
- // Now close B.
174
- driver.forceCompleteTurn({ chatId: 'chatB' })
175
- expect(maps.baseTurnSeqs.has('chatB')).toBe(false)
176
- expect(maps.chats.size).toBe(0)
177
- })
178
-
179
- it('PR-C2 follow-up: bg-subagent-carry guard — chatRunningSubagents inner map survives turn_end while a sub-agent is still in flight', () => {
180
- // When parent turn_end fires but a sub-agent is still running, the
181
- // chatState enters pendingCompletion and the per-base
182
- // `chatRunningSubagents` inner map MUST NOT be cleaned up — the
183
- // next turn's enqueue will clone it back into the new fleet
184
- // (issue #334 / #64). Cleanup on close is gated on the inner map
185
- // being empty.
186
- const { driver } = harness()
187
- const maps = driver._debugGetMaps!()
188
-
189
- driver.ingest(enqueue('chatA'), null)
190
- driver.ingest(
191
- {
192
- kind: 'tool_use', toolName: 'Agent', toolUseId: 'tu1',
193
- input: { prompt: 'bg', run_in_background: true },
194
- },
195
- 'chatA',
196
- )
197
- driver.ingest({ kind: 'sub_agent_started', agentId: 'saBG', firstPromptText: 'bg' }, 'chatA')
198
- driver.ingest({ kind: 'tool_use', toolName: 'mcp__switchroom-telegram__reply' }, 'chatA')
199
- driver.recordOutboundDelivered('chatA')
200
- driver.ingest({ kind: 'turn_end', durationMs: 100 }, 'chatA')
201
-
202
- // Pending — chats.size==1 (originating bg-pending state survives).
203
- expect(maps.chats.size).toBe(1)
204
- // Critical: the running-subagents inner map for 'chatA' must still
205
- // contain saBG. If cleanupBaseKeyIfUnused regressed and ran here,
206
- // the next turn would lose the bg carry.
207
- expect(maps.chatRunningSubagents.get('chatA')?.has('saBG')).toBe(true)
208
-
209
- // Resolve the bg sub-agent; now full close should also drain the
210
- // sync registry inner map.
211
- driver.ingest({ kind: 'sub_agent_turn_end', agentId: 'saBG' }, 'chatA')
212
- expect(maps.chats.size).toBe(0)
213
- expect(maps.chatRunningSubagents.has('chatA')).toBe(false)
214
- })
215
- })
@@ -1,123 +0,0 @@
1
- /**
2
- * P0 of #662 — invariant test that the driver's `fleet` shadow Map
3
- * stays in lockstep with the legacy `chatState.subAgents` map across a
4
- * full sub-agent lifecycle.
5
- *
6
- * We drive the real driver via createProgressDriver (no Telegram bot
7
- * required — the emit callback just records calls) and feed a complete
8
- * lifecycle: started → 3× tool_use → tool_result(isError=true) →
9
- * turn_end. After every event we assert:
10
- * - cardinality matches between fleet and subAgents
11
- * - on terminal turn_end: status='failed' (errorSeen accumulated)
12
- * - originatingTurnKey was snapshotted from currentTurnKey
13
- * - lastTool reflects the most recent tool_use's sanitised arg
14
- */
15
-
16
- import { describe, it, expect } from 'vitest'
17
- import { createProgressDriver } from '../progress-card-driver.js'
18
- import type { SessionEvent } from '../session-tail.js'
19
-
20
- function harness() {
21
- let now = 1000
22
- const timers: Array<{ fireAt: number; fn: () => void; ref: number; repeat?: number }> = []
23
- let nextRef = 0
24
- const driver = createProgressDriver({
25
- emit: () => {},
26
- minIntervalMs: 500,
27
- coalesceMs: 400,
28
- initialDelayMs: 0,
29
- promoteAfterMs: 999_999,
30
- now: () => now,
31
- setTimeout: (fn, ms) => {
32
- const ref = nextRef++
33
- timers.push({ fireAt: now + ms, fn, ref })
34
- return { ref }
35
- },
36
- clearTimeout: (h) => {
37
- const ref = (h as { ref: number }).ref
38
- const idx = timers.findIndex((t) => t.ref === ref)
39
- if (idx !== -1) timers.splice(idx, 1)
40
- },
41
- setInterval: (fn, ms) => {
42
- const ref = nextRef++
43
- timers.push({ fireAt: now + ms, fn, ref, repeat: ms })
44
- return { ref }
45
- },
46
- clearInterval: (h) => {
47
- const ref = (h as { ref: number }).ref
48
- const idx = timers.findIndex((t) => t.ref === ref)
49
- if (idx !== -1) timers.splice(idx, 1)
50
- },
51
- })
52
- return { driver, advance: (ms: number) => { now += ms } }
53
- }
54
-
55
- const enqueue = (chatId: string): SessionEvent => ({
56
- kind: 'enqueue',
57
- chatId,
58
- messageId: '1',
59
- threadId: null,
60
- rawContent: `<channel chat_id="${chatId}">go</channel>`,
61
- })
62
-
63
- describe('driver fleet-state shadow', () => {
64
- it('shadow Map stays in lockstep with chatState.subAgents through a failed sub-agent lifecycle', () => {
65
- const { driver } = harness()
66
- const CHAT = 'c1'
67
- driver.ingest(enqueue(CHAT), null)
68
-
69
- const events: SessionEvent[] = [
70
- { kind: 'sub_agent_started', agentId: 'sa1', firstPromptText: 'do work', subagentType: 'worker' },
71
- { kind: 'sub_agent_tool_use', agentId: 'sa1', toolUseId: 't1', toolName: 'Read', input: { file_path: '/etc/secrets/k.key' } },
72
- { kind: 'sub_agent_tool_use', agentId: 'sa1', toolUseId: 't2', toolName: 'Bash', input: { command: 'ls' } },
73
- { kind: 'sub_agent_tool_use', agentId: 'sa1', toolUseId: 't3', toolName: 'Edit', input: { file_path: '/tmp/x.ts' } },
74
- { kind: 'sub_agent_tool_result', agentId: 'sa1', toolUseId: 't3', isError: true, errorText: 'boom' },
75
- ]
76
-
77
- for (const ev of events) {
78
- driver.ingest(ev, CHAT)
79
- const state = driver.peek(CHAT)
80
- const fleet = driver.peekFleet(CHAT)
81
- expect(state).toBeDefined()
82
- expect(fleet).toBeDefined()
83
- // Cardinality invariant — every sub-agent in the legacy map has a
84
- // shadow entry, and vice versa.
85
- expect(fleet!.size).toBe(state!.subAgents.size)
86
- for (const id of state!.subAgents.keys()) {
87
- expect(fleet!.has(id)).toBe(true)
88
- }
89
- }
90
-
91
- // Pre-turn-end: fleet member exists, status still running, errorSeen true.
92
- const midFleet = driver.peekFleet(CHAT)!
93
- const midMember = midFleet.get('sa1')!
94
- expect(midMember.status).toBe('running')
95
- expect(midMember.errorSeen).toBe(true)
96
- expect(midMember.toolCount).toBe(3)
97
- expect(midMember.lastTool).toEqual({ name: 'Edit', sanitisedArg: 'x.ts' })
98
- expect(midMember.role).toBe('worker') // from subagentType fallback
99
- // Snapshotted from currentTurnKey at sub_agent_started.
100
- expect(midMember.originatingTurnKey).toMatch(/^c1:/)
101
-
102
- // Now end the sub-agent's turn — fleet member should flip to failed.
103
- driver.ingest({ kind: 'sub_agent_turn_end', agentId: 'sa1' }, CHAT)
104
- const finalFleet = driver.peekFleet(CHAT)!
105
- const finalMember = finalFleet.get('sa1')!
106
- expect(finalMember.status).toBe('failed')
107
- expect(finalMember.terminalAt).not.toBeNull()
108
- })
109
-
110
- it('uses description as role when present', () => {
111
- const { driver } = harness()
112
- driver.ingest(enqueue('c2'), null)
113
- // session-tail's sub_agent_started doesn't carry description directly,
114
- // but the watcher path supplies subagentType — verify the fallback chain
115
- // works when neither is set: first 20 chars of firstPromptText.
116
- driver.ingest(
117
- { kind: 'sub_agent_started', agentId: 'sa2', firstPromptText: 'investigate the auth bug end-to-end' },
118
- 'c2',
119
- )
120
- const m = driver.peekFleet('c2')!.get('sa2')!
121
- expect(m.role).toBe('investigate the auth')
122
- })
123
- })
@@ -1,76 +0,0 @@
1
- /**
2
- * Regression: forceCompleteTurn must set `parentTurnEndAt` so the heartbeat's
3
- * `parentDone` branch lights up and the elapsed counter keeps ticking through
4
- * `subAgentTickIntervalMs` while sub-agents are still running.
5
- *
6
- * Bug shape (#686, fixed in #687): forceCompleteTurn reduced `turn_end` but
7
- * never set `parentTurnEndAt`. The heartbeat's `parentDone` was therefore
8
- * always false during the deferred-unpin window, the elapsed-ticker bypass
9
- * never engaged, and the rendered card froze on its last emit until the
10
- * sub-agents finished.
11
- *
12
- * Test shape: spawn a sub-agent, call forceCompleteTurn, then advance fake
13
- * time across several heartbeat ticks. The "Done" header (parentDone branch
14
- * of the renderer) MUST appear and elapsed time MUST keep advancing in the
15
- * emitted HTML.
16
- */
17
- import { describe, it, expect } from 'vitest'
18
- import { makeHarness, enqueue } from './_progress-card-harness.js'
19
-
20
- describe('progress-card-driver: forceCompleteTurn unfreezes elapsed-ticker', () => {
21
- it('parentDone engages and elapsed advances after forceCompleteTurn while a sub-agent is still running', () => {
22
- const { driver, emits, advance } = makeHarness({
23
- minIntervalMs: 0,
24
- coalesceMs: 0,
25
- heartbeatMs: 1_000,
26
- })
27
- const CHAT = 'chatF'
28
-
29
- driver.ingest(enqueue(CHAT), null)
30
- driver.ingest(
31
- {
32
- kind: 'tool_use',
33
- toolName: 'Agent',
34
- toolUseId: 'tu1',
35
- input: { prompt: 'bg work', run_in_background: true },
36
- },
37
- CHAT,
38
- )
39
- driver.ingest({ kind: 'sub_agent_started', agentId: 'saBG', firstPromptText: 'bg work' }, CHAT)
40
- driver.ingest({ kind: 'tool_use', toolName: 'mcp__switchroom-telegram__reply' }, CHAT)
41
- driver.recordOutboundDelivered(CHAT)
42
-
43
- // External completion signal (e.g. stream_reply done=true). Sub-agent
44
- // is still running, so the chatState enters pendingCompletion and the
45
- // heartbeat keeps the card alive.
46
- driver.forceCompleteTurn({ chatId: CHAT })
47
-
48
- // The flush triggered by forceCompleteTurn itself produces an emit
49
- // with the parentDone-branch ("Done") header.
50
- const emitsAfterForce = emits.length
51
- expect(emitsAfterForce).toBeGreaterThan(0)
52
- const renderedAtForce = emits[emitsAfterForce - 1].html
53
- // The renderer surfaces "Background" once parentDone=true and a bg
54
- // sub-agent is still running; if the bug regresses (parentTurnEndAt
55
- // stays null), parentDone is false and we'd see "Working".
56
- expect(renderedAtForce).toMatch(/Background/)
57
- expect(renderedAtForce).not.toMatch(/Working/)
58
-
59
- // Advance well past the elapsed-ticker interval to prove the
60
- // heartbeat keeps emitting fresh elapsed values rather than freezing
61
- // on the last emit. Several ticks should produce at least one extra
62
- // emit with different rendered HTML.
63
- advance(15_000)
64
- const tailEmits = emits.slice(emitsAfterForce)
65
- expect(tailEmits.length).toBeGreaterThan(0)
66
- // At least one post-force emit must differ from the force-emit HTML —
67
- // proving the elapsed counter advanced rather than freezing.
68
- const advanced = tailEmits.some((e) => e.html !== renderedAtForce)
69
- expect(advanced).toBe(true)
70
-
71
- // Resolve the sub-agent and assert the deferred completion fires.
72
- driver.ingest({ kind: 'sub_agent_turn_end', agentId: 'saBG' }, CHAT)
73
- const finalEmit = emits[emits.length - 1]
74
- expect(finalEmit.done).toBe(true)
75
- })
76
- })
@@ -1,62 +0,0 @@
1
- /**
2
- * PR-C2 — `editTimestamps` sliding-window cleanup must keep the array
3
- * bounded under sustained burst.
4
- *
5
- * The driver maintains a per-turn array of recent emit timestamps and
6
- * uses its length within the trailing 60s to flip "hot" mode (longer
7
- * coalesce window). The cleanup is `while (arr[0] < cutoff) arr.shift()`,
8
- * which only fires when `recordEdit` or `isBudgetHot` is called. If
9
- * that cleanup ever regresses, the array would grow unbounded across a
10
- * long-running turn.
11
- *
12
- * fails when: the `arr.shift()` cleanup is removed from `recordEdit` or
13
- * `isBudgetHot`, OR the cutoff window is widened beyond 60s without a
14
- * matching upper bound.
15
- */
16
- import { describe, it, expect } from 'vitest'
17
- import { makeHarness, enqueue } from './_progress-card-harness.js'
18
-
19
- describe('PR-C2: editTimestamps stays bounded under sustained emit burst', () => {
20
- it('after 100 emits spread across 5 minutes, the per-turn array holds <= ~window-worth', () => {
21
- // Use very low coalesce so each ingest can drive an emit. Keep the
22
- // turn open for the whole burst.
23
- const { driver, advance } = makeHarness({
24
- minIntervalMs: 0,
25
- coalesceMs: 0,
26
- heartbeatMs: 999_999, // never auto-fire heartbeat
27
- promoteAfterMs: 999_999,
28
- })
29
- const maps = driver._debugGetMaps!()
30
-
31
- driver.ingest(enqueue('cA'), null)
32
-
33
- // Drive 100 events spaced 3s apart — so the trailing 60s only ever
34
- // contains ~21 timestamps. With a working sliding-window cleanup the
35
- // array should stay bounded near that figure.
36
- for (let i = 0; i < 100; i++) {
37
- driver.ingest(
38
- {
39
- kind: 'tool_use',
40
- toolName: 'Read',
41
- toolUseId: `tu${i}`,
42
- input: { file_path: `/tmp/${i}.txt` },
43
- },
44
- 'cA',
45
- )
46
- advance(3000)
47
- }
48
-
49
- // Find the per-turn timestamp array. The key is the active turnKey;
50
- // there's only one turn so we can pick it.
51
- const sizes = [...maps.editTimestamps.values()].map((a) => a.length)
52
- expect(sizes.length).toBeGreaterThan(0)
53
- const max = Math.max(...sizes)
54
- // 60s window / 3s spacing = 20 entries. Allow tight slack (<= 22)
55
- // for one or two boundary timestamps recorded by the harness's
56
- // setup (initial enqueue, etc.) — anything looser fails to catch
57
- // off-by-N regressions in the sliding-window cleanup.
58
- expect(max).toBeLessThanOrEqual(22)
59
- // And critically, NOT 100+ — that would mean cleanup never ran.
60
- expect(max).toBeLessThan(100)
61
- })
62
- })