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,372 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { BEAD_ACTION_TYPES, executeBeadAction, beadActionsPromptSection } from './actions-beads.js';
3
+ // ---------------------------------------------------------------------------
4
+ // Mocks — override discord-sync and related modules
5
+ // ---------------------------------------------------------------------------
6
+ vi.mock('../beads/discord-sync.js', () => ({
7
+ resolveTasksForum: vi.fn(() => ({
8
+ threads: {
9
+ create: vi.fn(async () => ({ id: 'thread-new' })),
10
+ },
11
+ })),
12
+ createTaskThread: vi.fn(async () => 'thread-new'),
13
+ closeTaskThread: vi.fn(async () => { }),
14
+ updateTaskThreadName: vi.fn(async () => { }),
15
+ updateTaskStarterMessage: vi.fn(async () => true),
16
+ updateTaskThreadTags: vi.fn(async () => false),
17
+ ensureUnarchived: vi.fn(async () => { }),
18
+ getThreadIdFromTask: vi.fn((task) => {
19
+ const ref = task.externalRef ?? task.external_ref ?? '';
20
+ if (ref.startsWith('discord:'))
21
+ return ref.slice('discord:'.length);
22
+ return null;
23
+ }),
24
+ findExistingThreadForTask: vi.fn(async () => null),
25
+ reloadTagMapInPlace: vi.fn(async () => 2),
26
+ }));
27
+ vi.mock('../beads/auto-tag.js', () => ({
28
+ autoTagBead: vi.fn(async () => ['feature']),
29
+ }));
30
+ vi.mock('../beads/bead-sync.js', () => ({
31
+ runBeadSync: vi.fn(async () => ({
32
+ threadsCreated: 1,
33
+ emojisUpdated: 2,
34
+ starterMessagesUpdated: 5,
35
+ threadsArchived: 3,
36
+ statusesUpdated: 4,
37
+ tagsUpdated: 0,
38
+ warnings: 0,
39
+ })),
40
+ }));
41
+ // ---------------------------------------------------------------------------
42
+ // Helpers
43
+ // ---------------------------------------------------------------------------
44
+ function makeCtx() {
45
+ return {
46
+ guild: {},
47
+ client: {
48
+ channels: {
49
+ cache: {
50
+ get: () => undefined,
51
+ },
52
+ },
53
+ },
54
+ channelId: 'test-channel',
55
+ messageId: 'test-message',
56
+ };
57
+ }
58
+ function makeStore() {
59
+ const defaultBead = (id) => ({
60
+ id,
61
+ title: 'Test bead',
62
+ description: 'A test',
63
+ status: 'open',
64
+ priority: 2,
65
+ issue_type: 'task',
66
+ owner: '',
67
+ external_ref: 'discord:111222333',
68
+ labels: ['feature'],
69
+ comments: [],
70
+ created_at: '2026-01-01T00:00:00Z',
71
+ updated_at: '2026-01-01T00:00:00Z',
72
+ });
73
+ return {
74
+ get: vi.fn((id) => {
75
+ if (id === 'ws-notfound')
76
+ return undefined;
77
+ return defaultBead(id);
78
+ }),
79
+ list: vi.fn(() => [
80
+ { id: 'ws-001', title: 'First', status: 'open', priority: 2 },
81
+ { id: 'ws-002', title: 'Second', status: 'in_progress', priority: 1 },
82
+ ]),
83
+ create: vi.fn((params) => ({
84
+ id: 'ws-new',
85
+ title: params.title,
86
+ description: params.description ?? '',
87
+ status: 'open',
88
+ priority: params.priority ?? 2,
89
+ issue_type: 'task',
90
+ owner: '',
91
+ external_ref: '',
92
+ labels: params.labels ?? [],
93
+ comments: [],
94
+ created_at: '2026-01-01T00:00:00Z',
95
+ updated_at: '2026-01-01T00:00:00Z',
96
+ })),
97
+ update: vi.fn((id) => defaultBead(id)),
98
+ close: vi.fn((id) => ({ ...defaultBead(id), status: 'closed' })),
99
+ addLabel: vi.fn((id) => defaultBead(id)),
100
+ };
101
+ }
102
+ function makeBeadCtx(overrides) {
103
+ return {
104
+ beadsCwd: '/tmp/test-beads',
105
+ forumId: 'forum-123',
106
+ tagMap: { feature: 'tag-1', bug: 'tag-2' },
107
+ store: makeStore(),
108
+ runtime: { id: 'other', capabilities: new Set(), invoke: async function* () { } },
109
+ autoTag: false,
110
+ autoTagModel: 'haiku',
111
+ ...overrides,
112
+ };
113
+ }
114
+ // ---------------------------------------------------------------------------
115
+ // Tests
116
+ // ---------------------------------------------------------------------------
117
+ describe('BEAD_ACTION_TYPES', () => {
118
+ it('contains all bead action types', () => {
119
+ expect(BEAD_ACTION_TYPES.has('beadCreate')).toBe(true);
120
+ expect(BEAD_ACTION_TYPES.has('beadUpdate')).toBe(true);
121
+ expect(BEAD_ACTION_TYPES.has('beadClose')).toBe(true);
122
+ expect(BEAD_ACTION_TYPES.has('beadShow')).toBe(true);
123
+ expect(BEAD_ACTION_TYPES.has('beadList')).toBe(true);
124
+ expect(BEAD_ACTION_TYPES.has('beadSync')).toBe(true);
125
+ expect(BEAD_ACTION_TYPES.has('tagMapReload')).toBe(true);
126
+ });
127
+ it('does not contain non-bead types', () => {
128
+ expect(BEAD_ACTION_TYPES.has('channelCreate')).toBe(false);
129
+ });
130
+ });
131
+ describe('executeBeadAction', () => {
132
+ it('beadCreate returns created bead summary', async () => {
133
+ const result = await executeBeadAction({ type: 'beadCreate', title: 'New task', priority: 1 }, makeCtx(), makeBeadCtx());
134
+ expect(result.ok).toBe(true);
135
+ expect(result.summary).toContain('ws-new');
136
+ expect(result.summary).toContain('New task');
137
+ });
138
+ it('beadCreate calls forumCountSync.requestUpdate', async () => {
139
+ const mockSync = { requestUpdate: vi.fn(), stop: vi.fn() };
140
+ await executeBeadAction({ type: 'beadCreate', title: 'Counted task' }, makeCtx(), makeBeadCtx({ forumCountSync: mockSync }));
141
+ expect(mockSync.requestUpdate).toHaveBeenCalled();
142
+ });
143
+ it('beadCreate fails without title', async () => {
144
+ const result = await executeBeadAction({ type: 'beadCreate', title: '' }, makeCtx(), makeBeadCtx());
145
+ expect(result.ok).toBe(false);
146
+ });
147
+ it('beadCreate honors no-thread by skipping thread creation', async () => {
148
+ const { createTaskThread } = await import('../beads/discord-sync.js');
149
+ createTaskThread.mockClear?.();
150
+ const result = await executeBeadAction({ type: 'beadCreate', title: 'No thread please', tags: 'no-thread,feature' }, makeCtx(), makeBeadCtx());
151
+ expect(result.ok).toBe(true);
152
+ expect(createTaskThread).not.toHaveBeenCalled();
153
+ });
154
+ it('beadUpdate returns updated summary', async () => {
155
+ const result = await executeBeadAction({ type: 'beadUpdate', beadId: 'ws-001', status: 'in_progress', priority: 1 }, makeCtx(), makeBeadCtx());
156
+ expect(result.ok).toBe(true);
157
+ expect(result.summary).toContain('ws-001');
158
+ expect(result.summary).toContain('in_progress');
159
+ });
160
+ it('beadUpdate calls forumCountSync.requestUpdate when status changed', async () => {
161
+ const mockSync = { requestUpdate: vi.fn(), stop: vi.fn() };
162
+ await executeBeadAction({ type: 'beadUpdate', beadId: 'ws-001', status: 'in_progress' }, makeCtx(), makeBeadCtx({ forumCountSync: mockSync }));
163
+ expect(mockSync.requestUpdate).toHaveBeenCalled();
164
+ });
165
+ it('beadUpdate does NOT call forumCountSync.requestUpdate without status change', async () => {
166
+ const mockSync = { requestUpdate: vi.fn(), stop: vi.fn() };
167
+ await executeBeadAction({ type: 'beadUpdate', beadId: 'ws-001', title: 'New title' }, makeCtx(), makeBeadCtx({ forumCountSync: mockSync }));
168
+ expect(mockSync.requestUpdate).not.toHaveBeenCalled();
169
+ });
170
+ it('beadUpdate fails without beadId', async () => {
171
+ const result = await executeBeadAction({ type: 'beadUpdate', beadId: '' }, makeCtx(), makeBeadCtx());
172
+ expect(result.ok).toBe(false);
173
+ });
174
+ it('beadUpdate calls updateBeadStarterMessage when bead has a linked thread', async () => {
175
+ const { updateTaskStarterMessage } = await import('../beads/discord-sync.js');
176
+ updateTaskStarterMessage.mockClear();
177
+ await executeBeadAction({ type: 'beadUpdate', beadId: 'ws-001', description: 'Updated desc' }, makeCtx(), makeBeadCtx());
178
+ expect(updateTaskStarterMessage).toHaveBeenCalledWith(expect.anything(), '111222333', expect.objectContaining({ id: 'ws-001' }), undefined);
179
+ });
180
+ it('beadUpdate passes sidebarMentionUserId to updateBeadStarterMessage', async () => {
181
+ const { updateTaskStarterMessage } = await import('../beads/discord-sync.js');
182
+ updateTaskStarterMessage.mockClear();
183
+ await executeBeadAction({ type: 'beadUpdate', beadId: 'ws-001', description: 'Updated desc' }, makeCtx(), makeBeadCtx({ sidebarMentionUserId: '999' }));
184
+ expect(updateTaskStarterMessage).toHaveBeenCalledWith(expect.anything(), '111222333', expect.objectContaining({ id: 'ws-001' }), '999');
185
+ });
186
+ it('beadUpdate succeeds even if updateBeadStarterMessage throws', async () => {
187
+ const { updateTaskStarterMessage } = await import('../beads/discord-sync.js');
188
+ updateTaskStarterMessage.mockRejectedValueOnce(new Error('Discord API error'));
189
+ const result = await executeBeadAction({ type: 'beadUpdate', beadId: 'ws-001', status: 'in_progress' }, makeCtx(), makeBeadCtx());
190
+ expect(result.ok).toBe(true);
191
+ });
192
+ it('beadUpdate calls updateBeadThreadTags when bead has a linked thread', async () => {
193
+ const { updateTaskThreadTags } = await import('../beads/discord-sync.js');
194
+ updateTaskThreadTags.mockClear();
195
+ await executeBeadAction({ type: 'beadUpdate', beadId: 'ws-001', status: 'in_progress' }, makeCtx(), makeBeadCtx());
196
+ expect(updateTaskThreadTags).toHaveBeenCalledWith(expect.anything(), '111222333', expect.objectContaining({ id: 'ws-001' }), expect.objectContaining({ feature: 'tag-1' }));
197
+ });
198
+ it('beadClose passes tagMap to closeBeadThread', async () => {
199
+ const { closeTaskThread } = await import('../beads/discord-sync.js');
200
+ closeTaskThread.mockClear();
201
+ const beadCtx = makeBeadCtx();
202
+ await executeBeadAction({ type: 'beadClose', beadId: 'ws-001', reason: 'Done' }, makeCtx(), beadCtx);
203
+ expect(closeTaskThread).toHaveBeenCalledWith(expect.anything(), '111222333', expect.objectContaining({ id: 'ws-001' }), beadCtx.tagMap, beadCtx.log);
204
+ });
205
+ it('beadUpdate rejects invalid status', async () => {
206
+ const result = await executeBeadAction({ type: 'beadUpdate', beadId: 'ws-001', status: 'nonsense' }, makeCtx(), makeBeadCtx());
207
+ expect(result.ok).toBe(false);
208
+ expect(result.error).toContain('Invalid');
209
+ });
210
+ it('beadClose returns closed summary', async () => {
211
+ const result = await executeBeadAction({ type: 'beadClose', beadId: 'ws-001', reason: 'Done' }, makeCtx(), makeBeadCtx());
212
+ expect(result.ok).toBe(true);
213
+ expect(result.summary).toContain('ws-001');
214
+ expect(result.summary).toContain('Done');
215
+ });
216
+ it('beadClose calls forumCountSync.requestUpdate', async () => {
217
+ const mockSync = { requestUpdate: vi.fn(), stop: vi.fn() };
218
+ await executeBeadAction({ type: 'beadClose', beadId: 'ws-001' }, makeCtx(), makeBeadCtx({ forumCountSync: mockSync }));
219
+ expect(mockSync.requestUpdate).toHaveBeenCalled();
220
+ });
221
+ it('beadShow returns bead details', async () => {
222
+ const result = await executeBeadAction({ type: 'beadShow', beadId: 'ws-001' }, makeCtx(), makeBeadCtx());
223
+ expect(result.ok).toBe(true);
224
+ expect(result.summary).toContain('Test bead');
225
+ expect(result.summary).toContain('ws-001');
226
+ });
227
+ it('beadShow fails for unknown bead', async () => {
228
+ const result = await executeBeadAction({ type: 'beadShow', beadId: 'ws-notfound' }, makeCtx(), makeBeadCtx());
229
+ expect(result.ok).toBe(false);
230
+ expect(result.error).toContain('not found');
231
+ });
232
+ it('beadList returns bead list', async () => {
233
+ const result = await executeBeadAction({ type: 'beadList', status: 'open', limit: 10 }, makeCtx(), makeBeadCtx());
234
+ expect(result.ok).toBe(true);
235
+ expect(result.summary).toContain('ws-001');
236
+ expect(result.summary).toContain('ws-002');
237
+ });
238
+ it('beadList defaults to limit 50 when no limit provided', async () => {
239
+ const store = makeStore();
240
+ await executeBeadAction({ type: 'beadList', status: 'all' }, makeCtx(), makeBeadCtx({ store: store }));
241
+ expect(store.list).toHaveBeenCalledWith(expect.objectContaining({ limit: 50 }));
242
+ });
243
+ it('beadList respects explicit limit', async () => {
244
+ const store = makeStore();
245
+ await executeBeadAction({ type: 'beadList', status: 'all', limit: 5 }, makeCtx(), makeBeadCtx({ store: store }));
246
+ expect(store.list).toHaveBeenCalledWith(expect.objectContaining({ limit: 5 }));
247
+ });
248
+ it('beadSync returns extended sync summary', async () => {
249
+ const result = await executeBeadAction({ type: 'beadSync' }, makeCtx(), makeBeadCtx());
250
+ expect(result.ok).toBe(true);
251
+ expect(result.summary).toContain('status-fixes');
252
+ expect(result.summary).toContain('5 starters');
253
+ });
254
+ it('beadSync passes statusPoster through to runBeadSync', async () => {
255
+ const { runBeadSync } = await import('../beads/bead-sync.js');
256
+ runBeadSync.mockClear();
257
+ const mockPoster = { beadSyncComplete: vi.fn() };
258
+ await executeBeadAction({ type: 'beadSync' }, makeCtx(), makeBeadCtx({ statusPoster: mockPoster }));
259
+ expect(runBeadSync).toHaveBeenCalledWith(expect.objectContaining({ statusPoster: mockPoster, mentionUserId: undefined }));
260
+ });
261
+ it('beadSync passes sidebarMentionUserId as mentionUserId to runBeadSync', async () => {
262
+ const { runBeadSync } = await import('../beads/bead-sync.js');
263
+ runBeadSync.mockClear();
264
+ await executeBeadAction({ type: 'beadSync' }, makeCtx(), makeBeadCtx({ sidebarMentionUserId: '999' }));
265
+ expect(runBeadSync).toHaveBeenCalledWith(expect.objectContaining({ mentionUserId: '999' }));
266
+ });
267
+ });
268
+ describe('tagMapReload action', () => {
269
+ it('success: returns old/new count with tag names', async () => {
270
+ const { reloadTagMapInPlace } = await import('../beads/discord-sync.js');
271
+ reloadTagMapInPlace.mockClear();
272
+ reloadTagMapInPlace.mockImplementationOnce(async (_path, tagMap) => {
273
+ // Simulate reload: clear and add new tags
274
+ for (const k of Object.keys(tagMap))
275
+ delete tagMap[k];
276
+ Object.assign(tagMap, { bug: '111', feature: '222', docs: '333' });
277
+ return 3;
278
+ });
279
+ const result = await executeBeadAction({ type: 'tagMapReload' }, makeCtx(), makeBeadCtx({ tagMapPath: '/tmp/tag-map.json' }));
280
+ expect(result.ok).toBe(true);
281
+ expect(result.summary).toContain('Tag map reloaded');
282
+ expect(result.summary).toContain('bug');
283
+ expect(result.summary).toContain('feature');
284
+ expect(result.summary).toContain('docs');
285
+ });
286
+ it('success with >10 tags: truncates tag list display', async () => {
287
+ const { reloadTagMapInPlace } = await import('../beads/discord-sync.js');
288
+ reloadTagMapInPlace.mockClear();
289
+ reloadTagMapInPlace.mockImplementationOnce(async (_path, tagMap) => {
290
+ for (const k of Object.keys(tagMap))
291
+ delete tagMap[k];
292
+ for (let i = 0; i < 15; i++)
293
+ tagMap[`tag${i}`] = `id${i}`;
294
+ return 15;
295
+ });
296
+ const result = await executeBeadAction({ type: 'tagMapReload' }, makeCtx(), makeBeadCtx({ tagMapPath: '/tmp/tag-map.json' }));
297
+ expect(result.ok).toBe(true);
298
+ expect(result.summary).toContain('(+5 more)');
299
+ });
300
+ it('failure: returns error with message, map preserved', async () => {
301
+ const { reloadTagMapInPlace } = await import('../beads/discord-sync.js');
302
+ reloadTagMapInPlace.mockClear();
303
+ reloadTagMapInPlace.mockRejectedValueOnce(new Error('ENOENT: file not found'));
304
+ const tagMap = { existing: '999' };
305
+ const result = await executeBeadAction({ type: 'tagMapReload' }, makeCtx(), makeBeadCtx({ tagMapPath: '/tmp/tag-map.json', tagMap }));
306
+ expect(result.ok).toBe(false);
307
+ expect(result.error).toContain('Tag map reload failed');
308
+ expect(result.error).toContain('ENOENT');
309
+ });
310
+ it('without tagMapPath: returns error', async () => {
311
+ const result = await executeBeadAction({ type: 'tagMapReload' }, makeCtx(), makeBeadCtx());
312
+ expect(result.ok).toBe(false);
313
+ expect(result.error).toContain('Tag map path not configured');
314
+ });
315
+ });
316
+ describe('beadSync fallback with tagMapPath', () => {
317
+ it('reloads tag map before runBeadSync in fallback path', async () => {
318
+ const { reloadTagMapInPlace } = await import('../beads/discord-sync.js');
319
+ const { runBeadSync } = await import('../beads/bead-sync.js');
320
+ reloadTagMapInPlace.mockClear();
321
+ runBeadSync.mockClear();
322
+ await executeBeadAction({ type: 'beadSync' }, makeCtx(), makeBeadCtx({ tagMapPath: '/tmp/tag-map.json' }));
323
+ expect(reloadTagMapInPlace).toHaveBeenCalledWith('/tmp/tag-map.json', expect.any(Object));
324
+ expect(runBeadSync).toHaveBeenCalled();
325
+ });
326
+ it('does not attempt reload without tagMapPath', async () => {
327
+ const { reloadTagMapInPlace } = await import('../beads/discord-sync.js');
328
+ reloadTagMapInPlace.mockClear();
329
+ await executeBeadAction({ type: 'beadSync' }, makeCtx(), makeBeadCtx());
330
+ expect(reloadTagMapInPlace).not.toHaveBeenCalled();
331
+ });
332
+ });
333
+ describe('beadActionsPromptSection', () => {
334
+ it('returns non-empty prompt section', () => {
335
+ const section = beadActionsPromptSection();
336
+ expect(section).toContain('taskCreate');
337
+ expect(section).toContain('taskClose');
338
+ expect(section).toContain('taskList');
339
+ });
340
+ it('includes tagMapReload in prompt section', () => {
341
+ const section = beadActionsPromptSection();
342
+ expect(section).toContain('tagMapReload');
343
+ });
344
+ it('includes task quality guidelines', () => {
345
+ const section = beadActionsPromptSection();
346
+ expect(section).toContain('imperative mood');
347
+ expect(section).toContain('Description');
348
+ expect(section).toContain('P0');
349
+ expect(section).toContain('P1');
350
+ expect(section).toContain('taskUpdate');
351
+ });
352
+ it('keeps guidelines block under 600 chars', () => {
353
+ const section = beadActionsPromptSection();
354
+ const marker = '#### Task Quality Guidelines';
355
+ const crossRefMarker = '#### Cross-Task References';
356
+ const idx = section.indexOf(marker);
357
+ expect(idx).toBeGreaterThanOrEqual(0);
358
+ const crossRefIdx = section.indexOf(crossRefMarker);
359
+ // Slice up to the cross-task section (or end of string if not found)
360
+ const end = crossRefIdx > idx ? crossRefIdx : section.length;
361
+ const guidelinesBlock = section.slice(idx, end);
362
+ expect(guidelinesBlock.length).toBeLessThanOrEqual(600);
363
+ });
364
+ it('includes cross-task references guideline', () => {
365
+ const section = beadActionsPromptSection();
366
+ expect(section).toContain('#### Cross-Task References');
367
+ expect(section).toContain('taskShow');
368
+ expect(section).toContain('taskUpdate');
369
+ expect(section).toContain('taskSync');
370
+ expect(section).toContain('Legacy aliases');
371
+ });
372
+ });
@@ -0,0 +1,107 @@
1
+ import { ActivityType } from 'discord.js';
2
+ const BOT_PROFILE_TYPE_MAP = {
3
+ botSetStatus: true,
4
+ botSetActivity: true,
5
+ botSetNickname: true,
6
+ };
7
+ export const BOT_PROFILE_ACTION_TYPES = new Set(Object.keys(BOT_PROFILE_TYPE_MAP));
8
+ // ---------------------------------------------------------------------------
9
+ // Helpers
10
+ // ---------------------------------------------------------------------------
11
+ const VALID_STATUSES = new Set(['online', 'idle', 'dnd', 'invisible']);
12
+ export const ACTIVITY_TYPE_MAP = {
13
+ Playing: ActivityType.Playing,
14
+ Listening: ActivityType.Listening,
15
+ Watching: ActivityType.Watching,
16
+ Competing: ActivityType.Competing,
17
+ Custom: ActivityType.Custom,
18
+ };
19
+ // ---------------------------------------------------------------------------
20
+ // Executor
21
+ // ---------------------------------------------------------------------------
22
+ export async function executeBotProfileAction(action, ctx) {
23
+ const { client, guild } = ctx;
24
+ switch (action.type) {
25
+ case 'botSetStatus': {
26
+ if (!VALID_STATUSES.has(action.status)) {
27
+ return { ok: false, error: `Invalid status "${action.status}"; must be one of: online, idle, dnd, invisible` };
28
+ }
29
+ client.user.setStatus(action.status);
30
+ return { ok: true, summary: `Status set to ${action.status}` };
31
+ }
32
+ case 'botSetActivity': {
33
+ if (!action.name || typeof action.name !== 'string') {
34
+ return { ok: false, error: 'botSetActivity requires a non-empty "name" field' };
35
+ }
36
+ const typeName = action.activityType ?? 'Playing';
37
+ const typeNum = ACTIVITY_TYPE_MAP[typeName];
38
+ if (typeNum === undefined) {
39
+ return { ok: false, error: `Invalid activityType "${typeName}"; must be one of: Playing, Listening, Watching, Competing, Custom` };
40
+ }
41
+ if (typeName === 'Custom') {
42
+ client.user.setActivity({ name: 'Custom Status', type: ActivityType.Custom, state: action.name });
43
+ }
44
+ else {
45
+ client.user.setActivity({ name: action.name, type: typeNum });
46
+ }
47
+ return { ok: true, summary: `Activity set to ${typeName}: ${action.name}` };
48
+ }
49
+ case 'botSetNickname': {
50
+ if (!action.nickname || typeof action.nickname !== 'string') {
51
+ return { ok: false, error: 'botSetNickname requires a non-empty "nickname" field' };
52
+ }
53
+ let me = guild.members.me;
54
+ if (!me) {
55
+ try {
56
+ me = await guild.members.fetchMe();
57
+ }
58
+ catch {
59
+ return { ok: false, error: 'Could not fetch bot member in this guild' };
60
+ }
61
+ }
62
+ // Skip if nickname already matches (avoid unnecessary API call).
63
+ if (me.nickname === action.nickname) {
64
+ return { ok: true, summary: `Nickname already set to "${action.nickname}"` };
65
+ }
66
+ // Skip if no nickname is set and the username already matches.
67
+ if (me.nickname == null && me.user?.username === action.nickname) {
68
+ return { ok: true, summary: `Nickname already set to "${action.nickname}"` };
69
+ }
70
+ try {
71
+ await me.setNickname(action.nickname, 'Runtime nickname change via bot profile action');
72
+ }
73
+ catch (err) {
74
+ if (err?.code === 50013) {
75
+ return { ok: false, error: 'Missing Permissions — cannot set nickname (check bot role permissions)' };
76
+ }
77
+ throw err;
78
+ }
79
+ return { ok: true, summary: `Nickname set to "${action.nickname}"` };
80
+ }
81
+ }
82
+ }
83
+ // ---------------------------------------------------------------------------
84
+ // Prompt section
85
+ // ---------------------------------------------------------------------------
86
+ export function botProfileActionsPromptSection() {
87
+ return `### Bot Profile
88
+
89
+ **botSetStatus** — Change the bot's online status:
90
+ \`\`\`
91
+ <discord-action>{"type":"botSetStatus","status":"idle"}</discord-action>
92
+ \`\`\`
93
+ - \`status\` (required): One of \`online\`, \`idle\`, \`dnd\`, \`invisible\`.
94
+
95
+ **botSetActivity** — Set the bot's activity text:
96
+ \`\`\`
97
+ <discord-action>{"type":"botSetActivity","name":"with tasks","activityType":"Playing"}</discord-action>
98
+ \`\`\`
99
+ - \`name\` (required): The activity text shown in the bot's presence.
100
+ - \`activityType\` (optional): One of \`Playing\` (default), \`Listening\`, \`Watching\`, \`Competing\`, \`Custom\`.
101
+
102
+ **botSetNickname** — Change the bot's nickname in the current server:
103
+ \`\`\`
104
+ <discord-action>{"type":"botSetNickname","nickname":"Weston"}</discord-action>
105
+ \`\`\`
106
+ - \`nickname\` (required): The new display name for this server.`;
107
+ }
@@ -0,0 +1,138 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { ActivityType } from 'discord.js';
3
+ import { executeBotProfileAction } from './actions-bot-profile.js';
4
+ // ---------------------------------------------------------------------------
5
+ // Helpers
6
+ // ---------------------------------------------------------------------------
7
+ function makeCtx(overrides = {}) {
8
+ const setStatus = vi.fn();
9
+ const setActivity = vi.fn();
10
+ const setNickname = vi.fn();
11
+ const me = {
12
+ nickname: overrides.meNickname ?? null,
13
+ user: { username: 'TestBot' },
14
+ setNickname,
15
+ };
16
+ return {
17
+ client: {
18
+ user: { setStatus, setActivity },
19
+ },
20
+ guild: {
21
+ id: 'guild-1',
22
+ members: {
23
+ me: overrides.meNull ? null : me,
24
+ fetchMe: vi.fn(async () => me),
25
+ },
26
+ },
27
+ channelId: 'ch-1',
28
+ messageId: 'msg-1',
29
+ };
30
+ }
31
+ // ---------------------------------------------------------------------------
32
+ // botSetStatus
33
+ // ---------------------------------------------------------------------------
34
+ describe('executeBotProfileAction — botSetStatus', () => {
35
+ it.each(['online', 'idle', 'dnd', 'invisible'])('sets status to %s', async (status) => {
36
+ const ctx = makeCtx();
37
+ const result = await executeBotProfileAction({ type: 'botSetStatus', status }, ctx);
38
+ expect(result).toEqual({ ok: true, summary: `Status set to ${status}` });
39
+ expect(ctx.client.user.setStatus).toHaveBeenCalledWith(status);
40
+ });
41
+ it('rejects invalid status', async () => {
42
+ const ctx = makeCtx();
43
+ const result = await executeBotProfileAction({ type: 'botSetStatus', status: 'away' }, ctx);
44
+ expect(result.ok).toBe(false);
45
+ expect(result.error).toContain('Invalid status');
46
+ });
47
+ });
48
+ // ---------------------------------------------------------------------------
49
+ // botSetActivity
50
+ // ---------------------------------------------------------------------------
51
+ describe('executeBotProfileAction — botSetActivity', () => {
52
+ it('defaults to Playing type', async () => {
53
+ const ctx = makeCtx();
54
+ const result = await executeBotProfileAction({ type: 'botSetActivity', name: 'with beads' }, ctx);
55
+ expect(result).toEqual({ ok: true, summary: 'Activity set to Playing: with beads' });
56
+ expect(ctx.client.user.setActivity).toHaveBeenCalledWith({ name: 'with beads', type: ActivityType.Playing });
57
+ });
58
+ it('sets explicit Listening type', async () => {
59
+ const ctx = makeCtx();
60
+ const result = await executeBotProfileAction({ type: 'botSetActivity', name: 'music', activityType: 'Listening' }, ctx);
61
+ expect(result).toEqual({ ok: true, summary: 'Activity set to Listening: music' });
62
+ expect(ctx.client.user.setActivity).toHaveBeenCalledWith({ name: 'music', type: ActivityType.Listening });
63
+ });
64
+ it('Custom type uses state field', async () => {
65
+ const ctx = makeCtx();
66
+ const result = await executeBotProfileAction({ type: 'botSetActivity', name: 'Thinking hard', activityType: 'Custom' }, ctx);
67
+ expect(result).toEqual({ ok: true, summary: 'Activity set to Custom: Thinking hard' });
68
+ expect(ctx.client.user.setActivity).toHaveBeenCalledWith({
69
+ name: 'Custom Status',
70
+ type: ActivityType.Custom,
71
+ state: 'Thinking hard',
72
+ });
73
+ });
74
+ it('rejects invalid activityType', async () => {
75
+ const ctx = makeCtx();
76
+ const result = await executeBotProfileAction({ type: 'botSetActivity', name: 'test', activityType: 'Streaming' }, ctx);
77
+ expect(result.ok).toBe(false);
78
+ expect(result.error).toContain('Invalid activityType');
79
+ });
80
+ it('rejects missing name', async () => {
81
+ const ctx = makeCtx();
82
+ const result = await executeBotProfileAction({ type: 'botSetActivity', name: '' }, ctx);
83
+ expect(result.ok).toBe(false);
84
+ expect(result.error).toContain('non-empty "name"');
85
+ });
86
+ });
87
+ // ---------------------------------------------------------------------------
88
+ // botSetNickname
89
+ // ---------------------------------------------------------------------------
90
+ describe('executeBotProfileAction — botSetNickname', () => {
91
+ it('sets nickname in current guild', async () => {
92
+ const ctx = makeCtx();
93
+ const result = await executeBotProfileAction({ type: 'botSetNickname', nickname: 'Weston' }, ctx);
94
+ expect(result).toEqual({ ok: true, summary: 'Nickname set to "Weston"' });
95
+ expect(ctx.guild.members.me.setNickname).toHaveBeenCalledWith('Weston', 'Runtime nickname change via bot profile action');
96
+ });
97
+ it('rejects missing nickname', async () => {
98
+ const ctx = makeCtx();
99
+ const result = await executeBotProfileAction({ type: 'botSetNickname', nickname: '' }, ctx);
100
+ expect(result.ok).toBe(false);
101
+ expect(result.error).toContain('non-empty "nickname"');
102
+ });
103
+ it('fetches me when members.me is null', async () => {
104
+ const ctx = makeCtx({ meNull: true });
105
+ const result = await executeBotProfileAction({ type: 'botSetNickname', nickname: 'Weston' }, ctx);
106
+ expect(result).toEqual({ ok: true, summary: 'Nickname set to "Weston"' });
107
+ expect(ctx.guild.members.fetchMe).toHaveBeenCalled();
108
+ });
109
+ it('skips API call when nickname already matches', async () => {
110
+ const ctx = makeCtx({ meNickname: 'Weston' });
111
+ const result = await executeBotProfileAction({ type: 'botSetNickname', nickname: 'Weston' }, ctx);
112
+ expect(result).toEqual({ ok: true, summary: 'Nickname already set to "Weston"' });
113
+ expect(ctx.guild.members.me.setNickname).not.toHaveBeenCalled();
114
+ });
115
+ it('skips API call when no nickname set and username matches', async () => {
116
+ // meNickname defaults to null; username is 'TestBot'
117
+ const ctx = makeCtx();
118
+ const result = await executeBotProfileAction({ type: 'botSetNickname', nickname: 'TestBot' }, ctx);
119
+ expect(result).toEqual({ ok: true, summary: 'Nickname already set to "TestBot"' });
120
+ expect(ctx.guild.members.me.setNickname).not.toHaveBeenCalled();
121
+ });
122
+ it('handles fetchMe failure gracefully', async () => {
123
+ const ctx = makeCtx({ meNull: true });
124
+ ctx.guild.members.fetchMe.mockRejectedValueOnce(new Error('fetch failed'));
125
+ const result = await executeBotProfileAction({ type: 'botSetNickname', nickname: 'Weston' }, ctx);
126
+ expect(result.ok).toBe(false);
127
+ expect(result.error).toContain('Could not fetch bot member');
128
+ });
129
+ it('handles permission error (50013) gracefully', async () => {
130
+ const ctx = makeCtx();
131
+ const permErr = new Error('Missing Permissions');
132
+ permErr.code = 50013;
133
+ ctx.guild.members.me.setNickname.mockRejectedValueOnce(permErr);
134
+ const result = await executeBotProfileAction({ type: 'botSetNickname', nickname: 'Weston' }, ctx);
135
+ expect(result.ok).toBe(false);
136
+ expect(result.error).toContain('Missing Permissions');
137
+ });
138
+ });