discoclaw 0.1.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 (393) hide show
  1. package/.context/README.md +42 -0
  2. package/.context/architecture.md +58 -0
  3. package/.context/bot-setup.md +24 -0
  4. package/.context/dev.md +230 -0
  5. package/.context/discord.md +144 -0
  6. package/.context/memory.md +257 -0
  7. package/.context/ops.md +59 -0
  8. package/.context/pa-safety.md +47 -0
  9. package/.context/pa.md +118 -0
  10. package/.context/project.md +43 -0
  11. package/.context/runtime.md +253 -0
  12. package/.context/tasks.md +71 -0
  13. package/.context/tools.md +75 -0
  14. package/.env.example +88 -0
  15. package/.env.example.full +378 -0
  16. package/LICENSE +21 -0
  17. package/README.md +220 -0
  18. package/dist/beads/auto-tag.js +2 -0
  19. package/dist/beads/auto-tag.test.js +62 -0
  20. package/dist/beads/bd-cli.js +9 -0
  21. package/dist/beads/bd-cli.test.js +495 -0
  22. package/dist/beads/bead-hooks-cli.js +149 -0
  23. package/dist/beads/bead-sync-cli.js +5 -0
  24. package/dist/beads/bead-sync-cli.test.js +72 -0
  25. package/dist/beads/bead-sync-coordinator.js +4 -0
  26. package/dist/beads/bead-sync-coordinator.test.js +239 -0
  27. package/dist/beads/bead-sync-watcher.js +2 -0
  28. package/dist/beads/bead-sync-watcher.test.js +96 -0
  29. package/dist/beads/bead-sync.js +7 -0
  30. package/dist/beads/bead-sync.test.js +876 -0
  31. package/dist/beads/bead-thread-cache.js +8 -0
  32. package/dist/beads/bead-thread-cache.test.js +91 -0
  33. package/dist/beads/discord-sync.js +18 -0
  34. package/dist/beads/discord-sync.test.js +782 -0
  35. package/dist/beads/find-bead-by-thread.test.js +36 -0
  36. package/dist/beads/forum-guard.js +2 -0
  37. package/dist/beads/forum-guard.test.js +204 -0
  38. package/dist/beads/initialize.js +3 -0
  39. package/dist/beads/initialize.test.js +304 -0
  40. package/dist/beads/types.js +10 -0
  41. package/dist/cli/daemon-installer.js +225 -0
  42. package/dist/cli/daemon-installer.test.js +289 -0
  43. package/dist/cli/index.js +42 -0
  44. package/dist/cli/init-wizard.js +374 -0
  45. package/dist/cli/init-wizard.test.js +191 -0
  46. package/dist/config.js +385 -0
  47. package/dist/config.test.js +589 -0
  48. package/dist/cron/auto-tag.js +100 -0
  49. package/dist/cron/auto-tag.test.js +91 -0
  50. package/dist/cron/cadence.js +74 -0
  51. package/dist/cron/cadence.test.js +53 -0
  52. package/dist/cron/cron-sync-coordinator.js +66 -0
  53. package/dist/cron/cron-sync-coordinator.test.js +118 -0
  54. package/dist/cron/cron-sync.js +165 -0
  55. package/dist/cron/cron-sync.test.js +228 -0
  56. package/dist/cron/cron-tag-map-watcher.js +128 -0
  57. package/dist/cron/cron-tag-map-watcher.test.js +155 -0
  58. package/dist/cron/default-timezone.js +23 -0
  59. package/dist/cron/default-timezone.test.js +30 -0
  60. package/dist/cron/discord-sync.js +205 -0
  61. package/dist/cron/discord-sync.test.js +353 -0
  62. package/dist/cron/executor.js +303 -0
  63. package/dist/cron/executor.test.js +614 -0
  64. package/dist/cron/forum-sync.js +347 -0
  65. package/dist/cron/forum-sync.test.js +539 -0
  66. package/dist/cron/job-lock.js +164 -0
  67. package/dist/cron/job-lock.test.js +178 -0
  68. package/dist/cron/parser.js +68 -0
  69. package/dist/cron/parser.test.js +115 -0
  70. package/dist/cron/run-control.js +24 -0
  71. package/dist/cron/run-control.test.js +27 -0
  72. package/dist/cron/run-stats.js +265 -0
  73. package/dist/cron/run-stats.test.js +160 -0
  74. package/dist/cron/scheduler.js +97 -0
  75. package/dist/cron/scheduler.test.js +112 -0
  76. package/dist/cron/tag-map.js +47 -0
  77. package/dist/cron/tag-map.test.js +64 -0
  78. package/dist/cron/types.js +1 -0
  79. package/dist/discoclaw-plan-format.test.js +137 -0
  80. package/dist/discoclaw-recipe-format.test.js +137 -0
  81. package/dist/discord/abort-registry.js +70 -0
  82. package/dist/discord/action-categories.js +36 -0
  83. package/dist/discord/action-types.js +1 -0
  84. package/dist/discord/action-utils.js +58 -0
  85. package/dist/discord/action-utils.test.js +58 -0
  86. package/dist/discord/actions-beads.js +1 -0
  87. package/dist/discord/actions-beads.test.js +372 -0
  88. package/dist/discord/actions-bot-profile.js +107 -0
  89. package/dist/discord/actions-bot-profile.test.js +138 -0
  90. package/dist/discord/actions-channels.js +427 -0
  91. package/dist/discord/actions-channels.test.js +697 -0
  92. package/dist/discord/actions-config.js +173 -0
  93. package/dist/discord/actions-config.test.js +322 -0
  94. package/dist/discord/actions-crons.js +586 -0
  95. package/dist/discord/actions-crons.test.js +499 -0
  96. package/dist/discord/actions-defer.js +60 -0
  97. package/dist/discord/actions-defer.test.js +134 -0
  98. package/dist/discord/actions-forge.js +134 -0
  99. package/dist/discord/actions-forge.test.js +206 -0
  100. package/dist/discord/actions-guild.js +301 -0
  101. package/dist/discord/actions-guild.test.js +386 -0
  102. package/dist/discord/actions-memory.js +106 -0
  103. package/dist/discord/actions-memory.test.js +248 -0
  104. package/dist/discord/actions-messaging.js +401 -0
  105. package/dist/discord/actions-messaging.test.js +738 -0
  106. package/dist/discord/actions-moderation.js +65 -0
  107. package/dist/discord/actions-moderation.test.js +88 -0
  108. package/dist/discord/actions-plan.js +445 -0
  109. package/dist/discord/actions-plan.test.js +610 -0
  110. package/dist/discord/actions-poll.js +38 -0
  111. package/dist/discord/actions-poll.test.js +93 -0
  112. package/dist/discord/actions-tasks.js +3 -0
  113. package/dist/discord/actions-tasks.test.js +418 -0
  114. package/dist/discord/actions.js +600 -0
  115. package/dist/discord/actions.test.js +522 -0
  116. package/dist/discord/allowed-mentions.js +3 -0
  117. package/dist/discord/allowed-mentions.test.js +17 -0
  118. package/dist/discord/allowlist.js +29 -0
  119. package/dist/discord/allowlist.test.js +24 -0
  120. package/dist/discord/audit-handler.js +191 -0
  121. package/dist/discord/audit-handler.test.js +361 -0
  122. package/dist/discord/bot.js +141 -0
  123. package/dist/discord/channel-context.js +181 -0
  124. package/dist/discord/defer-scheduler.js +45 -0
  125. package/dist/discord/destructive-confirmation.js +128 -0
  126. package/dist/discord/destructive-confirmation.test.js +49 -0
  127. package/dist/discord/discord-plan-auto-implement.test.js +18 -0
  128. package/dist/discord/durable-memory.js +145 -0
  129. package/dist/discord/durable-memory.test.js +281 -0
  130. package/dist/discord/durable-write-queue.js +4 -0
  131. package/dist/discord/file-download.js +308 -0
  132. package/dist/discord/file-download.test.js +303 -0
  133. package/dist/discord/forge-audit-verdict.js +140 -0
  134. package/dist/discord/forge-auto-implement.js +80 -0
  135. package/dist/discord/forge-auto-implement.test.js +110 -0
  136. package/dist/discord/forge-commands.js +698 -0
  137. package/dist/discord/forge-commands.test.js +1606 -0
  138. package/dist/discord/forge-plan-registry.js +68 -0
  139. package/dist/discord/forge-plan-registry.test.js +127 -0
  140. package/dist/discord/forum-count-sync.js +130 -0
  141. package/dist/discord/forum-count-sync.test.js +200 -0
  142. package/dist/discord/health-command.js +98 -0
  143. package/dist/discord/health-command.test.js +195 -0
  144. package/dist/discord/help-command.js +22 -0
  145. package/dist/discord/help-command.test.js +49 -0
  146. package/dist/discord/image-download.js +201 -0
  147. package/dist/discord/image-download.test.js +499 -0
  148. package/dist/discord/inflight-replies.js +228 -0
  149. package/dist/discord/inflight-replies.test.js +295 -0
  150. package/dist/discord/json-extract.js +110 -0
  151. package/dist/discord/keyed-queue.js +22 -0
  152. package/dist/discord/memory-commands.js +85 -0
  153. package/dist/discord/memory-commands.test.js +159 -0
  154. package/dist/discord/memory-timing.integration.test.js +159 -0
  155. package/dist/discord/message-coordinator.js +2347 -0
  156. package/dist/discord/message-coordinator.onboarding.test.js +183 -0
  157. package/dist/discord/message-coordinator.plan-run.test.js +264 -0
  158. package/dist/discord/message-history.js +53 -0
  159. package/dist/discord/message-history.test.js +95 -0
  160. package/dist/discord/models-command.js +59 -0
  161. package/dist/discord/models-command.test.js +150 -0
  162. package/dist/discord/nickname.test.js +76 -0
  163. package/dist/discord/onboarding-completion.js +55 -0
  164. package/dist/discord/onboarding-completion.test.js +176 -0
  165. package/dist/discord/output-common.js +178 -0
  166. package/dist/discord/output-common.test.js +198 -0
  167. package/dist/discord/output-utils.js +156 -0
  168. package/dist/discord/parse-identity-name.test.js +129 -0
  169. package/dist/discord/plan-commands.js +612 -0
  170. package/dist/discord/plan-commands.test.js +1622 -0
  171. package/dist/discord/plan-manager.js +1491 -0
  172. package/dist/discord/plan-manager.test.js +2380 -0
  173. package/dist/discord/plan-parser.js +110 -0
  174. package/dist/discord/plan-parser.test.js +63 -0
  175. package/dist/discord/plan-run-phase-start.js +20 -0
  176. package/dist/discord/plan-run-phase-start.test.js +29 -0
  177. package/dist/discord/platform-message.js +45 -0
  178. package/dist/discord/platform-message.test.js +110 -0
  179. package/dist/discord/prompt-common.js +240 -0
  180. package/dist/discord/prompt-common.test.js +423 -0
  181. package/dist/discord/reaction-handler.js +691 -0
  182. package/dist/discord/reaction-handler.test.js +1574 -0
  183. package/dist/discord/reaction-prompts.js +118 -0
  184. package/dist/discord/reaction-prompts.test.js +253 -0
  185. package/dist/discord/reply-reference.js +66 -0
  186. package/dist/discord/reply-reference.test.js +125 -0
  187. package/dist/discord/restart-command.js +143 -0
  188. package/dist/discord/restart-command.test.js +196 -0
  189. package/dist/discord/runtime-utils.js +43 -0
  190. package/dist/discord/runtime-utils.test.js +112 -0
  191. package/dist/discord/session-key.js +7 -0
  192. package/dist/discord/session-key.test.js +13 -0
  193. package/dist/discord/shortterm-memory.js +166 -0
  194. package/dist/discord/shortterm-memory.test.js +345 -0
  195. package/dist/discord/shutdown-context.js +122 -0
  196. package/dist/discord/shutdown-context.test.js +279 -0
  197. package/dist/discord/startup-profile.test.js +214 -0
  198. package/dist/discord/status-channel.js +190 -0
  199. package/dist/discord/status-channel.test.js +282 -0
  200. package/dist/discord/status-command.js +206 -0
  201. package/dist/discord/status-command.test.js +341 -0
  202. package/dist/discord/streaming-progress.js +107 -0
  203. package/dist/discord/streaming-progress.test.js +93 -0
  204. package/dist/discord/summarizer.js +89 -0
  205. package/dist/discord/summarizer.test.js +245 -0
  206. package/dist/discord/system-bootstrap.js +396 -0
  207. package/dist/discord/system-bootstrap.test.js +724 -0
  208. package/dist/discord/thread-context.js +169 -0
  209. package/dist/discord/thread-context.test.js +386 -0
  210. package/dist/discord/tool-aware-queue.js +116 -0
  211. package/dist/discord/tool-aware-queue.test.js +180 -0
  212. package/dist/discord/update-command.js +127 -0
  213. package/dist/discord/update-command.test.js +275 -0
  214. package/dist/discord/user-errors.js +40 -0
  215. package/dist/discord/user-errors.test.js +31 -0
  216. package/dist/discord/user-turn-to-durable.js +111 -0
  217. package/dist/discord/user-turn-to-durable.test.js +273 -0
  218. package/dist/discord-followup.test.js +677 -0
  219. package/dist/discord.channel-context.test.js +95 -0
  220. package/dist/discord.fail-closed.test.js +199 -0
  221. package/dist/discord.health-command.integration.test.js +140 -0
  222. package/dist/discord.js +190 -0
  223. package/dist/discord.prompt-context.test.js +1431 -0
  224. package/dist/discord.render.test.js +621 -0
  225. package/dist/discord.status-wiring.test.js +187 -0
  226. package/dist/engine/claudeCli.js +137 -0
  227. package/dist/engine/types.js +1 -0
  228. package/dist/group-queue.js +25 -0
  229. package/dist/health/credential-check.js +175 -0
  230. package/dist/health/credential-check.test.js +401 -0
  231. package/dist/health/startup-healing.js +139 -0
  232. package/dist/health/startup-healing.test.js +298 -0
  233. package/dist/identity.js +36 -0
  234. package/dist/index.js +1378 -0
  235. package/dist/logging/logger-like.js +1 -0
  236. package/dist/observability/memory-sampler.js +51 -0
  237. package/dist/observability/memory-sampler.test.js +93 -0
  238. package/dist/observability/metrics.js +88 -0
  239. package/dist/observability/metrics.test.js +42 -0
  240. package/dist/onboarding/onboarding-flow.js +246 -0
  241. package/dist/onboarding/onboarding-flow.test.js +238 -0
  242. package/dist/onboarding/onboarding-writer.js +102 -0
  243. package/dist/onboarding/onboarding-writer.test.js +143 -0
  244. package/dist/pidlock.js +187 -0
  245. package/dist/pidlock.test.js +128 -0
  246. package/dist/pipeline/engine.js +206 -0
  247. package/dist/pipeline/engine.test.js +771 -0
  248. package/dist/root-policy.js +21 -0
  249. package/dist/root-policy.test.js +55 -0
  250. package/dist/runtime/claude-code-cli.js +35 -0
  251. package/dist/runtime/claude-code-cli.test.js +1199 -0
  252. package/dist/runtime/cli-adapter.js +584 -0
  253. package/dist/runtime/cli-output-parsers.js +108 -0
  254. package/dist/runtime/cli-shared.js +96 -0
  255. package/dist/runtime/cli-shared.test.js +104 -0
  256. package/dist/runtime/cli-strategy.js +6 -0
  257. package/dist/runtime/codex-cli.js +16 -0
  258. package/dist/runtime/codex-cli.test.js +862 -0
  259. package/dist/runtime/concurrency-limit.js +80 -0
  260. package/dist/runtime/concurrency-limit.test.js +137 -0
  261. package/dist/runtime/gemini-cli.js +16 -0
  262. package/dist/runtime/gemini-cli.test.js +413 -0
  263. package/dist/runtime/long-running-process.js +415 -0
  264. package/dist/runtime/long-running-process.test.js +318 -0
  265. package/dist/runtime/model-smoke-helpers.js +160 -0
  266. package/dist/runtime/model-smoke.test.js +194 -0
  267. package/dist/runtime/model-tiers.js +33 -0
  268. package/dist/runtime/model-tiers.test.js +65 -0
  269. package/dist/runtime/openai-auth.js +151 -0
  270. package/dist/runtime/openai-auth.test.js +361 -0
  271. package/dist/runtime/openai-compat.js +178 -0
  272. package/dist/runtime/openai-compat.test.js +449 -0
  273. package/dist/runtime/process-pool.js +93 -0
  274. package/dist/runtime/process-pool.test.js +148 -0
  275. package/dist/runtime/registry.js +15 -0
  276. package/dist/runtime/registry.test.js +47 -0
  277. package/dist/runtime/session-scanner.js +186 -0
  278. package/dist/runtime/session-scanner.test.js +257 -0
  279. package/dist/runtime/strategies/claude-strategy.js +193 -0
  280. package/dist/runtime/strategies/codex-strategy.js +161 -0
  281. package/dist/runtime/strategies/gemini-strategy.js +64 -0
  282. package/dist/runtime/strategies/template-strategy.js +85 -0
  283. package/dist/runtime/tool-capabilities.js +27 -0
  284. package/dist/runtime/tool-capabilities.test.js +24 -0
  285. package/dist/runtime/tool-labels.js +48 -0
  286. package/dist/runtime/types.js +2 -0
  287. package/dist/sessionManager.js +47 -0
  288. package/dist/sessions.js +18 -0
  289. package/dist/tasks/architecture-contract.js +33 -0
  290. package/dist/tasks/architecture-contract.test.js +90 -0
  291. package/dist/tasks/auto-tag.js +50 -0
  292. package/dist/tasks/auto-tag.test.js +64 -0
  293. package/dist/tasks/bd-cli.js +164 -0
  294. package/dist/tasks/bd-cli.test.js +359 -0
  295. package/dist/tasks/bead-sync.js +1 -0
  296. package/dist/tasks/context-summary.js +27 -0
  297. package/dist/tasks/discord-sync.js +3 -0
  298. package/dist/tasks/discord-sync.test.js +685 -0
  299. package/dist/tasks/discord-types.js +4 -0
  300. package/dist/tasks/find-task-by-thread.test.js +36 -0
  301. package/dist/tasks/forum-guard.js +81 -0
  302. package/dist/tasks/forum-guard.test.js +192 -0
  303. package/dist/tasks/initialize.js +77 -0
  304. package/dist/tasks/initialize.test.js +263 -0
  305. package/dist/tasks/logger-types.js +1 -0
  306. package/dist/tasks/metrics-types.js +3 -0
  307. package/dist/tasks/migrate.js +33 -0
  308. package/dist/tasks/migrate.test.js +156 -0
  309. package/dist/tasks/path-defaults.js +67 -0
  310. package/dist/tasks/path-defaults.test.js +73 -0
  311. package/dist/tasks/runtime-types.js +1 -0
  312. package/dist/tasks/service.js +33 -0
  313. package/dist/tasks/service.test.js +51 -0
  314. package/dist/tasks/store.js +238 -0
  315. package/dist/tasks/store.test.js +417 -0
  316. package/dist/tasks/sync-context.js +1 -0
  317. package/dist/tasks/sync-contract.js +24 -0
  318. package/dist/tasks/sync-contract.test.js +25 -0
  319. package/dist/tasks/sync-coordinator-metrics.js +41 -0
  320. package/dist/tasks/sync-coordinator-retries.js +71 -0
  321. package/dist/tasks/sync-coordinator.js +96 -0
  322. package/dist/tasks/sync-coordinator.test.js +501 -0
  323. package/dist/tasks/sync-types.js +1 -0
  324. package/dist/tasks/sync-watcher.js +27 -0
  325. package/dist/tasks/sync-watcher.test.js +92 -0
  326. package/dist/tasks/tag-map.js +36 -0
  327. package/dist/tasks/tag-map.test.js +54 -0
  328. package/dist/tasks/task-action-contract.js +16 -0
  329. package/dist/tasks/task-action-contract.test.js +16 -0
  330. package/dist/tasks/task-action-executor.js +18 -0
  331. package/dist/tasks/task-action-executor.test.js +420 -0
  332. package/dist/tasks/task-action-mutation-helpers.js +17 -0
  333. package/dist/tasks/task-action-mutations.js +151 -0
  334. package/dist/tasks/task-action-prompt.js +62 -0
  335. package/dist/tasks/task-action-read-ops.js +73 -0
  336. package/dist/tasks/task-action-runner-types.js +1 -0
  337. package/dist/tasks/task-action-thread-sync.js +82 -0
  338. package/dist/tasks/task-actions.js +3 -0
  339. package/dist/tasks/task-cli.js +227 -0
  340. package/dist/tasks/task-context.js +1 -0
  341. package/dist/tasks/task-lifecycle.js +46 -0
  342. package/dist/tasks/task-lifecycle.test.js +35 -0
  343. package/dist/tasks/task-sync-apply-plan.js +95 -0
  344. package/dist/tasks/task-sync-apply-types.js +12 -0
  345. package/dist/tasks/task-sync-apply.js +319 -0
  346. package/dist/tasks/task-sync-cli.js +89 -0
  347. package/dist/tasks/task-sync-cli.test.js +70 -0
  348. package/dist/tasks/task-sync-engine.js +88 -0
  349. package/dist/tasks/task-sync-engine.test.js +934 -0
  350. package/dist/tasks/task-sync-phase-apply.js +171 -0
  351. package/dist/tasks/task-sync-pipeline.js +2 -0
  352. package/dist/tasks/task-sync-pipeline.test.js +265 -0
  353. package/dist/tasks/task-sync-reconcile-plan.js +182 -0
  354. package/dist/tasks/task-sync-reconcile.js +144 -0
  355. package/dist/tasks/task-sync.js +56 -0
  356. package/dist/tasks/task-sync.test.js +86 -0
  357. package/dist/tasks/thread-cache.js +42 -0
  358. package/dist/tasks/thread-cache.test.js +89 -0
  359. package/dist/tasks/thread-contracts.test.js +711 -0
  360. package/dist/tasks/thread-forum-ops.js +68 -0
  361. package/dist/tasks/thread-helpers.js +86 -0
  362. package/dist/tasks/thread-helpers.test.js +33 -0
  363. package/dist/tasks/thread-lifecycle-ops.js +144 -0
  364. package/dist/tasks/thread-ops-shared.js +21 -0
  365. package/dist/tasks/thread-ops.js +2 -0
  366. package/dist/tasks/types.js +20 -0
  367. package/dist/tasks/types.test.js +60 -0
  368. package/dist/test-setup.js +11 -0
  369. package/dist/test-setup.test.js +42 -0
  370. package/dist/transport/types.js +1 -0
  371. package/dist/validate.js +41 -0
  372. package/dist/validate.test.js +94 -0
  373. package/dist/version.js +15 -0
  374. package/dist/version.test.js +31 -0
  375. package/dist/webhook/server.js +199 -0
  376. package/dist/webhook/server.test.js +460 -0
  377. package/dist/workspace-bootstrap.js +135 -0
  378. package/dist/workspace-bootstrap.test.js +514 -0
  379. package/dist/workspace-permissions.js +134 -0
  380. package/dist/workspace-permissions.test.js +181 -0
  381. package/package.json +74 -0
  382. package/scripts/cron/cron-tag-map.json +9 -0
  383. package/scripts/tasks/tag-map.json +10 -0
  384. package/systemd/discoclaw.service +19 -0
  385. package/templates/recipes/integration.discoclaw-recipe.md +171 -0
  386. package/templates/workspace/AGENTS.md +217 -0
  387. package/templates/workspace/BOOTSTRAP.md +1 -0
  388. package/templates/workspace/HEARTBEAT.md +10 -0
  389. package/templates/workspace/IDENTITY.md +16 -0
  390. package/templates/workspace/MEMORY.md +24 -0
  391. package/templates/workspace/SOUL.md +52 -0
  392. package/templates/workspace/TOOLS.md +304 -0
  393. package/templates/workspace/USER.md +37 -0
