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,183 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { createMessageCreateHandler } from './message-coordinator.js';
6
+ import { completeOnboarding } from './onboarding-completion.js';
7
+ import { getDefaultTimezone } from '../cron/default-timezone.js';
8
+ vi.mock('../workspace-bootstrap.js', () => ({
9
+ isOnboardingComplete: vi.fn(async () => false),
10
+ }));
11
+ vi.mock('./onboarding-completion.js', () => ({
12
+ completeOnboarding: vi.fn(async () => ({
13
+ writeResult: { written: ['IDENTITY.md', 'USER.md'], errors: [], warnings: [] },
14
+ })),
15
+ }));
16
+ vi.mock('../cron/default-timezone.js', () => ({
17
+ getDefaultTimezone: vi.fn(() => 'Etc/Test'),
18
+ }));
19
+ function makeParams(workspaceCwd) {
20
+ const metrics = { increment: vi.fn() };
21
+ const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
22
+ return {
23
+ allowUserIds: new Set(['user-1']),
24
+ dataDir: workspaceCwd,
25
+ botDisplayName: 'Discoclaw',
26
+ requireChannelContext: false,
27
+ autoIndexChannelContext: false,
28
+ autoJoinThreads: false,
29
+ useRuntimeSessions: false,
30
+ runtime: { id: 'claude', capabilities: {} },
31
+ sessionManager: {},
32
+ workspaceCwd,
33
+ projectCwd: workspaceCwd,
34
+ groupsDir: workspaceCwd,
35
+ useGroupDirCwd: false,
36
+ runtimeModel: 'capable',
37
+ runtimeTools: [],
38
+ runtimeTimeoutMs: 30_000,
39
+ discordActionsEnabled: false,
40
+ discordActionsChannels: false,
41
+ discordActionsMessaging: false,
42
+ discordActionsGuild: false,
43
+ discordActionsModeration: false,
44
+ discordActionsPolls: false,
45
+ messageHistoryBudget: 0,
46
+ summaryEnabled: false,
47
+ summaryModel: 'fast',
48
+ summaryMaxChars: 2000,
49
+ summaryEveryNTurns: 6,
50
+ summaryDataDir: workspaceCwd,
51
+ durableMemoryEnabled: false,
52
+ durableDataDir: workspaceCwd,
53
+ durableInjectMaxChars: 2000,
54
+ durableMaxItems: 100,
55
+ memoryCommandsEnabled: false,
56
+ planCommandsEnabled: false,
57
+ forgeCommandsEnabled: false,
58
+ summaryToDurableEnabled: false,
59
+ shortTermMemoryEnabled: false,
60
+ shortTermDataDir: workspaceCwd,
61
+ shortTermMaxEntries: 0,
62
+ shortTermMaxAgeMs: 0,
63
+ shortTermInjectMaxChars: 0,
64
+ streamStallWarningMs: 10_000,
65
+ actionFollowupDepth: 1,
66
+ reactionHandlerEnabled: false,
67
+ reactionRemoveHandlerEnabled: false,
68
+ reactionMaxAgeMs: 0,
69
+ healthCommandsEnabled: false,
70
+ metrics,
71
+ log,
72
+ };
73
+ }
74
+ function makeMessage(overrides = {}) {
75
+ const author = {
76
+ id: 'user-1',
77
+ bot: false,
78
+ displayName: 'Alice',
79
+ username: 'alice',
80
+ send: vi.fn(async () => ({})),
81
+ };
82
+ const channel = {
83
+ id: 'dm-1',
84
+ name: 'dm',
85
+ send: vi.fn(async () => ({})),
86
+ isThread: () => false,
87
+ };
88
+ const msg = {
89
+ id: 'm1',
90
+ type: 0,
91
+ content: 'hello',
92
+ author,
93
+ guildId: null,
94
+ guild: null,
95
+ channelId: 'dm-1',
96
+ channel,
97
+ client: { channels: { cache: new Map() }, user: { id: 'bot-1' } },
98
+ attachments: new Map(),
99
+ stickers: new Map(),
100
+ embeds: [],
101
+ mentions: { has: () => false },
102
+ reply: vi.fn(async () => ({})),
103
+ };
104
+ return Object.assign(msg, overrides);
105
+ }
106
+ describe('message coordinator onboarding', () => {
107
+ beforeEach(() => {
108
+ vi.useFakeTimers();
109
+ vi.setSystemTime(new Date('2026-02-21T08:00:00.000Z'));
110
+ vi.clearAllMocks();
111
+ });
112
+ afterEach(() => {
113
+ vi.useRealTimers();
114
+ });
115
+ it('completes onboarding with defaults when an active session is timed out', async () => {
116
+ const workspaceCwd = await fs.mkdtemp(path.join(os.tmpdir(), 'discoclaw-onboard-timeout-'));
117
+ await fs.writeFile(path.join(workspaceCwd, 'IDENTITY.md'), 'template marker', 'utf8');
118
+ const params = makeParams(workspaceCwd);
119
+ const queue = { run: vi.fn(async () => undefined) };
120
+ const handler = createMessageCreateHandler(params, queue);
121
+ const startMsg = makeMessage();
122
+ await handler(startMsg);
123
+ vi.setSystemTime(new Date('2026-02-22T08:00:01.000Z'));
124
+ const timedOutMsg = makeMessage({
125
+ id: 'm2',
126
+ author: startMsg.author,
127
+ channel: startMsg.channel,
128
+ content: 'still here',
129
+ });
130
+ await handler(timedOutMsg);
131
+ expect(queue.run).not.toHaveBeenCalled();
132
+ expect(vi.mocked(getDefaultTimezone)).toHaveBeenCalled();
133
+ expect(vi.mocked(completeOnboarding)).toHaveBeenCalledTimes(1);
134
+ expect(vi.mocked(completeOnboarding)).toHaveBeenCalledWith({ userName: 'Alice', timezone: 'Etc/Test', morningCheckin: false }, workspaceCwd, startMsg.author, undefined);
135
+ });
136
+ it('sends redirect once for wrong-channel onboarding messages and then passes through', async () => {
137
+ const workspaceCwd = await fs.mkdtemp(path.join(os.tmpdir(), 'discoclaw-onboard-redirect-'));
138
+ await fs.writeFile(path.join(workspaceCwd, 'IDENTITY.md'), 'template marker', 'utf8');
139
+ const params = makeParams(workspaceCwd);
140
+ const queue = { run: vi.fn(async () => undefined) };
141
+ const handler = createMessageCreateHandler(params, queue);
142
+ const startMsg = makeMessage();
143
+ await handler(startMsg);
144
+ const wrongChannelMsg1 = makeMessage({
145
+ id: 'm2',
146
+ author: startMsg.author,
147
+ content: 'hello from guild',
148
+ guildId: 'guild-1',
149
+ guild: { id: 'guild-1' },
150
+ channelId: 'guild-channel-1',
151
+ channel: {
152
+ id: 'guild-channel-1',
153
+ name: 'general',
154
+ send: vi.fn(async () => ({})),
155
+ isThread: () => false,
156
+ },
157
+ });
158
+ await handler(wrongChannelMsg1);
159
+ expect(wrongChannelMsg1.reply).toHaveBeenCalledTimes(1);
160
+ expect(wrongChannelMsg1.reply).toHaveBeenCalledWith(expect.objectContaining({
161
+ content: expect.stringContaining('setting things up with you in DMs'),
162
+ }));
163
+ expect(queue.run).toHaveBeenCalledTimes(1);
164
+ const wrongChannelMsg2 = makeMessage({
165
+ id: 'm3',
166
+ author: startMsg.author,
167
+ content: 'another guild message',
168
+ guildId: 'guild-1',
169
+ guild: { id: 'guild-1' },
170
+ channelId: 'guild-channel-2',
171
+ channel: {
172
+ id: 'guild-channel-2',
173
+ name: 'general-2',
174
+ send: vi.fn(async () => ({})),
175
+ isThread: () => false,
176
+ },
177
+ });
178
+ await handler(wrongChannelMsg2);
179
+ expect(wrongChannelMsg2.reply).not.toHaveBeenCalled();
180
+ expect(queue.run).toHaveBeenCalledTimes(2);
181
+ expect(vi.mocked(completeOnboarding)).not.toHaveBeenCalled();
182
+ });
183
+ });
@@ -0,0 +1,264 @@
1
+ import { describe, expect, it, vi, beforeEach } from 'vitest';
2
+ vi.mock('../workspace-bootstrap.js', () => ({
3
+ isOnboardingComplete: vi.fn(async () => true),
4
+ }));
5
+ vi.mock('./plan-commands.js', () => ({
6
+ parsePlanCommand: vi.fn((content) => {
7
+ const trimmed = content.trim();
8
+ if (!trimmed.startsWith('!plan run '))
9
+ return null;
10
+ return { action: 'run', args: trimmed.slice('!plan run '.length) };
11
+ }),
12
+ handlePlanCommand: vi.fn(async () => 'ok'),
13
+ preparePlanRun: vi.fn(async () => ({
14
+ phasesFilePath: '/tmp/plans/plan-042-phases.md',
15
+ planFilePath: '/tmp/plans/plan-042-test.md',
16
+ planContent: '# Plan',
17
+ nextPhase: { id: 'phase-1', title: 'First phase', kind: 'implement', status: 'pending', deps: [], contextFiles: [] },
18
+ })),
19
+ handlePlanSkip: vi.fn(async () => 'ok'),
20
+ closePlanIfComplete: vi.fn(async () => ({ closed: false, reason: 'not_all_complete' })),
21
+ NO_PHASES_SENTINEL: 'NO_PHASES',
22
+ findPlanFile: vi.fn(async () => null),
23
+ looksLikePlanId: vi.fn(() => false),
24
+ }));
25
+ vi.mock('./plan-manager.js', () => ({
26
+ runNextPhase: vi.fn(async () => ({ result: 'nothing_to_run' })),
27
+ resolveProjectCwd: vi.fn((_content, workspaceCwd) => workspaceCwd),
28
+ readPhasesFile: vi.fn(() => ({ phases: [] })),
29
+ buildPostRunSummary: vi.fn(() => ''),
30
+ }));
31
+ vi.mock('./forge-plan-registry.js', () => ({
32
+ acquireWriterLock: vi.fn(async () => vi.fn()),
33
+ setActiveOrchestrator: vi.fn(),
34
+ getActiveOrchestrator: vi.fn(() => null),
35
+ addRunningPlan: vi.fn(),
36
+ removeRunningPlan: vi.fn(),
37
+ isPlanRunning: vi.fn(() => false),
38
+ }));
39
+ function makeParams() {
40
+ const metrics = { increment: vi.fn() };
41
+ const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
42
+ return {
43
+ allowUserIds: new Set(['user-1']),
44
+ botDisplayName: 'Discoclaw',
45
+ requireChannelContext: false,
46
+ autoIndexChannelContext: false,
47
+ autoJoinThreads: false,
48
+ useRuntimeSessions: false,
49
+ runtime: { id: 'claude', capabilities: {} },
50
+ sessionManager: {},
51
+ workspaceCwd: '/tmp/workspace',
52
+ projectCwd: '/tmp/workspace',
53
+ groupsDir: '/tmp/workspace',
54
+ useGroupDirCwd: false,
55
+ runtimeModel: 'capable',
56
+ runtimeTools: [],
57
+ runtimeTimeoutMs: 30_000,
58
+ discordActionsEnabled: false,
59
+ discordActionsChannels: false,
60
+ discordActionsMessaging: false,
61
+ discordActionsGuild: false,
62
+ discordActionsModeration: false,
63
+ discordActionsPolls: false,
64
+ messageHistoryBudget: 0,
65
+ summaryEnabled: false,
66
+ summaryModel: 'fast',
67
+ summaryMaxChars: 2000,
68
+ summaryEveryNTurns: 6,
69
+ summaryDataDir: '/tmp/workspace',
70
+ durableMemoryEnabled: false,
71
+ durableDataDir: '/tmp/workspace',
72
+ durableInjectMaxChars: 2000,
73
+ durableMaxItems: 100,
74
+ memoryCommandsEnabled: false,
75
+ planCommandsEnabled: true,
76
+ planPhasesEnabled: true,
77
+ planPhaseMaxContextFiles: 5,
78
+ forgeCommandsEnabled: false,
79
+ summaryToDurableEnabled: false,
80
+ shortTermMemoryEnabled: false,
81
+ shortTermDataDir: '/tmp/workspace',
82
+ shortTermMaxEntries: 0,
83
+ shortTermMaxAgeMs: 0,
84
+ shortTermInjectMaxChars: 0,
85
+ streamStallWarningMs: 10_000,
86
+ actionFollowupDepth: 1,
87
+ reactionHandlerEnabled: false,
88
+ reactionRemoveHandlerEnabled: false,
89
+ reactionMaxAgeMs: 0,
90
+ healthCommandsEnabled: false,
91
+ metrics,
92
+ log,
93
+ };
94
+ }
95
+ function makeMessage(content) {
96
+ const phaseMsg = {
97
+ edit: vi.fn(async () => ({})),
98
+ };
99
+ const progressReply = {
100
+ edit: vi.fn(async () => ({})),
101
+ };
102
+ const channel = {
103
+ id: 'channel-1',
104
+ name: 'general',
105
+ send: vi.fn(async () => phaseMsg),
106
+ isThread: () => false,
107
+ };
108
+ return {
109
+ id: 'm1',
110
+ type: 0,
111
+ content,
112
+ author: { id: 'user-1', bot: false },
113
+ guildId: 'g1',
114
+ guild: { id: 'g1' },
115
+ channelId: 'channel-1',
116
+ channel,
117
+ client: { channels: { cache: new Map() }, user: { id: 'bot-1' } },
118
+ attachments: new Map(),
119
+ stickers: new Map(),
120
+ embeds: [],
121
+ mentions: { has: () => false },
122
+ reply: vi.fn(async () => progressReply),
123
+ progressReply,
124
+ phaseMsg,
125
+ };
126
+ }
127
+ async function makeHandler(params, queue) {
128
+ const { createMessageCreateHandler } = await import('./message-coordinator.js');
129
+ return createMessageCreateHandler(params, queue);
130
+ }
131
+ describe('message coordinator plan run phase-start posts', () => {
132
+ beforeEach(() => {
133
+ vi.clearAllMocks();
134
+ });
135
+ it('posts phase-start updates for manual !plan run with non phase-* ids', async () => {
136
+ const { runNextPhase } = await import('./plan-manager.js');
137
+ runNextPhase.mockImplementationOnce(async (_phases, _plan, opts) => {
138
+ await opts.onPlanEvent?.({
139
+ type: 'phase_start',
140
+ planId: 'plan-042',
141
+ phase: { id: 'audit-1', title: 'Post implementation audit', kind: 'audit' },
142
+ });
143
+ return { result: 'nothing_to_run' };
144
+ });
145
+ const queue = { run: vi.fn(async (_key, fn) => fn()) };
146
+ const handler = await makeHandler(makeParams(), queue);
147
+ const msg = makeMessage('!plan run plan-042');
148
+ await handler(msg);
149
+ await vi.waitFor(() => {
150
+ expect(runNextPhase).toHaveBeenCalled();
151
+ expect(msg.channel.send).toHaveBeenCalledWith(expect.objectContaining({
152
+ content: '**Post implementation audit**...',
153
+ }));
154
+ });
155
+ });
156
+ it('deduplicates repeated phase-start progress lines in a single run', async () => {
157
+ const { runNextPhase } = await import('./plan-manager.js');
158
+ runNextPhase.mockImplementationOnce(async (_phases, _plan, opts) => {
159
+ const event = {
160
+ type: 'phase_start',
161
+ planId: 'plan-042',
162
+ phase: { id: 'phase-1', title: 'First phase', kind: 'implement' },
163
+ };
164
+ await opts.onPlanEvent?.(event);
165
+ await opts.onPlanEvent?.(event);
166
+ return { result: 'nothing_to_run' };
167
+ });
168
+ const queue = { run: vi.fn(async (_key, fn) => fn()) };
169
+ const handler = await makeHandler(makeParams(), queue);
170
+ const msg = makeMessage('!plan run plan-042');
171
+ await handler(msg);
172
+ await vi.waitFor(() => {
173
+ const phaseStartPosts = msg.channel.send.mock.calls
174
+ .map((call) => String(call[0]?.content ?? ''))
175
+ .filter((content) => content.includes('**First phase**...'));
176
+ expect(phaseStartPosts).toHaveLength(1);
177
+ });
178
+ });
179
+ it('edits phase-start message to resolved state on phase_complete (done)', async () => {
180
+ const { runNextPhase } = await import('./plan-manager.js');
181
+ runNextPhase.mockImplementationOnce(async (_phases, _plan, opts) => {
182
+ await opts.onPlanEvent?.({
183
+ type: 'phase_start',
184
+ planId: 'plan-042',
185
+ phase: { id: 'phase-1', title: 'First phase', kind: 'implement' },
186
+ });
187
+ await opts.onPlanEvent?.({
188
+ type: 'phase_complete',
189
+ planId: 'plan-042',
190
+ phase: { id: 'phase-1', title: 'First phase', kind: 'implement' },
191
+ status: 'done',
192
+ });
193
+ return { result: 'nothing_to_run' };
194
+ });
195
+ const queue = { run: vi.fn(async (_key, fn) => fn()) };
196
+ const handler = await makeHandler(makeParams(), queue);
197
+ const msg = makeMessage('!plan run plan-042');
198
+ await handler(msg);
199
+ await vi.waitFor(() => {
200
+ expect(msg.channel.send).toHaveBeenCalledWith(expect.objectContaining({
201
+ content: '**First phase**...',
202
+ }));
203
+ expect(msg.phaseMsg.edit).toHaveBeenCalledWith(expect.objectContaining({
204
+ content: '[x] **First phase**',
205
+ }));
206
+ });
207
+ });
208
+ it('edits phase-start message with failure indicator on phase_complete (failed)', async () => {
209
+ const { runNextPhase } = await import('./plan-manager.js');
210
+ runNextPhase.mockImplementationOnce(async (_phases, _plan, opts) => {
211
+ await opts.onPlanEvent?.({
212
+ type: 'phase_start',
213
+ planId: 'plan-042',
214
+ phase: { id: 'phase-1', title: 'First phase', kind: 'implement' },
215
+ });
216
+ await opts.onPlanEvent?.({
217
+ type: 'phase_complete',
218
+ planId: 'plan-042',
219
+ phase: { id: 'phase-1', title: 'First phase', kind: 'implement' },
220
+ status: 'failed',
221
+ });
222
+ return { result: 'nothing_to_run' };
223
+ });
224
+ const queue = { run: vi.fn(async (_key, fn) => fn()) };
225
+ const handler = await makeHandler(makeParams(), queue);
226
+ const msg = makeMessage('!plan run plan-042');
227
+ await handler(msg);
228
+ await vi.waitFor(() => {
229
+ expect(msg.phaseMsg.edit).toHaveBeenCalledWith(expect.objectContaining({
230
+ content: '[!] **First phase**',
231
+ }));
232
+ });
233
+ });
234
+ it('posts a final summary channel message after a full plan run', async () => {
235
+ const { runNextPhase } = await import('./plan-manager.js');
236
+ runNextPhase
237
+ .mockImplementationOnce(async (_phases, _plan, opts) => {
238
+ await opts.onPlanEvent?.({
239
+ type: 'phase_start',
240
+ planId: 'plan-042',
241
+ phase: { id: 'phase-1', title: 'First phase', kind: 'implement' },
242
+ });
243
+ return {
244
+ result: 'done',
245
+ phase: { id: 'phase-1', title: 'First phase', kind: 'implement', status: 'done', dependsOn: [], contextFiles: [] },
246
+ output: 'done',
247
+ nextPhase: undefined,
248
+ };
249
+ })
250
+ .mockImplementationOnce(async () => ({ result: 'nothing_to_run' }));
251
+ const queue = { run: vi.fn(async (_key, fn) => fn()) };
252
+ const handler = await makeHandler(makeParams(), queue);
253
+ const msg = makeMessage('!plan run plan-042');
254
+ await handler(msg);
255
+ await vi.waitFor(() => {
256
+ // The final channel.send should include a plan-run-complete summary
257
+ const finalSend = msg.channel.send.mock.calls.find((call) => {
258
+ const content = String(call[0]?.content ?? '');
259
+ return content.includes('plan-042') && content.includes('phase');
260
+ });
261
+ expect(finalSend).toBeDefined();
262
+ });
263
+ });
264
+ });
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Fetch recent messages from a Discord channel and format them as conversation
3
+ * history suitable for prepending to a prompt.
4
+ *
5
+ * Returns an empty string if no history is available or on any fetch error.
6
+ */
7
+ export async function fetchMessageHistory(channel, beforeMessageId, opts) {
8
+ if (opts.budgetChars <= 0)
9
+ return '';
10
+ let messages;
11
+ try {
12
+ messages = await channel.messages.fetch({
13
+ before: beforeMessageId,
14
+ limit: opts.fetchLimit ?? 10,
15
+ });
16
+ }
17
+ catch {
18
+ return '';
19
+ }
20
+ if (!messages || messages.size === 0)
21
+ return '';
22
+ // Discord API returns newest-first; convert to array and reverse to chronological order.
23
+ const sorted = [...messages.values()].reverse();
24
+ // Build history from most recent backward so the most relevant context is kept.
25
+ let remaining = opts.budgetChars;
26
+ const selected = [];
27
+ for (let i = sorted.length - 1; i >= 0 && remaining > 0; i--) {
28
+ const m = sorted[i];
29
+ const author = m.author.bot ? (opts.botDisplayName ?? 'Discoclaw') : (m.author.displayName || m.author.username);
30
+ const content = String(m.content ?? '');
31
+ const full = `[${author}]: ${content}`;
32
+ if (m.author.bot && full.length > remaining) {
33
+ // Truncate bot messages to fit remaining budget.
34
+ const prefix = `[${author}]: `;
35
+ const maxContent = Math.max(0, remaining - prefix.length - 3);
36
+ if (maxContent <= 0)
37
+ break;
38
+ selected.unshift(`${prefix}${content.slice(0, maxContent)}...`);
39
+ remaining = 0;
40
+ }
41
+ else if (full.length > remaining) {
42
+ // User message doesn't fit — stop.
43
+ break;
44
+ }
45
+ else {
46
+ selected.unshift(full);
47
+ remaining -= full.length + 1; // +1 for newline separator
48
+ }
49
+ }
50
+ if (selected.length === 0)
51
+ return '';
52
+ return selected.join('\n');
53
+ }
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { fetchMessageHistory } from './message-history.js';
3
+ /** Helper: create a fake Discord message. */
4
+ function fakeMsg(id, content, username, bot = false) {
5
+ return {
6
+ id,
7
+ content,
8
+ author: { username, displayName: username, bot },
9
+ };
10
+ }
11
+ /** Helper: create a fake channel whose messages.fetch returns the given messages (newest-first). */
12
+ function fakeChannel(messages) {
13
+ return {
14
+ messages: {
15
+ fetch: async () => {
16
+ // Discord returns a Collection (Map-like) with newest-first order.
17
+ const map = new Map();
18
+ for (const m of messages)
19
+ map.set(m.id, m);
20
+ return map;
21
+ },
22
+ },
23
+ };
24
+ }
25
+ describe('fetchMessageHistory', () => {
26
+ it('fetches and formats messages in chronological order', async () => {
27
+ const ch = fakeChannel([
28
+ fakeMsg('3', 'sounds good', 'TestUser'),
29
+ fakeMsg('2', 'Before I create it, let me confirm...', 'Discoclaw', true),
30
+ fakeMsg('1', 'create a status channel', 'TestUser'),
31
+ ]);
32
+ const result = await fetchMessageHistory(ch, '4', { budgetChars: 5000 });
33
+ const lines = result.split('\n');
34
+ expect(lines).toHaveLength(3);
35
+ expect(lines[0]).toBe('[TestUser]: create a status channel');
36
+ expect(lines[1]).toBe('[Discoclaw]: Before I create it, let me confirm...');
37
+ expect(lines[2]).toBe('[TestUser]: sounds good');
38
+ });
39
+ it('respects char budget — stops adding messages when full', async () => {
40
+ const ch = fakeChannel([
41
+ fakeMsg('3', 'c', 'User'),
42
+ fakeMsg('2', 'b', 'User'),
43
+ fakeMsg('1', 'aaaaaaaaaa', 'User'), // oldest — long enough to exceed budget
44
+ ]);
45
+ // Budget enough for the two recent short messages but not all three.
46
+ // "[User]: c" = 9 chars, "[User]: b" = 9 chars, + 1 newline = 19 chars
47
+ const result = await fetchMessageHistory(ch, '4', { budgetChars: 19 });
48
+ const lines = result.split('\n');
49
+ expect(lines).toHaveLength(2);
50
+ expect(lines[0]).toBe('[User]: b');
51
+ expect(lines[1]).toBe('[User]: c');
52
+ });
53
+ it('truncates bot responses that exceed remaining budget', async () => {
54
+ const longResponse = 'A'.repeat(200);
55
+ const ch = fakeChannel([
56
+ fakeMsg('2', longResponse, 'Discoclaw', true),
57
+ fakeMsg('1', 'hi', 'User'),
58
+ ]);
59
+ // Budget enough for part of the bot response but not all.
60
+ const result = await fetchMessageHistory(ch, '3', { budgetChars: 50 });
61
+ expect(result).toContain('[Discoclaw]:');
62
+ expect(result).toContain('...');
63
+ expect(result.length).toBeLessThanOrEqual(55); // some tolerance for formatting
64
+ });
65
+ it('includes user messages in full', async () => {
66
+ const ch = fakeChannel([
67
+ fakeMsg('2', 'this is a user message', 'Alice'),
68
+ fakeMsg('1', 'hello', 'Alice'),
69
+ ]);
70
+ const result = await fetchMessageHistory(ch, '3', { budgetChars: 5000 });
71
+ expect(result).toContain('[Alice]: hello');
72
+ expect(result).toContain('[Alice]: this is a user message');
73
+ });
74
+ it('handles fetch failures gracefully (returns empty string)', async () => {
75
+ const ch = {
76
+ messages: {
77
+ fetch: async () => { throw new Error('forbidden'); },
78
+ },
79
+ };
80
+ const result = await fetchMessageHistory(ch, '1', { budgetChars: 3000 });
81
+ expect(result).toBe('');
82
+ });
83
+ it('returns empty string when no prior messages exist', async () => {
84
+ const ch = fakeChannel([]);
85
+ const result = await fetchMessageHistory(ch, '1', { budgetChars: 3000 });
86
+ expect(result).toBe('');
87
+ });
88
+ it('returns empty string when budget is 0', async () => {
89
+ const ch = fakeChannel([
90
+ fakeMsg('1', 'hello', 'User'),
91
+ ]);
92
+ const result = await fetchMessageHistory(ch, '2', { budgetChars: 0 });
93
+ expect(result).toBe('');
94
+ });
95
+ });
@@ -0,0 +1,59 @@
1
+ import { executeConfigAction } from './actions-config.js';
2
+ // ---------------------------------------------------------------------------
3
+ // Parser
4
+ // ---------------------------------------------------------------------------
5
+ const VALID_ROLES = new Set(['chat', 'fast', 'forge-drafter', 'forge-auditor', 'summary', 'cron', 'cron-exec']);
6
+ export function parseModelsCommand(content) {
7
+ const tokens = String(content ?? '').trim().split(/\s+/).filter(Boolean);
8
+ if (tokens.length === 0)
9
+ return null;
10
+ if (tokens[0].toLowerCase() !== '!models')
11
+ return null;
12
+ if (tokens.length === 1)
13
+ return { action: 'show' };
14
+ const subcommand = tokens[1].toLowerCase();
15
+ if (subcommand === 'show' && tokens.length === 2)
16
+ return { action: 'show' };
17
+ if (subcommand === 'help' && tokens.length === 2)
18
+ return { action: 'help' };
19
+ if (subcommand !== 'set' || tokens.length !== 4)
20
+ return null;
21
+ const role = tokens[2].toLowerCase();
22
+ if (!VALID_ROLES.has(role))
23
+ return null;
24
+ // Preserve original case for model IDs.
25
+ return { action: 'set', role: role, model: tokens[3] };
26
+ }
27
+ export function handleModelsCommand(cmd, opts) {
28
+ const { configCtx, configEnabled } = opts;
29
+ if (!configCtx) {
30
+ return configEnabled
31
+ ? 'Model configuration is not yet available — the bot is still starting up. Try again in a moment.'
32
+ : 'Model configuration is disabled.';
33
+ }
34
+ if (cmd.action === 'help') {
35
+ return [
36
+ '**!models commands:**',
37
+ '- `!models` — show current model assignments for all roles',
38
+ '- `!models show` — same as above',
39
+ '- `!models set <role> <model>` — change the model for a role at runtime',
40
+ '- `!models help` — this message',
41
+ '',
42
+ '**Roles:** `chat`, `fast`, `forge-drafter`, `forge-auditor`, `summary`, `cron`, `cron-exec`',
43
+ '',
44
+ '**Examples:**',
45
+ '- `!models set chat sonnet`',
46
+ '- `!models set fast haiku`',
47
+ '- `!models set forge-drafter opus`',
48
+ '- `!models set cron-exec haiku` — run crons on a cheaper model',
49
+ '- `!models set cron-exec default` — revert to following chat model',
50
+ ].join('\n');
51
+ }
52
+ if (cmd.action === 'show') {
53
+ const result = executeConfigAction({ type: 'modelShow' }, configCtx);
54
+ return result.ok ? result.summary : `Error: ${result.error}`;
55
+ }
56
+ // action === 'set'
57
+ const result = executeConfigAction({ type: 'modelSet', role: cmd.role, model: cmd.model }, configCtx);
58
+ return result.ok ? result.summary : `Error: ${result.error}`;
59
+ }