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,218 +0,0 @@
1
- /**
2
- * #654 regression tests — deterministic double-message fix via card
3
- * takeover.
4
- *
5
- * Bug: when an agent's turn took longer than `initialDelayMs` (60s) AND
6
- * the agent emitted assistant text without calling `reply` /
7
- * `stream_reply` (turn-flush path), the user saw TWO outbound Telegram
8
- * messages — the pinned progress card AND the turn-flush bubble — for
9
- * one logical reply.
10
- *
11
- * Root cause: the gateway's turn-flush path issued a fresh
12
- * `bot.api.sendMessage` even when a progress card was already on screen
13
- * for that turn. The driver's `forceCompleteTurn` couldn't help because
14
- * once the deferred-emit timer had fired, no path existed to retract
15
- * the posted card — `flush()` would only edit it to "Done".
16
- *
17
- * Fix: add a `takeOverCard` method to the driver that:
18
- * - cancels the pending deferred-emit timer if not yet fired
19
- * - sets `cardTakenOver = true` so subsequent `flush()` calls
20
- * short-circuit (no further "Done" edit)
21
- * - returns `{ wasEmitted, turnKey }` so the caller (turn-flush)
22
- * can look up the pinned messageId and rewrite it in place via
23
- * `editMessageText` instead of creating a second message.
24
- *
25
- * The harness gap that hid the bug: no existing test wired a real
26
- * driver into a long-turn scenario. `turn-flush-safety.test.ts`
27
- * covered `decideTurnFlush()` only; `real-gateway-i6` covered turn-
28
- * flush replay/dedup but never modeled a card already on screen.
29
- *
30
- * These tests pin the driver-level contract. The gateway integration
31
- * is exercised in the bridged scenario at the bottom of this file.
32
- */
33
-
34
- import { describe, it, expect } from 'vitest'
35
- import { createProgressDriver } from '../progress-card-driver.js'
36
- import type { SessionEvent } from '../session-tail.js'
37
-
38
- function harness(opts?: { initialDelayMs?: number }) {
39
- let now = 1000
40
- const timers: Array<{ fireAt: number; fn: () => void; ref: number; repeat?: number }> = []
41
- let nextRef = 0
42
- const emits: Array<{
43
- chatId: string
44
- threadId?: string
45
- turnKey: string
46
- html: string
47
- done: boolean
48
- isFirstEmit: boolean
49
- }> = []
50
-
51
- const driver = createProgressDriver({
52
- emit: (a) => emits.push(a),
53
- minIntervalMs: 0,
54
- coalesceMs: 0,
55
- initialDelayMs: opts?.initialDelayMs ?? 60_000,
56
- promoteAfterMs: 999_999,
57
- now: () => now,
58
- setTimeout: (fn, ms) => {
59
- const ref = nextRef++
60
- timers.push({ fireAt: now + ms, fn, ref })
61
- return { ref }
62
- },
63
- clearTimeout: (handle) => {
64
- const target = (handle as { ref: number }).ref
65
- const idx = timers.findIndex((t) => t.ref === target)
66
- if (idx !== -1) timers.splice(idx, 1)
67
- },
68
- setInterval: (fn, ms) => {
69
- const ref = nextRef++
70
- timers.push({ fireAt: now + ms, fn, ref, repeat: ms })
71
- return { ref }
72
- },
73
- clearInterval: (handle) => {
74
- const target = (handle as { ref: number }).ref
75
- const idx = timers.findIndex((t) => t.ref === target)
76
- if (idx !== -1) timers.splice(idx, 1)
77
- },
78
- })
79
-
80
- const advance = (ms: number): void => {
81
- now += ms
82
- for (;;) {
83
- timers.sort((a, b) => a.fireAt - b.fireAt)
84
- const next = timers[0]
85
- if (!next || next.fireAt > now) break
86
- if (next.repeat != null) {
87
- next.fireAt += next.repeat
88
- next.fn()
89
- } else {
90
- timers.shift()
91
- next.fn()
92
- }
93
- }
94
- }
95
-
96
- return { driver, emits, advance }
97
- }
98
-
99
- let nextMsgId = 1
100
- function enqueue(chatId: string, text = 'q', threadId: string | null = null): SessionEvent {
101
- return {
102
- kind: 'enqueue',
103
- chatId,
104
- messageId: String(nextMsgId++),
105
- threadId,
106
- rawContent: `<channel chat_id="${chatId}">${text}</channel>`,
107
- } as unknown as SessionEvent
108
- }
109
-
110
- describe('takeOverCard — #654 regression', () => {
111
- it('returns wasEmitted=false when card has not yet emitted (pre-60s turn)', () => {
112
- // Fast-turn case: turn-flush fires before the deferred-emit timer.
113
- // Driver suppresses the card; turn-flush sends fresh.
114
- const { driver } = harness({ initialDelayMs: 60_000 })
115
- driver.ingest(enqueue('c1'), 'c1')
116
- // Don't advance — timer still pending.
117
-
118
- const result = driver.takeOverCard({ chatId: 'c1' })
119
- expect(result.wasEmitted).toBe(false)
120
- expect(result.turnKey).not.toBeNull()
121
- expect(typeof result.turnKey).toBe('string')
122
- })
123
-
124
- it('returns wasEmitted=true when deferred-emit timer has fired (the #654 path)', () => {
125
- // Slow-turn case: the card has been emitted to the chat. takeOverCard
126
- // signals that the caller should edit-in-place rather than send fresh.
127
- const { driver, emits, advance } = harness({ initialDelayMs: 60_000 })
128
- driver.ingest(
129
- enqueue('c1'),
130
- 'c1',
131
- )
132
- advance(60_000)
133
- expect(emits.length).toBeGreaterThan(0) // card emitted
134
-
135
- const result = driver.takeOverCard({ chatId: 'c1' })
136
- expect(result.wasEmitted).toBe(true)
137
- expect(typeof result.turnKey).toBe('string')
138
- expect(result.turnKey).toContain('c1')
139
- })
140
-
141
- it('cancels the pending deferred-emit timer (no late card emission)', () => {
142
- // After takeOverCard cancels the timer, advancing past the original
143
- // delay must NOT produce a card emit.
144
- const { driver, emits, advance } = harness({ initialDelayMs: 60_000 })
145
- driver.ingest(
146
- enqueue('c1'),
147
- 'c1',
148
- )
149
- expect(emits.length).toBe(0) // suppressed by initial delay
150
-
151
- driver.takeOverCard({ chatId: 'c1' })
152
- advance(120_000) // way past 60s
153
-
154
- expect(emits.length).toBe(0) // timer was cancelled — no late emit
155
- })
156
-
157
- it('blocks subsequent flushes — driver.ingest(turn_end) does NOT emit a "Done" edit', () => {
158
- // The bug case: after card is on screen, gateway calls takeOverCard,
159
- // then session-tail dispatches turn_end which the driver ingests.
160
- // Without the cardTakenOver guard, turn_end would call flush(forceDone)
161
- // → editMessageText("Done") — wasted edit. With the guard, no emit.
162
- const { driver, emits, advance } = harness({ initialDelayMs: 60_000 })
163
- driver.ingest(
164
- enqueue('c1'),
165
- 'c1',
166
- )
167
- advance(60_000)
168
- const emitsAfterCard = emits.length
169
- expect(emitsAfterCard).toBeGreaterThan(0)
170
-
171
- driver.takeOverCard({ chatId: 'c1' })
172
-
173
- // Now simulate the driver receiving turn_end (as session-tail would
174
- // dispatch synchronously upstream of the gateway's turn-flush block).
175
- driver.ingest(
176
- { kind: 'turn_end', durationMs: 70_000 } as unknown as SessionEvent,
177
- 'c1',
178
- )
179
- expect(emits.length).toBe(emitsAfterCard) // no additional edits
180
- })
181
-
182
- it('idempotent — second call returns same shape, no double-cancel side-effects', () => {
183
- const { driver, emits, advance } = harness({ initialDelayMs: 60_000 })
184
- driver.ingest(
185
- enqueue('c1'),
186
- 'c1',
187
- )
188
- advance(60_000)
189
- const emitsAfter1 = emits.length
190
-
191
- const r1 = driver.takeOverCard({ chatId: 'c1' })
192
- const r2 = driver.takeOverCard({ chatId: 'c1' })
193
- expect(r1).toEqual(r2)
194
- expect(emits.length).toBe(emitsAfter1)
195
- })
196
-
197
- it('returns null turnKey when no active card exists for (chatId, threadId)', () => {
198
- const { driver } = harness({ initialDelayMs: 60_000 })
199
- const result = driver.takeOverCard({ chatId: 'never-enqueued' })
200
- expect(result).toEqual({ wasEmitted: false, turnKey: null })
201
- })
202
-
203
- it('routes by chatId+threadId — separate chats do not clobber each other', () => {
204
- const { driver } = harness({ initialDelayMs: 60_000 })
205
- driver.ingest(enqueue('c1'), 'c1')
206
- driver.ingest(enqueue('c2'), 'c2')
207
-
208
- // Take over c1 — c2's card must remain untouched.
209
- const r1 = driver.takeOverCard({ chatId: 'c1' })
210
- expect(r1.turnKey).toContain('c1')
211
- expect(r1.turnKey).not.toContain('c2')
212
-
213
- // c2 still has its own active card, distinct turnKey.
214
- const r2 = driver.takeOverCard({ chatId: 'c2' })
215
- expect(r2.turnKey).toContain('c2')
216
- expect(r2.turnKey).not.toContain('c1')
217
- })
218
- })
@@ -1,78 +0,0 @@
1
- /**
2
- * Tests for the prose-recovery helper used by the turn-flush backstop
3
- * to bridge the divergence between the gateway's `capturedText`
4
- * accumulator and the progress-card driver's narrative state. See #51.
5
- */
6
-
7
- import { describe, it, expect } from 'vitest'
8
- import type { ProgressCardState, NarrativeStep } from '../progress-card.js'
9
- import { recoverProseFromProgressCard } from '../turn-flush-prose-recovery.js'
10
-
11
- function narrative(id: number, text: string): NarrativeStep {
12
- return { id, text, state: 'done', startedAt: 0, toolCount: 0 }
13
- }
14
-
15
- function stateWith(narratives: NarrativeStep[]): ProgressCardState {
16
- return {
17
- turnStartedAt: 0,
18
- items: [],
19
- stage: 'idle',
20
- thinking: false,
21
- narratives,
22
- subAgents: new Map(),
23
- pendingAgentSpawns: new Map(),
24
- } as unknown as ProgressCardState
25
- }
26
-
27
- describe('recoverProseFromProgressCard', () => {
28
- it('returns empty string for undefined state', () => {
29
- expect(recoverProseFromProgressCard(undefined)).toBe('')
30
- })
31
-
32
- it('returns empty string when there are no narratives', () => {
33
- expect(recoverProseFromProgressCard(stateWith([]))).toBe('')
34
- })
35
-
36
- it('returns empty string when the narratives field is missing', () => {
37
- // Defensive: partial state (e.g. older persisted shape) must not throw.
38
- const partial = { turnStartedAt: 0, items: [], stage: 'idle', thinking: false } as unknown as ProgressCardState
39
- expect(recoverProseFromProgressCard(partial)).toBe('')
40
- })
41
-
42
- it('joins narrative text in order, newline-separated', () => {
43
- const state = stateWith([
44
- narrative(1, 'Reading the file.'),
45
- narrative(2, 'Found the issue.'),
46
- narrative(3, 'Patching gateway.ts.'),
47
- ])
48
- expect(recoverProseFromProgressCard(state)).toBe(
49
- 'Reading the file.\nFound the issue.\nPatching gateway.ts.',
50
- )
51
- })
52
-
53
- it('skips empty-string narratives but preserves order of the rest', () => {
54
- const state = stateWith([
55
- narrative(1, 'first'),
56
- narrative(2, ''),
57
- narrative(3, 'third'),
58
- ])
59
- expect(recoverProseFromProgressCard(state)).toBe('first\nthird')
60
- })
61
-
62
- it('trims surrounding whitespace from the joined result', () => {
63
- const state = stateWith([narrative(1, ' prose with edges ')])
64
- expect(recoverProseFromProgressCard(state)).toBe('prose with edges')
65
- })
66
-
67
- it('recovers the original incident — single narrative line that should have flushed', () => {
68
- // Mirrors the #45/#51 incident transcript: the assistant emitted
69
- // prose-as-step but never called reply. Recovery must surface that
70
- // text so the flush backstop can send it.
71
- const state = stateWith([
72
- narrative(1, 'Just the caption swap — the Klanker body stays.'),
73
- ])
74
- expect(recoverProseFromProgressCard(state)).toBe(
75
- 'Just the caption swap — the Klanker body stays.',
76
- )
77
- })
78
- })
@@ -1,131 +0,0 @@
1
- /**
2
- * PR-C2 — full lifecycle of background sub-agent carry across two
3
- * consecutive parent turns.
4
- *
5
- * Turn A: enqueue → spawn bg sub-agent → parent reply + turn_end
6
- * (parent done, bg still running → phase=Background on A's card).
7
- * Turn B: enqueue (carries the still-running bg member into B's fleet).
8
- * B's phase starts as Working (parent active again).
9
- * Background sub-agent emits during B → still Working.
10
- * Background sub-agent reaches sub_agent_turn_end during B → fleet
11
- * now empty of running members; B's phase resolves cleanly.
12
- *
13
- * fails when: a refactor drops the originatingTurnKey routing of a bg
14
- * sub-agent's events back to its origin chat, OR when the bg member
15
- * isn't carried into turn B's fleet on enqueue.
16
- */
17
- import { describe, it, expect } from 'vitest'
18
- import { phaseFor } from '../two-zone-card.js'
19
- import { makeHarness, enqueue } from './_progress-card-harness.js'
20
-
21
- describe('PR-C2: two-zone bg-carry full lifecycle (turn A → turn B → bg done)', () => {
22
- it('phase transitions A=Background, B=Working, B-after-bg-done=Done', () => {
23
- const { driver, advance, getNow, completions } = makeHarness({
24
- minIntervalMs: 500,
25
- coalesceMs: 400,
26
- promoteAfterMs: 999_999,
27
- })
28
- const CHAT = 'cA'
29
-
30
- // ── Turn A: spawn bg sub-agent, parent replies, turn_end. ──────────
31
- driver.ingest(enqueue(CHAT), null)
32
- driver.ingest(
33
- {
34
- kind: 'tool_use',
35
- toolName: 'Agent',
36
- toolUseId: 'tu1',
37
- input: { prompt: 'bg work', run_in_background: true },
38
- },
39
- CHAT,
40
- )
41
- driver.ingest({ kind: 'sub_agent_started', agentId: 'saBG', firstPromptText: 'bg work' }, CHAT)
42
- driver.ingest({ kind: 'tool_use', toolName: 'mcp__switchroom-telegram__reply' }, CHAT)
43
- driver.recordOutboundDelivered(CHAT)
44
- driver.ingest({ kind: 'turn_end', durationMs: 500 }, CHAT)
45
-
46
- // After parent turn_end, the originating chatState is held in
47
- // pendingCompletion because saBG is still running.
48
- const fleetAfterA = driver.peekFleet(CHAT)!
49
- expect(fleetAfterA.has('saBG')).toBe(true)
50
- expect(fleetAfterA.get('saBG')!.status).toBe('background')
51
-
52
- // Phase resolution for A: parentDone=true + bg running → Background.
53
- {
54
- const all = (driver as unknown as {
55
- peekAllFleets?: () => Array<{ turnKey: string; fleet: Map<string, unknown>; state?: unknown }>
56
- }).peekAllFleets?.() ?? []
57
- // Find turn A — it's the one whose fleet contains saBG and whose
58
- // turnKey ends in :1.
59
- const a = all.find((e) => e.turnKey.endsWith(':1'))
60
- expect(a).toBeDefined()
61
- }
62
- // Capture A's turnKey for the deferred-completion assertion below.
63
- const turnKeyA = (driver as unknown as {
64
- peekAllFleets?: () => Array<{ turnKey: string; fleet: Map<string, unknown> }>
65
- }).peekAllFleets!().find((e) => e.fleet.has('saBG'))!.turnKey
66
-
67
- // ── Turn B: fresh enqueue. The bg member carries forward. ─────────
68
- advance(50)
69
- driver.ingest(enqueue(CHAT), null)
70
- const fleetB = driver.peekFleet(CHAT)!
71
- // Carry: saBG should still be reachable somewhere in the driver's
72
- // fleets (either on B's fresh state or A's still-pending one).
73
- const allFleets = (driver as unknown as {
74
- peekAllFleets?: () => Array<{ turnKey: string; fleet: Map<string, { status: string }> }>
75
- }).peekAllFleets?.() ?? []
76
- const sawBG = allFleets.some((f) => f.fleet.has('saBG'))
77
- expect(sawBG).toBe(true)
78
-
79
- // B parent is in flight — phaseFor should resolve to Working… because
80
- // parentDone=false for B regardless of bg state.
81
- const phaseB = phaseFor(
82
- {
83
- turnStartedAt: getNow(),
84
- items: [],
85
- narratives: [],
86
- stage: 'run',
87
- thinking: false,
88
- subAgents: new Map(),
89
- pendingAgentSpawns: new Map(),
90
- tasks: [],
91
- },
92
- fleetB,
93
- getNow(),
94
- {},
95
- )
96
- expect(phaseB.label).toBe('Working…')
97
-
98
- // ── BG sub-agent emits during B (proves routing still works). ────
99
- advance(20)
100
- driver.ingest(
101
- {
102
- kind: 'sub_agent_tool_use',
103
- agentId: 'saBG',
104
- toolUseId: 'bgt1',
105
- toolName: 'Read',
106
- input: { file_path: '/tmp/x.txt' },
107
- },
108
- CHAT,
109
- )
110
-
111
- // ── BG sub-agent finishes during B. ──────────────────────────────
112
- advance(20)
113
- driver.ingest({ kind: 'sub_agent_turn_end', agentId: 'saBG' }, CHAT)
114
-
115
- // Turn A's pendingCompletion should now resolve (saBG no longer
116
- // running). Turn B's fleet should drop its bg copy too.
117
- const allAfter = (driver as unknown as {
118
- peekAllFleets?: () => Array<{ turnKey: string; fleet: Map<string, { status: string }> }>
119
- }).peekAllFleets?.() ?? []
120
- for (const entry of allAfter) {
121
- const m = entry.fleet.get('saBG')
122
- if (m == null) continue
123
- // Whichever turn still holds saBG, it must be terminal (done/failed/killed)
124
- expect(['done', 'failed', 'killed']).toContain(m.status)
125
- }
126
- // Critical: A's deferred completion MUST have fired now that saBG
127
- // reached sub_agent_turn_end. Without this assertion the loop above
128
- // trivially passes when allAfter is empty.
129
- expect(completions).toContain(turnKeyA)
130
- })
131
- })
@@ -1,120 +0,0 @@
1
- /**
2
- * P2 of #662 — runInBackground detection.
3
- *
4
- * When the parent dispatches an Agent/Task tool with
5
- * `input.run_in_background: true`, the resulting fleet member must be
6
- * marked with `status: 'background'` instead of `running`. Foreground
7
- * dispatches (no flag, or false) stay `running`.
8
- */
9
-
10
- import { describe, it, expect } from 'vitest'
11
- import { createProgressDriver } from '../progress-card-driver.js'
12
- import type { SessionEvent } from '../session-tail.js'
13
-
14
- function harness() {
15
- let now = 1000
16
- const timers: Array<{ fireAt: number; fn: () => void; ref: number; repeat?: number }> = []
17
- let nextRef = 0
18
- const driver = createProgressDriver({
19
- emit: () => {},
20
- minIntervalMs: 500,
21
- coalesceMs: 400,
22
- initialDelayMs: 0,
23
- promoteAfterMs: 999_999,
24
- now: () => now,
25
- setTimeout: (fn, ms) => {
26
- const ref = nextRef++
27
- timers.push({ fireAt: now + ms, fn, ref })
28
- return { ref }
29
- },
30
- clearTimeout: (h) => {
31
- const ref = (h as { ref: number }).ref
32
- const idx = timers.findIndex((t) => t.ref === ref)
33
- if (idx !== -1) timers.splice(idx, 1)
34
- },
35
- setInterval: (fn, ms) => {
36
- const ref = nextRef++
37
- timers.push({ fireAt: now + ms, fn, ref, repeat: ms })
38
- return { ref }
39
- },
40
- clearInterval: (h) => {
41
- const ref = (h as { ref: number }).ref
42
- const idx = timers.findIndex((t) => t.ref === ref)
43
- if (idx !== -1) timers.splice(idx, 1)
44
- },
45
- })
46
- return { driver, advance: (ms: number) => { now += ms } }
47
- }
48
-
49
- const enqueue = (chatId: string): SessionEvent => ({
50
- kind: 'enqueue',
51
- chatId,
52
- messageId: '1',
53
- threadId: null,
54
- rawContent: `<channel chat_id="${chatId}">go</channel>`,
55
- })
56
-
57
- describe('P2: runInBackground detection', () => {
58
- it('marks fleet member status=background when Agent dispatched with run_in_background:true', () => {
59
- const { driver } = harness()
60
- driver.ingest(enqueue('c1'), null)
61
- // Parent fires Agent tool_use with run_in_background=true.
62
- driver.ingest(
63
- {
64
- kind: 'tool_use',
65
- toolName: 'Agent',
66
- toolUseId: 'tu1',
67
- input: { prompt: 'do bg work', description: 'bg-job', run_in_background: true },
68
- },
69
- 'c1',
70
- )
71
- // sub_agent_started arrives with matching prompt.
72
- driver.ingest(
73
- { kind: 'sub_agent_started', agentId: 'sa1', firstPromptText: 'do bg work', subagentType: 'worker' },
74
- 'c1',
75
- )
76
-
77
- const m = driver.peekFleet('c1')!.get('sa1')!
78
- expect(m.status).toBe('background')
79
- })
80
-
81
- it('keeps status=running when Agent dispatched without run_in_background', () => {
82
- const { driver } = harness()
83
- driver.ingest(enqueue('c2'), null)
84
- driver.ingest(
85
- {
86
- kind: 'tool_use',
87
- toolName: 'Agent',
88
- toolUseId: 'tu2',
89
- input: { prompt: 'do fg work', description: 'fg-job' },
90
- },
91
- 'c2',
92
- )
93
- driver.ingest(
94
- { kind: 'sub_agent_started', agentId: 'sa2', firstPromptText: 'do fg work' },
95
- 'c2',
96
- )
97
- const m = driver.peekFleet('c2')!.get('sa2')!
98
- expect(m.status).toBe('running')
99
- })
100
-
101
- it('keeps status=running when run_in_background is explicitly false', () => {
102
- const { driver } = harness()
103
- driver.ingest(enqueue('c3'), null)
104
- driver.ingest(
105
- {
106
- kind: 'tool_use',
107
- toolName: 'Agent',
108
- toolUseId: 'tu3',
109
- input: { prompt: 'p', run_in_background: false },
110
- },
111
- 'c3',
112
- )
113
- driver.ingest(
114
- { kind: 'sub_agent_started', agentId: 'sa3', firstPromptText: 'p' },
115
- 'c3',
116
- )
117
- const m = driver.peekFleet('c3')!.get('sa3')!
118
- expect(m.status).toBe('running')
119
- })
120
- })
@@ -1,116 +0,0 @@
1
- /**
2
- * P2 of #662 — completion gate: a turn with both foreground (done) and
3
- * background (still running) sub-agents must NOT fire onTurnComplete
4
- * until the background member also reaches a terminal state. This is
5
- * the "✅ Done only when everything is actually done" invariant; the
6
- * v2 renderer uses the same predicate to choose between the
7
- * ⏸ Background and ✅ Done header phases.
8
- */
9
-
10
- import { describe, it, expect } from 'vitest'
11
- import { createProgressDriver } from '../progress-card-driver.js'
12
- import { hasLiveBackground } from '../fleet-state.js'
13
- import type { SessionEvent } from '../session-tail.js'
14
-
15
- function harness() {
16
- let now = 1000
17
- const timers: Array<{ fireAt: number; fn: () => void; ref: number; repeat?: number }> = []
18
- let nextRef = 0
19
- const completions: string[] = []
20
- const driver = createProgressDriver({
21
- emit: () => {},
22
- minIntervalMs: 500,
23
- coalesceMs: 400,
24
- initialDelayMs: 0,
25
- promoteAfterMs: 999_999,
26
- onTurnComplete: (s) => completions.push(s.turnKey),
27
- now: () => now,
28
- setTimeout: (fn, ms) => {
29
- const ref = nextRef++
30
- timers.push({ fireAt: now + ms, fn, ref })
31
- return { ref }
32
- },
33
- clearTimeout: (h) => {
34
- const ref = (h as { ref: number }).ref
35
- const idx = timers.findIndex((t) => t.ref === ref)
36
- if (idx !== -1) timers.splice(idx, 1)
37
- },
38
- setInterval: (fn, ms) => {
39
- const ref = nextRef++
40
- timers.push({ fireAt: now + ms, fn, ref, repeat: ms })
41
- return { ref }
42
- },
43
- clearInterval: (h) => {
44
- const ref = (h as { ref: number }).ref
45
- const idx = timers.findIndex((t) => t.ref === ref)
46
- if (idx !== -1) timers.splice(idx, 1)
47
- },
48
- })
49
- return { driver, completions, advance: (ms: number) => { now += ms } }
50
- }
51
-
52
- const enqueue = (chatId: string): SessionEvent => ({
53
- kind: 'enqueue',
54
- chatId,
55
- messageId: '1',
56
- threadId: null,
57
- rawContent: `<channel chat_id="${chatId}">go</channel>`,
58
- })
59
-
60
- describe('P2: completion gates on background fleet members', () => {
61
- it('hasLiveBackground reflects fleet status correctly', () => {
62
- // isBackgroundDispatch is the sticky flag used by hasLiveBackground —
63
- // status alone is no longer the gate (fixes #757).
64
- const fleet = new Map([
65
- ['a', { agentId: 'a', status: 'background' as const, terminalAt: null, isBackgroundDispatch: true } as never],
66
- ['b', { agentId: 'b', status: 'done' as const, terminalAt: 2000, isBackgroundDispatch: false } as never],
67
- ])
68
- expect(hasLiveBackground(fleet as never)).toBe(true)
69
- fleet.set('a', { agentId: 'a', status: 'done' as const, terminalAt: 3000, isBackgroundDispatch: true } as never)
70
- expect(hasLiveBackground(fleet as never)).toBe(false)
71
- })
72
-
73
- it('foreground sub completes + background still running → no turn completion', () => {
74
- const { driver, completions } = harness()
75
- const CHAT = 'c1'
76
- driver.ingest(enqueue(CHAT), null)
77
- // Foreground Agent dispatch.
78
- driver.ingest(
79
- {
80
- kind: 'tool_use',
81
- toolName: 'Agent',
82
- toolUseId: 'tuFg',
83
- input: { prompt: 'fg', description: 'fg-job' },
84
- },
85
- CHAT,
86
- )
87
- driver.ingest({ kind: 'sub_agent_started', agentId: 'saFG', firstPromptText: 'fg' }, CHAT)
88
- // Background Agent dispatch.
89
- driver.ingest(
90
- {
91
- kind: 'tool_use',
92
- toolName: 'Agent',
93
- toolUseId: 'tuBg',
94
- input: { prompt: 'bg', description: 'bg-job', run_in_background: true },
95
- },
96
- CHAT,
97
- )
98
- driver.ingest({ kind: 'sub_agent_started', agentId: 'saBG', firstPromptText: 'bg' }, CHAT)
99
- driver.ingest({ kind: 'tool_use', toolName: 'mcp__switchroom-telegram__reply' }, CHAT)
100
- driver.recordOutboundDelivered(CHAT)
101
- // Foreground completes.
102
- driver.ingest({ kind: 'sub_agent_turn_end', agentId: 'saFG' }, CHAT)
103
- // Parent ends.
104
- driver.ingest({ kind: 'turn_end', durationMs: 500 }, CHAT)
105
-
106
- // Background still running → no completion fired.
107
- expect(completions.length).toBe(0)
108
- const fleet = driver.peekFleet(CHAT)!
109
- expect(fleet.get('saFG')!.status).toBe('done')
110
- expect(fleet.get('saBG')!.status).toBe('background')
111
-
112
- // Background completes → completion fires.
113
- driver.ingest({ kind: 'sub_agent_turn_end', agentId: 'saBG' }, CHAT)
114
- expect(completions.length).toBe(1)
115
- })
116
- })