@@ -0,0 +1,539 @@
1
+ import { describe, expect, it, vi, beforeEach } from 'vitest';
2
+ import { ChannelType } from 'discord.js';
3
+ vi.mock('./parser.js', () => {
4
+ return { parseCronDefinition: vi.fn() };
5
+ });
6
+ // Mock ensureStatusMessage and detectCadence to avoid side effects.
7
+ vi.mock('./discord-sync.js', async (importOriginal) => {
8
+ const actual = await importOriginal();
9
+ return {
10
+ ...actual,
11
+ ensureStatusMessage: vi.fn(async () => 'status-msg-1'),
12
+ };
13
+ });
14
+ vi.mock('./cadence.js', () => ({
15
+ detectCadence: vi.fn(() => 'daily'),
16
+ }));
17
+ function makeClient(forum, botUserId = 'bot-user-1') {
18
+ const listeners = {};
19
+ return {
20
+ channels: { cache: { get: vi.fn().mockReturnValue(forum) } },
21
+ on: vi.fn((event, cb) => {
22
+ (listeners[event] ??= []).push(cb);
23
+ }),
24
+ user: { id: botUserId },
25
+ _listeners: listeners,
26
+ };
27
+ }
28
+ function makeThread(overrides) {
29
+ return {
30
+ id: 'thread-1',
31
+ name: 'Job 1',
32
+ archived: false,
33
+ parentId: 'forum-1',
34
+ ownerId: 'bot-user-1',
35
+ fetchStarterMessage: vi.fn(),
36
+ send: vi.fn().mockResolvedValue(undefined),
37
+ setArchived: vi.fn().mockResolvedValue(undefined),
38
+ messages: { fetch: vi.fn().mockResolvedValue(new Map()) },
39
+ client: { user: { id: 'bot-user-1' } },
40
+ ...overrides,
41
+ };
42
+ }
43
+ function makeForum(threads) {
44
+ const active = new Map(threads.map((t) => [t.id, t]));
45
+ return {
46
+ id: 'forum-1',
47
+ type: ChannelType.GuildForum,
48
+ name: 'cron-forum',
49
+ guildId: 'guild-1',
50
+ threads: {
51
+ fetchActive: vi.fn().mockResolvedValue({ threads: active }),
52
+ },
53
+ };
54
+ }
55
+ function makeScheduler() {
56
+ return {
57
+ register: vi.fn(),
58
+ disable: vi.fn(),
59
+ unregister: vi.fn(),
60
+ getJob: vi.fn(),
61
+ };
62
+ }
63
+ describe('initCronForum', () => {
64
+ let initCronForum;
65
+ let parseCronDefinition;
66
+ beforeEach(async () => {
67
+ // Dynamic import after mocks are registered.
68
+ ({ initCronForum } = await import('./forum-sync.js'));
69
+ ({ parseCronDefinition } = await import('./parser.js'));
70
+ vi.mocked(parseCronDefinition).mockReset();
71
+ });
72
+ it('does not register when starter author is not allowlisted', async () => {
73
+ const thread = makeThread();
74
+ thread.fetchStarterMessage.mockResolvedValue({
75
+ id: 'm1',
76
+ content: 'every day at 7am post to #general say hello',
77
+ author: { id: 'u-not-allowed' },
78
+ react: vi.fn().mockResolvedValue(undefined),
79
+ });
80
+ const forum = makeForum([thread]);
81
+ const client = makeClient(forum);
82
+ const scheduler = makeScheduler();
83
+ vi.mocked(parseCronDefinition).mockResolvedValue({
84
+ triggerType: 'schedule',
85
+ schedule: '0 7 * * *',
86
+ timezone: 'UTC',
87
+ channel: 'general',
88
+ prompt: 'Say hello.',
89
+ });
90
+ await initCronForum({
91
+ client: client,
92
+ forumChannelNameOrId: 'forum-1',
93
+ allowUserIds: new Set(['u-allowed']),
94
+ scheduler: scheduler,
95
+ runtime: {},
96
+ cronModel: 'haiku',
97
+ cwd: '/tmp',
98
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
99
+ });
100
+ expect(scheduler.register).not.toHaveBeenCalled();
101
+ expect(scheduler.disable).toHaveBeenCalledOnce();
102
+ expect(thread.send).toHaveBeenCalledOnce();
103
+ });
104
+ it('registers when starter author is the bot itself (cronCreate flow)', async () => {
105
+ const thread = makeThread();
106
+ thread.fetchStarterMessage.mockResolvedValue({
107
+ id: 'm1',
108
+ content: '**Schedule:** `0 7 * * *` (UTC)\n**Channel:** #general\n\nSay hello.',
109
+ author: { id: 'bot-user-1' },
110
+ react: vi.fn().mockResolvedValue(undefined),
111
+ });
112
+ const forum = makeForum([thread]);
113
+ const client = makeClient(forum, 'bot-user-1');
114
+ const scheduler = makeScheduler();
115
+ vi.mocked(parseCronDefinition).mockResolvedValue({
116
+ triggerType: 'schedule',
117
+ schedule: '0 7 * * *',
118
+ timezone: 'UTC',
119
+ channel: 'general',
120
+ prompt: 'Say hello.',
121
+ });
122
+ scheduler.register.mockReturnValue({ cron: { nextRun: () => new Date() } });
123
+ await initCronForum({
124
+ client: client,
125
+ forumChannelNameOrId: 'forum-1',
126
+ allowUserIds: new Set(['u-allowed']),
127
+ scheduler: scheduler,
128
+ runtime: {},
129
+ cronModel: 'haiku',
130
+ cwd: '/tmp',
131
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
132
+ });
133
+ expect(scheduler.register).toHaveBeenCalledOnce();
134
+ expect(scheduler.disable).not.toHaveBeenCalled();
135
+ });
136
+ it('disables and reports when parsing fails', async () => {
137
+ const thread = makeThread();
138
+ thread.fetchStarterMessage.mockResolvedValue({
139
+ id: 'm1',
140
+ content: 'nonsense',
141
+ author: { id: 'u-allowed' },
142
+ react: vi.fn().mockResolvedValue(undefined),
143
+ });
144
+ const forum = makeForum([thread]);
145
+ const client = makeClient(forum);
146
+ const scheduler = makeScheduler();
147
+ vi.mocked(parseCronDefinition).mockResolvedValue(null);
148
+ await initCronForum({
149
+ client: client,
150
+ forumChannelNameOrId: 'forum-1',
151
+ allowUserIds: new Set(['u-allowed']),
152
+ scheduler: scheduler,
153
+ runtime: {},
154
+ cronModel: 'haiku',
155
+ cwd: '/tmp',
156
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
157
+ });
158
+ expect(scheduler.register).not.toHaveBeenCalled();
159
+ expect(scheduler.disable).toHaveBeenCalledOnce();
160
+ expect(thread.send).toHaveBeenCalledOnce();
161
+ });
162
+ it('registers when parsing succeeds and author is allowlisted', async () => {
163
+ const thread = makeThread();
164
+ thread.fetchStarterMessage.mockResolvedValue({
165
+ id: 'm1',
166
+ content: 'every day at 7am post to #general say hello',
167
+ author: { id: 'u-allowed' },
168
+ react: vi.fn().mockResolvedValue(undefined),
169
+ });
170
+ const forum = makeForum([thread]);
171
+ const client = makeClient(forum);
172
+ const scheduler = makeScheduler();
173
+ vi.mocked(parseCronDefinition).mockResolvedValue({
174
+ triggerType: 'schedule',
175
+ schedule: '0 7 * * *',
176
+ timezone: 'UTC',
177
+ channel: 'general',
178
+ prompt: 'Say hello.',
179
+ });
180
+ scheduler.register.mockReturnValue({ cron: { nextRun: () => new Date() } });
181
+ await initCronForum({
182
+ client: client,
183
+ forumChannelNameOrId: 'forum-1',
184
+ allowUserIds: new Set(['u-allowed']),
185
+ scheduler: scheduler,
186
+ runtime: {},
187
+ cronModel: 'haiku',
188
+ cwd: '/tmp',
189
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
190
+ });
191
+ expect(scheduler.register).toHaveBeenCalledOnce();
192
+ expect(scheduler.disable).not.toHaveBeenCalled();
193
+ });
194
+ it('disables and reports when schedule is invalid', async () => {
195
+ const thread = makeThread();
196
+ thread.fetchStarterMessage.mockResolvedValue({
197
+ id: 'm1',
198
+ content: 'bad schedule',
199
+ author: { id: 'u-allowed' },
200
+ react: vi.fn().mockResolvedValue(undefined),
201
+ });
202
+ const forum = makeForum([thread]);
203
+ const client = makeClient(forum);
204
+ const scheduler = makeScheduler();
205
+ vi.mocked(parseCronDefinition).mockResolvedValue({
206
+ triggerType: 'schedule',
207
+ schedule: 'not a cron',
208
+ timezone: 'UTC',
209
+ channel: 'general',
210
+ prompt: 'Say hello.',
211
+ });
212
+ scheduler.register.mockImplementation(() => {
213
+ throw new Error('invalid schedule');
214
+ });
215
+ await initCronForum({
216
+ client: client,
217
+ forumChannelNameOrId: 'forum-1',
218
+ allowUserIds: new Set(['u-allowed']),
219
+ scheduler: scheduler,
220
+ runtime: {},
221
+ cronModel: 'haiku',
222
+ cwd: '/tmp',
223
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
224
+ });
225
+ expect(scheduler.register).toHaveBeenCalledOnce();
226
+ expect(scheduler.disable).toHaveBeenCalledOnce();
227
+ expect(thread.send).toHaveBeenCalledOnce();
228
+ });
229
+ it('passes cronId to scheduler.register when statsStore has record', async () => {
230
+ const thread = makeThread();
231
+ thread.fetchStarterMessage.mockResolvedValue({
232
+ id: 'm1',
233
+ content: 'every day at 7am post to #general say hello',
234
+ author: { id: 'u-allowed' },
235
+ react: vi.fn().mockResolvedValue(undefined),
236
+ });
237
+ // Ensure messages.fetch exists for cronId recovery scan.
238
+ thread.messages.fetch = vi.fn().mockResolvedValue(new Map());
239
+ const forum = makeForum([thread]);
240
+ const client = makeClient(forum);
241
+ const scheduler = makeScheduler();
242
+ vi.mocked(parseCronDefinition).mockResolvedValue({
243
+ triggerType: 'schedule',
244
+ schedule: '0 7 * * *',
245
+ timezone: 'UTC',
246
+ channel: 'general',
247
+ prompt: 'Say hello.',
248
+ });
249
+ scheduler.register.mockReturnValue({ cron: { nextRun: () => new Date() } });
250
+ const statsStore = {
251
+ getRecordByThreadId: vi.fn().mockReturnValue({ cronId: 'cron-recovered' }),
252
+ getRecord: vi.fn().mockReturnValue({ cronId: 'cron-recovered', threadId: 'thread-1', disabled: false }),
253
+ upsertRecord: vi.fn(async () => ({})),
254
+ };
255
+ await initCronForum({
256
+ client: client,
257
+ forumChannelNameOrId: 'forum-1',
258
+ allowUserIds: new Set(['u-allowed']),
259
+ scheduler: scheduler,
260
+ runtime: {},
261
+ cronModel: 'haiku',
262
+ cwd: '/tmp',
263
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
264
+ statsStore: statsStore,
265
+ });
266
+ // Should pass the recovered cronId to register.
267
+ expect(scheduler.register).toHaveBeenCalledWith('thread-1', 'thread-1', 'guild-1', 'Job 1', expect.objectContaining({ schedule: '0 7 * * *' }), 'cron-recovered');
268
+ });
269
+ it('recovers cronId from statusMessageId metadata before content token parsing', async () => {
270
+ const thread = makeThread();
271
+ thread.fetchStarterMessage.mockResolvedValue({
272
+ id: 'm1',
273
+ content: 'every day at 7am post to #general say hello',
274
+ author: { id: 'u-allowed' },
275
+ react: vi.fn().mockResolvedValue(undefined),
276
+ });
277
+ const statusMsg = { id: 'status-msg-123', author: { bot: true }, content: '📊 **Cron Status** no token' };
278
+ thread.messages.fetch = vi.fn().mockResolvedValue(new Map([[statusMsg.id, statusMsg]]));
279
+ const forum = makeForum([thread]);
280
+ const client = makeClient(forum);
281
+ const scheduler = makeScheduler();
282
+ vi.mocked(parseCronDefinition).mockResolvedValue({
283
+ triggerType: 'schedule',
284
+ schedule: '0 7 * * *',
285
+ timezone: 'UTC',
286
+ channel: 'general',
287
+ prompt: 'Say hello.',
288
+ });
289
+ scheduler.register.mockReturnValue({ cron: { nextRun: () => new Date() } });
290
+ const statsStore = {
291
+ getRecordByThreadId: vi.fn().mockReturnValue(undefined),
292
+ getRecordByStatusMessageId: vi.fn().mockImplementation((id) => {
293
+ if (id === 'status-msg-123')
294
+ return { cronId: 'cron-from-status-id', threadId: 'thread-1', disabled: false };
295
+ return undefined;
296
+ }),
297
+ getRecord: vi.fn().mockReturnValue({ cronId: 'cron-from-status-id', threadId: 'thread-1', disabled: false }),
298
+ upsertRecord: vi.fn(async () => ({})),
299
+ };
300
+ await initCronForum({
301
+ client: client,
302
+ forumChannelNameOrId: 'forum-1',
303
+ allowUserIds: new Set(['u-allowed']),
304
+ scheduler: scheduler,
305
+ runtime: {},
306
+ cronModel: 'haiku',
307
+ cwd: '/tmp',
308
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
309
+ statsStore: statsStore,
310
+ });
311
+ expect(scheduler.register).toHaveBeenCalledWith('thread-1', 'thread-1', 'guild-1', 'Job 1', expect.objectContaining({ schedule: '0 7 * * *' }), 'cron-from-status-id');
312
+ });
313
+ it('restores disabled state from stats store', async () => {
314
+ const thread = makeThread();
315
+ thread.fetchStarterMessage.mockResolvedValue({
316
+ id: 'm1',
317
+ content: 'every day at 7am post to #general say hello',
318
+ author: { id: 'u-allowed' },
319
+ react: vi.fn().mockResolvedValue(undefined),
320
+ });
321
+ thread.messages.fetch = vi.fn().mockResolvedValue(new Map());
322
+ const forum = makeForum([thread]);
323
+ const client = makeClient(forum);
324
+ const scheduler = makeScheduler();
325
+ vi.mocked(parseCronDefinition).mockResolvedValue({
326
+ triggerType: 'schedule',
327
+ schedule: '0 7 * * *',
328
+ timezone: 'UTC',
329
+ channel: 'general',
330
+ prompt: 'Say hello.',
331
+ });
332
+ scheduler.register.mockReturnValue({ cron: { nextRun: () => new Date() } });
333
+ const statsStore = {
334
+ getRecordByThreadId: vi.fn().mockReturnValue({ cronId: 'cron-disabled', disabled: true }),
335
+ getRecord: vi.fn().mockReturnValue({ cronId: 'cron-disabled', threadId: 'thread-1', disabled: true }),
336
+ upsertRecord: vi.fn(async () => ({})),
337
+ };
338
+ await initCronForum({
339
+ client: client,
340
+ forumChannelNameOrId: 'forum-1',
341
+ allowUserIds: new Set(['u-allowed']),
342
+ scheduler: scheduler,
343
+ runtime: {},
344
+ cronModel: 'haiku',
345
+ cwd: '/tmp',
346
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
347
+ statsStore: statsStore,
348
+ });
349
+ // Should disable the job because stats record says disabled: true.
350
+ expect(scheduler.disable).toHaveBeenCalledWith('thread-1');
351
+ });
352
+ });
353
+ describe('threadCreate listener', () => {
354
+ let initCronForum;
355
+ let parseCronDefinition;
356
+ beforeEach(async () => {
357
+ ({ initCronForum } = await import('./forum-sync.js'));
358
+ ({ parseCronDefinition } = await import('./parser.js'));
359
+ vi.mocked(parseCronDefinition).mockReset();
360
+ });
361
+ async function setupAndGetListener(opts = {}) {
362
+ const forum = makeForum([]);
363
+ const client = makeClient(forum);
364
+ const scheduler = opts.scheduler ?? makeScheduler();
365
+ await initCronForum({
366
+ client: client,
367
+ forumChannelNameOrId: 'forum-1',
368
+ allowUserIds: new Set(['u-allowed']),
369
+ scheduler: scheduler,
370
+ runtime: {},
371
+ cronModel: 'haiku',
372
+ cwd: '/tmp',
373
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
374
+ pendingThreadIds: opts.pendingThreadIds,
375
+ });
376
+ const threadCreateCallbacks = client._listeners['threadCreate'] ?? [];
377
+ expect(threadCreateCallbacks.length).toBeGreaterThan(0);
378
+ return { listener: threadCreateCallbacks[0], scheduler, client };
379
+ }
380
+ it('skips threads already registered in scheduler', async () => {
381
+ const scheduler = makeScheduler();
382
+ scheduler.getJob.mockReturnValue({ id: 'thread-new' });
383
+ const { listener } = await setupAndGetListener({ scheduler });
384
+ const thread = makeThread({ id: 'thread-new', parentId: 'forum-1' });
385
+ await listener(thread);
386
+ // Should not call loadThreadAsCron (no fetchStarterMessage call).
387
+ expect(thread.fetchStarterMessage).not.toHaveBeenCalled();
388
+ });
389
+ it('skips threads in pendingThreadIds set', async () => {
390
+ const pendingThreadIds = new Set(['thread-pending']);
391
+ const scheduler = makeScheduler();
392
+ scheduler.getJob.mockReturnValue(undefined);
393
+ const { listener } = await setupAndGetListener({ scheduler, pendingThreadIds });
394
+ const thread = makeThread({ id: 'thread-pending', parentId: 'forum-1' });
395
+ await listener(thread);
396
+ expect(thread.fetchStarterMessage).not.toHaveBeenCalled();
397
+ });
398
+ it('skips threads from other forums', async () => {
399
+ const scheduler = makeScheduler();
400
+ const { listener } = await setupAndGetListener({ scheduler });
401
+ const thread = makeThread({ id: 'thread-other', parentId: 'other-forum' });
402
+ await listener(thread);
403
+ expect(thread.fetchStarterMessage).not.toHaveBeenCalled();
404
+ });
405
+ it('processes new threads not in scheduler or pending set', async () => {
406
+ const scheduler = makeScheduler();
407
+ scheduler.getJob.mockReturnValue(undefined);
408
+ scheduler.register.mockReturnValue({ cron: { nextRun: () => new Date() } });
409
+ const { listener } = await setupAndGetListener({ scheduler });
410
+ vi.mocked(parseCronDefinition).mockResolvedValue({
411
+ triggerType: 'schedule',
412
+ schedule: '0 7 * * *',
413
+ timezone: 'UTC',
414
+ channel: 'general',
415
+ prompt: 'Say hello.',
416
+ });
417
+ const thread = makeThread({ id: 'thread-brand-new', parentId: 'forum-1' });
418
+ thread.fetchStarterMessage.mockResolvedValue({
419
+ id: 'm1',
420
+ content: 'every day at 7am say hello',
421
+ author: { id: 'u-allowed' },
422
+ react: vi.fn().mockResolvedValue(undefined),
423
+ });
424
+ await listener(thread);
425
+ expect(scheduler.register).toHaveBeenCalled();
426
+ });
427
+ it('rejects manually-created threads with guidance message and archives', async () => {
428
+ const scheduler = makeScheduler();
429
+ scheduler.getJob.mockReturnValue(undefined);
430
+ const { listener } = await setupAndGetListener({ scheduler });
431
+ const thread = makeThread({ id: 'thread-manual', parentId: 'forum-1', ownerId: 'some-user' });
432
+ await listener(thread);
433
+ expect(thread.send).toHaveBeenCalledWith(expect.stringContaining('cronCreate'));
434
+ expect(thread.setArchived).toHaveBeenCalledWith(true);
435
+ expect(thread.fetchStarterMessage).not.toHaveBeenCalled();
436
+ });
437
+ it('allows bot-created threads through to loadThreadAsCron', async () => {
438
+ const scheduler = makeScheduler();
439
+ scheduler.getJob.mockReturnValue(undefined);
440
+ scheduler.register.mockReturnValue({ cron: { nextRun: () => new Date() } });
441
+ const { listener } = await setupAndGetListener({ scheduler });
442
+ vi.mocked(parseCronDefinition).mockResolvedValue({
443
+ triggerType: 'schedule',
444
+ schedule: '0 7 * * *',
445
+ timezone: 'UTC',
446
+ channel: 'general',
447
+ prompt: 'Say hello.',
448
+ });
449
+ const thread = makeThread({ id: 'thread-bot', parentId: 'forum-1', ownerId: 'bot-user-1' });
450
+ thread.fetchStarterMessage.mockResolvedValue({
451
+ id: 'm1',
452
+ content: 'every day at 7am say hello',
453
+ author: { id: 'bot-user-1' },
454
+ react: vi.fn().mockResolvedValue(undefined),
455
+ });
456
+ await listener(thread);
457
+ expect(thread.fetchStarterMessage).toHaveBeenCalled();
458
+ });
459
+ it('handles send failure gracefully during rejection', async () => {
460
+ const scheduler = makeScheduler();
461
+ scheduler.getJob.mockReturnValue(undefined);
462
+ const { listener } = await setupAndGetListener({ scheduler });
463
+ const thread = makeThread({ id: 'thread-manual-2', parentId: 'forum-1', ownerId: 'some-user' });
464
+ thread.send.mockRejectedValue(new Error('Missing Access'));
465
+ await listener(thread);
466
+ expect(thread.setArchived).toHaveBeenCalledWith(true);
467
+ });
468
+ });
469
+ describe('threadUpdate listener', () => {
470
+ let initCronForum;
471
+ let parseCronDefinition;
472
+ beforeEach(async () => {
473
+ ({ initCronForum } = await import('./forum-sync.js'));
474
+ ({ parseCronDefinition } = await import('./parser.js'));
475
+ vi.mocked(parseCronDefinition).mockReset();
476
+ });
477
+ async function setupAndGetListener(opts = {}) {
478
+ const forum = makeForum([]);
479
+ const client = makeClient(forum);
480
+ const scheduler = opts.scheduler ?? makeScheduler();
481
+ await initCronForum({
482
+ client: client,
483
+ forumChannelNameOrId: 'forum-1',
484
+ allowUserIds: new Set(['u-allowed']),
485
+ scheduler: scheduler,
486
+ runtime: {},
487
+ cronModel: 'haiku',
488
+ cwd: '/tmp',
489
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
490
+ });
491
+ const threadUpdateCallbacks = client._listeners['threadUpdate'] ?? [];
492
+ expect(threadUpdateCallbacks.length).toBeGreaterThan(0);
493
+ return { listener: threadUpdateCallbacks[0], scheduler, client };
494
+ }
495
+ it('rejects unarchived manual thread not in scheduler', async () => {
496
+ const scheduler = makeScheduler();
497
+ scheduler.getJob.mockReturnValue(undefined);
498
+ const { listener } = await setupAndGetListener({ scheduler });
499
+ const oldThread = makeThread({ id: 'thread-manual', parentId: 'forum-1', ownerId: 'some-user', archived: true });
500
+ const newThread = makeThread({ id: 'thread-manual', parentId: 'forum-1', ownerId: 'some-user', archived: false });
501
+ await listener(oldThread, newThread);
502
+ expect(newThread.send).toHaveBeenCalledWith(expect.stringContaining('cronCreate'));
503
+ expect(newThread.setArchived).toHaveBeenCalledWith(true);
504
+ });
505
+ it('allows unarchived grandfathered thread through', async () => {
506
+ const scheduler = makeScheduler();
507
+ scheduler.getJob.mockReturnValue({ id: 'thread-grandfathered' });
508
+ const { listener } = await setupAndGetListener({ scheduler });
509
+ vi.mocked(parseCronDefinition).mockResolvedValue({
510
+ triggerType: 'schedule',
511
+ schedule: '0 7 * * *',
512
+ timezone: 'UTC',
513
+ channel: 'general',
514
+ prompt: 'Say hello.',
515
+ });
516
+ const oldThread = makeThread({ id: 'thread-grandfathered', parentId: 'forum-1', ownerId: 'some-user', archived: true });
517
+ const newThread = makeThread({ id: 'thread-grandfathered', parentId: 'forum-1', ownerId: 'some-user', archived: false });
518
+ newThread.fetchStarterMessage.mockResolvedValue({
519
+ id: 'm1',
520
+ content: 'every day at 7am say hello',
521
+ author: { id: 'u-allowed' },
522
+ react: vi.fn().mockResolvedValue(undefined),
523
+ });
524
+ scheduler.register.mockReturnValue({ cron: { nextRun: () => new Date() } });
525
+ await listener(oldThread, newThread);
526
+ // Should NOT be rejected — should proceed to loadThreadAsCron.
527
+ expect(newThread.setArchived).not.toHaveBeenCalledWith(true);
528
+ });
529
+ it('rejects manual thread on name change when not in scheduler', async () => {
530
+ const scheduler = makeScheduler();
531
+ scheduler.getJob.mockReturnValue(undefined);
532
+ const { listener } = await setupAndGetListener({ scheduler });
533
+ const oldThread = makeThread({ id: 'thread-manual', parentId: 'forum-1', ownerId: 'some-user', name: 'Old Name' });
534
+ const newThread = makeThread({ id: 'thread-manual', parentId: 'forum-1', ownerId: 'some-user', name: 'New Name' });
535
+ await listener(oldThread, newThread);
536
+ expect(newThread.send).toHaveBeenCalledWith(expect.stringContaining('cronCreate'));
537
+ expect(newThread.setArchived).toHaveBeenCalledWith(true);
538
+ });
539
+ });