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,697 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { ChannelType } from 'discord.js';
3
+ import { executeChannelAction } from './actions-channels.js';
4
+ // ---------------------------------------------------------------------------
5
+ // Helpers
6
+ // ---------------------------------------------------------------------------
7
+ function makeMockGuild(channels) {
8
+ const cache = new Map();
9
+ for (const ch of channels) {
10
+ cache.set(ch.id, {
11
+ id: ch.id,
12
+ name: ch.name,
13
+ type: ch.type,
14
+ parent: ch.parentName ? { name: ch.parentName } : null,
15
+ topic: ch.topic ?? null,
16
+ createdAt: ch.createdAt ?? null,
17
+ edit: vi.fn(async () => { }),
18
+ delete: vi.fn(async () => { }),
19
+ setParent: vi.fn(async () => { }),
20
+ setPosition: vi.fn(async () => { }),
21
+ });
22
+ }
23
+ return {
24
+ channels: {
25
+ cache: {
26
+ get: (id) => cache.get(id),
27
+ find: (fn) => {
28
+ for (const ch of cache.values()) {
29
+ if (fn(ch))
30
+ return ch;
31
+ }
32
+ return undefined;
33
+ },
34
+ values: () => cache.values(),
35
+ get size() { return cache.size; },
36
+ },
37
+ create: vi.fn(async (opts) => ({
38
+ name: opts.name,
39
+ id: 'new-id',
40
+ })),
41
+ },
42
+ };
43
+ }
44
+ function makeCtx(guild) {
45
+ return {
46
+ guild,
47
+ client: {},
48
+ channelId: 'test-channel',
49
+ messageId: 'test-message',
50
+ };
51
+ }
52
+ // ---------------------------------------------------------------------------
53
+ // channelList
54
+ // ---------------------------------------------------------------------------
55
+ describe('channelList', () => {
56
+ it('channels grouped by category include IDs', async () => {
57
+ const guild = makeMockGuild([
58
+ { id: 'cat1', name: 'Dev', type: ChannelType.GuildCategory },
59
+ { id: 'ch1', name: 'general', type: ChannelType.GuildText, parentName: 'Dev' },
60
+ ]);
61
+ const ctx = makeCtx(guild);
62
+ const result = await executeChannelAction({ type: 'channelList' }, ctx);
63
+ expect(result.ok).toBe(true);
64
+ expect(result.summary).toContain('#general (id:ch1)');
65
+ });
66
+ it('uncategorized channels include IDs', async () => {
67
+ const guild = makeMockGuild([
68
+ { id: 'ch1', name: 'random', type: ChannelType.GuildText },
69
+ ]);
70
+ const ctx = makeCtx(guild);
71
+ const result = await executeChannelAction({ type: 'channelList' }, ctx);
72
+ expect(result.ok).toBe(true);
73
+ expect(result.summary).toContain('#random (id:ch1)');
74
+ });
75
+ it('categories themselves excluded from output', async () => {
76
+ const guild = makeMockGuild([
77
+ { id: 'cat1', name: 'Dev', type: ChannelType.GuildCategory },
78
+ { id: 'ch1', name: 'general', type: ChannelType.GuildText, parentName: 'Dev' },
79
+ ]);
80
+ const ctx = makeCtx(guild);
81
+ const result = await executeChannelAction({ type: 'channelList' }, ctx);
82
+ expect(result.ok).toBe(true);
83
+ const summary = result.summary;
84
+ // Category name appears as a grouping label, not as a channel entry
85
+ expect(summary).not.toContain('#Dev');
86
+ expect(summary).not.toContain('(id:cat1)');
87
+ });
88
+ it('empty server returns (no channels)', async () => {
89
+ const guild = makeMockGuild([]);
90
+ const ctx = makeCtx(guild);
91
+ const result = await executeChannelAction({ type: 'channelList' }, ctx);
92
+ expect(result).toEqual({ ok: true, summary: '(no channels)' });
93
+ });
94
+ });
95
+ // ---------------------------------------------------------------------------
96
+ // channelEdit
97
+ // ---------------------------------------------------------------------------
98
+ describe('channelEdit', () => {
99
+ it('edits channel name and topic', async () => {
100
+ const guild = makeMockGuild([
101
+ { id: 'ch1', name: 'general', type: ChannelType.GuildText },
102
+ ]);
103
+ const ctx = makeCtx(guild);
104
+ const result = await executeChannelAction({ type: 'channelEdit', channelId: 'ch1', name: 'renamed', topic: 'New topic' }, ctx);
105
+ expect(result).toEqual({ ok: true, summary: 'Edited #general: name → renamed, topic updated' });
106
+ const ch = guild.channels.cache.get('ch1');
107
+ expect(ch.edit).toHaveBeenCalledWith({ name: 'renamed', topic: 'New topic' });
108
+ });
109
+ it('edits only the name', async () => {
110
+ const guild = makeMockGuild([
111
+ { id: 'ch1', name: 'general', type: ChannelType.GuildText },
112
+ ]);
113
+ const ctx = makeCtx(guild);
114
+ const result = await executeChannelAction({ type: 'channelEdit', channelId: 'ch1', name: 'renamed' }, ctx);
115
+ expect(result.ok).toBe(true);
116
+ const ch = guild.channels.cache.get('ch1');
117
+ expect(ch.edit).toHaveBeenCalledWith({ name: 'renamed' });
118
+ });
119
+ it('fails when channel not found', async () => {
120
+ const guild = makeMockGuild([]);
121
+ const ctx = makeCtx(guild);
122
+ const result = await executeChannelAction({ type: 'channelEdit', channelId: 'nope', name: 'x' }, ctx);
123
+ expect(result).toEqual({ ok: false, error: 'Channel "nope" not found' });
124
+ });
125
+ it('fails when no fields provided', async () => {
126
+ const guild = makeMockGuild([
127
+ { id: 'ch1', name: 'general', type: ChannelType.GuildText },
128
+ ]);
129
+ const ctx = makeCtx(guild);
130
+ const result = await executeChannelAction({ type: 'channelEdit', channelId: 'ch1' }, ctx);
131
+ expect(result).toEqual({ ok: false, error: 'channelEdit requires at least one of name or topic' });
132
+ });
133
+ });
134
+ // ---------------------------------------------------------------------------
135
+ // channelDelete
136
+ // ---------------------------------------------------------------------------
137
+ describe('channelDelete', () => {
138
+ it('deletes a channel', async () => {
139
+ const guild = makeMockGuild([
140
+ { id: 'ch1', name: 'to-delete', type: ChannelType.GuildText },
141
+ ]);
142
+ const ctx = makeCtx(guild);
143
+ const result = await executeChannelAction({ type: 'channelDelete', channelId: 'ch1' }, ctx);
144
+ expect(result).toEqual({ ok: true, summary: 'Deleted #to-delete' });
145
+ const ch = guild.channels.cache.get('ch1');
146
+ expect(ch.delete).toHaveBeenCalled();
147
+ });
148
+ it('fails when channel not found', async () => {
149
+ const guild = makeMockGuild([]);
150
+ const ctx = makeCtx(guild);
151
+ const result = await executeChannelAction({ type: 'channelDelete', channelId: 'nope' }, ctx);
152
+ expect(result).toEqual({ ok: false, error: 'Channel "nope" not found' });
153
+ });
154
+ });
155
+ // ---------------------------------------------------------------------------
156
+ // channelCreate types
157
+ // ---------------------------------------------------------------------------
158
+ describe('channelCreate', () => {
159
+ it('creates a text channel by default', async () => {
160
+ const guild = makeMockGuild([]);
161
+ const ctx = makeCtx(guild);
162
+ const result = await executeChannelAction({ type: 'channelCreate', name: 'general' }, ctx);
163
+ expect(result.ok).toBe(true);
164
+ expect(guild.channels.create).toHaveBeenCalledWith(expect.objectContaining({ type: ChannelType.GuildText }));
165
+ });
166
+ it('creates a voice channel', async () => {
167
+ const guild = makeMockGuild([]);
168
+ const ctx = makeCtx(guild);
169
+ const result = await executeChannelAction({ type: 'channelCreate', name: 'voice-chat', channelType: 'voice' }, ctx);
170
+ expect(result.ok).toBe(true);
171
+ expect(guild.channels.create).toHaveBeenCalledWith(expect.objectContaining({ type: ChannelType.GuildVoice }));
172
+ });
173
+ it('creates an announcement channel', async () => {
174
+ const guild = makeMockGuild([]);
175
+ const ctx = makeCtx(guild);
176
+ const result = await executeChannelAction({ type: 'channelCreate', name: 'news', channelType: 'announcement' }, ctx);
177
+ expect(result.ok).toBe(true);
178
+ expect(guild.channels.create).toHaveBeenCalledWith(expect.objectContaining({ type: ChannelType.GuildAnnouncement }));
179
+ });
180
+ it('creates a stage channel', async () => {
181
+ const guild = makeMockGuild([]);
182
+ const ctx = makeCtx(guild);
183
+ const result = await executeChannelAction({ type: 'channelCreate', name: 'stage-talk', channelType: 'stage' }, ctx);
184
+ expect(result.ok).toBe(true);
185
+ expect(guild.channels.create).toHaveBeenCalledWith(expect.objectContaining({ type: ChannelType.GuildStageVoice }));
186
+ });
187
+ it('creates under a parent category', async () => {
188
+ const guild = makeMockGuild([
189
+ { id: 'cat1', name: 'Dev', type: ChannelType.GuildCategory },
190
+ ]);
191
+ const ctx = makeCtx(guild);
192
+ const result = await executeChannelAction({ type: 'channelCreate', name: 'dev-chat', parent: 'Dev' }, ctx);
193
+ expect(result.ok).toBe(true);
194
+ expect(result.summary).toContain('under Dev');
195
+ expect(guild.channels.create).toHaveBeenCalledWith(expect.objectContaining({ parent: 'cat1' }));
196
+ });
197
+ it('fails when parent category not found', async () => {
198
+ const guild = makeMockGuild([]);
199
+ const ctx = makeCtx(guild);
200
+ const result = await executeChannelAction({ type: 'channelCreate', name: 'test', parent: 'NonExistent' }, ctx);
201
+ expect(result).toEqual({ ok: false, error: 'Category "NonExistent" not found' });
202
+ });
203
+ });
204
+ // ---------------------------------------------------------------------------
205
+ // channelInfo
206
+ // ---------------------------------------------------------------------------
207
+ describe('channelInfo', () => {
208
+ it('returns channel details', async () => {
209
+ const guild = makeMockGuild([
210
+ { id: 'ch1', name: 'general', type: ChannelType.GuildText, parentName: 'Text', topic: 'Main channel' },
211
+ ]);
212
+ const ctx = makeCtx(guild);
213
+ const result = await executeChannelAction({ type: 'channelInfo', channelId: 'ch1' }, ctx);
214
+ expect(result.ok).toBe(true);
215
+ const summary = result.summary;
216
+ expect(summary).toContain('Name: #general');
217
+ expect(summary).toContain('ID: ch1');
218
+ expect(summary).toContain('Category: Text');
219
+ expect(summary).toContain('Topic: Main channel');
220
+ });
221
+ it('fails when channel not found', async () => {
222
+ const guild = makeMockGuild([]);
223
+ const ctx = makeCtx(guild);
224
+ const result = await executeChannelAction({ type: 'channelInfo', channelId: 'nope' }, ctx);
225
+ expect(result).toEqual({ ok: false, error: 'Channel "nope" not found' });
226
+ });
227
+ });
228
+ // ---------------------------------------------------------------------------
229
+ // categoryCreate
230
+ // ---------------------------------------------------------------------------
231
+ // ---------------------------------------------------------------------------
232
+ // channelMove
233
+ // ---------------------------------------------------------------------------
234
+ describe('channelMove', () => {
235
+ it('moves channel to a category by name', async () => {
236
+ const guild = makeMockGuild([
237
+ { id: 'cat1', name: 'Projects', type: ChannelType.GuildCategory },
238
+ { id: 'ch1', name: 'general', type: ChannelType.GuildText },
239
+ ]);
240
+ const ctx = makeCtx(guild);
241
+ const result = await executeChannelAction({ type: 'channelMove', channelId: 'ch1', parent: 'Projects' }, ctx);
242
+ expect(result).toEqual({ ok: true, summary: 'Moved #general: moved to Projects' });
243
+ const ch = guild.channels.cache.get('ch1');
244
+ expect(ch.setParent).toHaveBeenCalledWith('cat1');
245
+ });
246
+ it('moves channel to a category by ID', async () => {
247
+ const guild = makeMockGuild([
248
+ { id: 'cat1', name: 'Projects', type: ChannelType.GuildCategory },
249
+ { id: 'ch1', name: 'general', type: ChannelType.GuildText },
250
+ ]);
251
+ const ctx = makeCtx(guild);
252
+ const result = await executeChannelAction({ type: 'channelMove', channelId: 'ch1', parent: 'cat1' }, ctx);
253
+ expect(result.ok).toBe(true);
254
+ const ch = guild.channels.cache.get('ch1');
255
+ expect(ch.setParent).toHaveBeenCalledWith('cat1');
256
+ });
257
+ it('removes channel from category with empty string', async () => {
258
+ const guild = makeMockGuild([
259
+ { id: 'ch1', name: 'general', type: ChannelType.GuildText, parentName: 'Old' },
260
+ ]);
261
+ const ctx = makeCtx(guild);
262
+ const result = await executeChannelAction({ type: 'channelMove', channelId: 'ch1', parent: '' }, ctx);
263
+ expect(result).toEqual({ ok: true, summary: 'Moved #general: removed from category' });
264
+ const ch = guild.channels.cache.get('ch1');
265
+ expect(ch.setParent).toHaveBeenCalledWith(null);
266
+ });
267
+ it('sets channel position', async () => {
268
+ const guild = makeMockGuild([
269
+ { id: 'ch1', name: 'general', type: ChannelType.GuildText },
270
+ ]);
271
+ const ctx = makeCtx(guild);
272
+ const result = await executeChannelAction({ type: 'channelMove', channelId: 'ch1', position: 3 }, ctx);
273
+ expect(result).toEqual({ ok: true, summary: 'Moved #general: position → 3' });
274
+ const ch = guild.channels.cache.get('ch1');
275
+ expect(ch.setPosition).toHaveBeenCalledWith(3);
276
+ });
277
+ it('moves and repositions in one call', async () => {
278
+ const guild = makeMockGuild([
279
+ { id: 'cat1', name: 'Dev', type: ChannelType.GuildCategory },
280
+ { id: 'ch1', name: 'general', type: ChannelType.GuildText },
281
+ ]);
282
+ const ctx = makeCtx(guild);
283
+ const result = await executeChannelAction({ type: 'channelMove', channelId: 'ch1', parent: 'Dev', position: 0 }, ctx);
284
+ expect(result).toEqual({ ok: true, summary: 'Moved #general: moved to Dev, position → 0' });
285
+ const ch = guild.channels.cache.get('ch1');
286
+ expect(ch.setParent).toHaveBeenCalledWith('cat1');
287
+ expect(ch.setPosition).toHaveBeenCalledWith(0);
288
+ });
289
+ it('fails when neither parent nor position given', async () => {
290
+ const guild = makeMockGuild([
291
+ { id: 'ch1', name: 'general', type: ChannelType.GuildText },
292
+ ]);
293
+ const ctx = makeCtx(guild);
294
+ const result = await executeChannelAction({ type: 'channelMove', channelId: 'ch1' }, ctx);
295
+ expect(result).toEqual({ ok: false, error: 'channelMove requires at least one of parent or position' });
296
+ });
297
+ it('fails when channel not found', async () => {
298
+ const guild = makeMockGuild([]);
299
+ const ctx = makeCtx(guild);
300
+ const result = await executeChannelAction({ type: 'channelMove', channelId: 'nope', parent: 'Dev' }, ctx);
301
+ expect(result).toEqual({ ok: false, error: 'Channel "nope" not found' });
302
+ });
303
+ it('fails when category not found', async () => {
304
+ const guild = makeMockGuild([
305
+ { id: 'ch1', name: 'general', type: ChannelType.GuildText },
306
+ ]);
307
+ const ctx = makeCtx(guild);
308
+ const result = await executeChannelAction({ type: 'channelMove', channelId: 'ch1', parent: 'NonExistent' }, ctx);
309
+ expect(result).toEqual({ ok: false, error: 'Category "NonExistent" not found' });
310
+ });
311
+ it('resolves category name case-insensitively', async () => {
312
+ const guild = makeMockGuild([
313
+ { id: 'cat1', name: 'Projects', type: ChannelType.GuildCategory },
314
+ { id: 'ch1', name: 'general', type: ChannelType.GuildText },
315
+ ]);
316
+ const ctx = makeCtx(guild);
317
+ const result = await executeChannelAction({ type: 'channelMove', channelId: 'ch1', parent: 'projects' }, ctx);
318
+ expect(result.ok).toBe(true);
319
+ const ch = guild.channels.cache.get('ch1');
320
+ expect(ch.setParent).toHaveBeenCalledWith('cat1');
321
+ });
322
+ });
323
+ // ---------------------------------------------------------------------------
324
+ // threadListArchived
325
+ // ---------------------------------------------------------------------------
326
+ describe('threadListArchived', () => {
327
+ function makeMockForumGuild(threads) {
328
+ const threadMap = new Map();
329
+ for (const t of threads) {
330
+ threadMap.set(t.id, { id: t.id, name: t.name });
331
+ }
332
+ const forumChannel = {
333
+ id: 'forum1',
334
+ name: 'beads',
335
+ type: ChannelType.GuildForum,
336
+ parent: null,
337
+ threads: {
338
+ fetchArchived: vi.fn(async () => ({ threads: threadMap })),
339
+ },
340
+ };
341
+ const cache = new Map();
342
+ cache.set('forum1', forumChannel);
343
+ return {
344
+ guild: {
345
+ channels: {
346
+ cache: {
347
+ get: (id) => cache.get(id),
348
+ find: (fn) => {
349
+ for (const ch of cache.values()) {
350
+ if (fn(ch))
351
+ return ch;
352
+ }
353
+ return undefined;
354
+ },
355
+ values: () => cache.values(),
356
+ get size() { return cache.size; },
357
+ },
358
+ create: vi.fn(async (opts) => ({ name: opts.name, id: 'new-id' })),
359
+ },
360
+ },
361
+ forumChannel,
362
+ };
363
+ }
364
+ it('lists archived threads in a forum channel', async () => {
365
+ const { guild } = makeMockForumGuild([
366
+ { id: 't1', name: 'Thread Alpha' },
367
+ { id: 't2', name: 'Thread Beta' },
368
+ ]);
369
+ const ctx = makeCtx(guild);
370
+ const result = await executeChannelAction({ type: 'threadListArchived', channelId: 'forum1' }, ctx);
371
+ expect(result.ok).toBe(true);
372
+ const summary = result.summary;
373
+ expect(summary).toContain('Archived threads in #beads (2)');
374
+ expect(summary).toContain('• Thread Alpha (id:t1)');
375
+ expect(summary).toContain('• Thread Beta (id:t2)');
376
+ });
377
+ it('returns message when no archived threads', async () => {
378
+ const { guild } = makeMockForumGuild([]);
379
+ const ctx = makeCtx(guild);
380
+ const result = await executeChannelAction({ type: 'threadListArchived', channelId: 'forum1' }, ctx);
381
+ expect(result).toEqual({ ok: true, summary: 'No archived threads in #beads' });
382
+ });
383
+ it('passes limit to fetchArchived', async () => {
384
+ const { guild, forumChannel } = makeMockForumGuild([]);
385
+ const ctx = makeCtx(guild);
386
+ await executeChannelAction({ type: 'threadListArchived', channelId: 'forum1', limit: 10 }, ctx);
387
+ expect(forumChannel.threads.fetchArchived).toHaveBeenCalledWith({ limit: 10, fetchAll: true });
388
+ });
389
+ it('fails when channel not found', async () => {
390
+ const { guild } = makeMockForumGuild([]);
391
+ const ctx = makeCtx(guild);
392
+ const result = await executeChannelAction({ type: 'threadListArchived', channelId: 'nope' }, ctx);
393
+ expect(result).toEqual({ ok: false, error: 'Channel "nope" not found' });
394
+ });
395
+ it('fails for non-forum/text channel', async () => {
396
+ const guild = makeMockGuild([
397
+ { id: 'voice1', name: 'voice-chat', type: ChannelType.GuildVoice },
398
+ ]);
399
+ const ctx = makeCtx(guild);
400
+ const result = await executeChannelAction({ type: 'threadListArchived', channelId: 'voice1' }, ctx);
401
+ expect(result).toEqual({ ok: false, error: 'Channel #voice-chat is not a forum or text channel' });
402
+ });
403
+ });
404
+ // ---------------------------------------------------------------------------
405
+ // categoryCreate
406
+ // ---------------------------------------------------------------------------
407
+ describe('categoryCreate', () => {
408
+ it('creates a category', async () => {
409
+ const guild = makeMockGuild([]);
410
+ const ctx = makeCtx(guild);
411
+ const result = await executeChannelAction({ type: 'categoryCreate', name: 'Projects' }, ctx);
412
+ expect(result).toEqual({ ok: true, summary: 'Created category "Projects"' });
413
+ expect(guild.channels.create).toHaveBeenCalledWith({
414
+ name: 'Projects',
415
+ type: ChannelType.GuildCategory,
416
+ position: undefined,
417
+ });
418
+ });
419
+ });
420
+ // ---------------------------------------------------------------------------
421
+ // threadEdit
422
+ // ---------------------------------------------------------------------------
423
+ describe('threadEdit', () => {
424
+ function makeThreadCtx(opts) {
425
+ const thread = {
426
+ id: opts.threadId,
427
+ name: opts.threadName,
428
+ guildId: opts.guildId,
429
+ isThread: () => true,
430
+ parent: { type: opts.parentType },
431
+ appliedTags: opts.appliedTags ?? [],
432
+ edit: vi.fn(async () => { }),
433
+ };
434
+ const channelsCache = new Map();
435
+ const client = {
436
+ channels: {
437
+ cache: {
438
+ get: (id) => (opts.inCache !== false && id === opts.threadId ? thread : undefined),
439
+ },
440
+ fetch: vi.fn(async (id) => {
441
+ if (id === opts.threadId)
442
+ return thread;
443
+ throw new Error('Unknown channel');
444
+ }),
445
+ },
446
+ };
447
+ const guild = makeMockGuild([]);
448
+ guild.id = opts.guildId;
449
+ return {
450
+ thread,
451
+ ctx: { ...makeCtx(guild), client },
452
+ };
453
+ }
454
+ it('edits appliedTags on a forum thread (cache hit)', async () => {
455
+ const { thread, ctx } = makeThreadCtx({
456
+ threadId: 't1', threadName: 'My Thread', guildId: 'g1',
457
+ parentType: ChannelType.GuildForum, inCache: true,
458
+ });
459
+ const result = await executeChannelAction({ type: 'threadEdit', threadId: 't1', appliedTags: ['tag1', 'tag2'] }, ctx);
460
+ expect(result.ok).toBe(true);
461
+ expect(result.summary).toContain('appliedTags → [tag1, tag2]');
462
+ expect(thread.edit).toHaveBeenCalledWith({ appliedTags: ['tag1', 'tag2'] });
463
+ });
464
+ it('edits thread name only', async () => {
465
+ const { thread, ctx } = makeThreadCtx({
466
+ threadId: 't1', threadName: 'Old Name', guildId: 'g1',
467
+ parentType: ChannelType.GuildForum, inCache: true,
468
+ });
469
+ const result = await executeChannelAction({ type: 'threadEdit', threadId: 't1', name: 'New Name' }, ctx);
470
+ expect(result.ok).toBe(true);
471
+ expect(result.summary).toContain('name → New Name');
472
+ expect(thread.edit).toHaveBeenCalledWith({ name: 'New Name' });
473
+ });
474
+ it('edits both appliedTags and name', async () => {
475
+ const { thread, ctx } = makeThreadCtx({
476
+ threadId: 't1', threadName: 'Old Name', guildId: 'g1',
477
+ parentType: ChannelType.GuildForum, inCache: true,
478
+ });
479
+ const result = await executeChannelAction({ type: 'threadEdit', threadId: 't1', appliedTags: ['tag1'], name: 'New Name' }, ctx);
480
+ expect(result.ok).toBe(true);
481
+ expect(thread.edit).toHaveBeenCalledWith({ appliedTags: ['tag1'], name: 'New Name' });
482
+ });
483
+ it('fetches thread from API when not in cache', async () => {
484
+ const { thread, ctx } = makeThreadCtx({
485
+ threadId: 't1', threadName: 'My Thread', guildId: 'g1',
486
+ parentType: ChannelType.GuildForum, inCache: false,
487
+ });
488
+ const result = await executeChannelAction({ type: 'threadEdit', threadId: 't1', appliedTags: ['tag1'] }, ctx);
489
+ expect(result.ok).toBe(true);
490
+ expect(ctx.client.channels.fetch).toHaveBeenCalledWith('t1');
491
+ expect(thread.edit).toHaveBeenCalled();
492
+ });
493
+ it('fails when thread not found', async () => {
494
+ const guild = makeMockGuild([]);
495
+ guild.id = 'g1';
496
+ const client = {
497
+ channels: {
498
+ cache: { get: () => undefined },
499
+ fetch: vi.fn(async () => { throw new Error('Unknown'); }),
500
+ },
501
+ };
502
+ const ctx = { ...makeCtx(guild), client };
503
+ const result = await executeChannelAction({ type: 'threadEdit', threadId: 'missing', appliedTags: ['tag1'] }, ctx);
504
+ expect(result).toEqual({ ok: false, error: 'Thread "missing" not found' });
505
+ });
506
+ it('fails when thread belongs to a different guild', async () => {
507
+ const { ctx } = makeThreadCtx({
508
+ threadId: 't1', threadName: 'My Thread', guildId: 'other-guild',
509
+ parentType: ChannelType.GuildForum, inCache: true,
510
+ });
511
+ // ctx.guild.id is set by makeCtx which uses makeMockGuild — override it
512
+ ctx.guild.id = 'this-guild';
513
+ const result = await executeChannelAction({ type: 'threadEdit', threadId: 't1', appliedTags: ['tag1'] }, ctx);
514
+ expect(result).toEqual({ ok: false, error: 'Thread "t1" does not belong to this guild' });
515
+ });
516
+ it('rejects appliedTags when parent is not a forum channel', async () => {
517
+ const { ctx } = makeThreadCtx({
518
+ threadId: 't1', threadName: 'My Thread', guildId: 'g1',
519
+ parentType: ChannelType.GuildText, inCache: true,
520
+ });
521
+ const result = await executeChannelAction({ type: 'threadEdit', threadId: 't1', appliedTags: ['tag1'] }, ctx);
522
+ expect(result).toEqual({
523
+ ok: false,
524
+ error: 'Thread "t1" is not in a forum channel — appliedTags only applies to forum threads',
525
+ });
526
+ });
527
+ it('rejects appliedTags exceeding 5', async () => {
528
+ const { ctx } = makeThreadCtx({
529
+ threadId: 't1', threadName: 'My Thread', guildId: 'g1',
530
+ parentType: ChannelType.GuildForum, inCache: true,
531
+ });
532
+ const result = await executeChannelAction({ type: 'threadEdit', threadId: 't1', appliedTags: ['a', 'b', 'c', 'd', 'e', 'f'] }, ctx);
533
+ expect(result).toEqual({ ok: false, error: 'appliedTags exceeds Discord maximum of 5 (got 6)' });
534
+ });
535
+ it('fails when neither appliedTags nor name provided', async () => {
536
+ const guild = makeMockGuild([]);
537
+ const ctx = makeCtx(guild);
538
+ const result = await executeChannelAction({ type: 'threadEdit', threadId: 't1' }, ctx);
539
+ expect(result).toEqual({ ok: false, error: 'threadEdit requires at least one of appliedTags or name' });
540
+ });
541
+ it('finds archived thread via forum-channel fallback', async () => {
542
+ const thread = {
543
+ id: 't1',
544
+ name: 'Old Name',
545
+ guildId: 'g1',
546
+ archived: true,
547
+ isThread: () => true,
548
+ parent: { type: ChannelType.GuildForum },
549
+ appliedTags: [],
550
+ edit: vi.fn(async () => { }),
551
+ setArchived: vi.fn(async () => { }),
552
+ };
553
+ const forumChannel = {
554
+ id: 'forum1',
555
+ name: 'tasks',
556
+ type: ChannelType.GuildForum,
557
+ threads: {
558
+ fetchActive: vi.fn(async () => ({ threads: new Map() })),
559
+ fetchArchived: vi.fn(async () => ({
560
+ threads: new Map([['t1', thread]]),
561
+ })),
562
+ },
563
+ };
564
+ const guildChannelsCache = new Map([['forum1', forumChannel]]);
565
+ const guild = {
566
+ id: 'g1',
567
+ channels: {
568
+ cache: {
569
+ get: (id) => guildChannelsCache.get(id),
570
+ find: () => undefined,
571
+ values: () => guildChannelsCache.values(),
572
+ size: guildChannelsCache.size,
573
+ },
574
+ create: vi.fn(),
575
+ },
576
+ };
577
+ const client = {
578
+ channels: {
579
+ cache: { get: () => undefined },
580
+ fetch: vi.fn(async () => { throw new Error('Unknown'); }),
581
+ },
582
+ };
583
+ const ctx = { ...makeCtx(guild), client };
584
+ const result = await executeChannelAction({ type: 'threadEdit', threadId: 't1', name: 'New Name' }, ctx);
585
+ expect(result.ok).toBe(true);
586
+ expect(thread.setArchived).toHaveBeenCalledWith(false);
587
+ expect(thread.edit).toHaveBeenCalledWith({ name: 'New Name' });
588
+ expect(thread.setArchived).toHaveBeenCalledWith(true);
589
+ // Archived checked before active (the common case for this fallback).
590
+ expect(forumChannel.threads.fetchArchived).toHaveBeenCalled();
591
+ });
592
+ it('finds active thread via forum-channel fallback', async () => {
593
+ const thread = {
594
+ id: 't1',
595
+ name: 'Old Name',
596
+ guildId: 'g1',
597
+ archived: false,
598
+ isThread: () => true,
599
+ parent: { type: ChannelType.GuildForum },
600
+ appliedTags: [],
601
+ edit: vi.fn(async () => { }),
602
+ setArchived: vi.fn(async () => { }),
603
+ };
604
+ const forumChannel = {
605
+ id: 'forum1',
606
+ name: 'tasks',
607
+ type: ChannelType.GuildForum,
608
+ threads: {
609
+ fetchActive: vi.fn(async () => ({
610
+ threads: new Map([['t1', thread]]),
611
+ })),
612
+ fetchArchived: vi.fn(async () => ({ threads: new Map() })),
613
+ },
614
+ };
615
+ const guildChannelsCache = new Map([['forum1', forumChannel]]);
616
+ const guild = {
617
+ id: 'g1',
618
+ channels: {
619
+ cache: {
620
+ get: (id) => guildChannelsCache.get(id),
621
+ find: () => undefined,
622
+ values: () => guildChannelsCache.values(),
623
+ size: guildChannelsCache.size,
624
+ },
625
+ create: vi.fn(),
626
+ },
627
+ };
628
+ const client = {
629
+ channels: {
630
+ cache: { get: () => undefined },
631
+ fetch: vi.fn(async () => { throw new Error('Unknown'); }),
632
+ },
633
+ };
634
+ const ctx = { ...makeCtx(guild), client };
635
+ const result = await executeChannelAction({ type: 'threadEdit', threadId: 't1', name: 'New Name' }, ctx);
636
+ expect(result.ok).toBe(true);
637
+ expect(thread.edit).toHaveBeenCalledWith({ name: 'New Name' });
638
+ // Should NOT have called setArchived since the thread was active
639
+ expect(thread.setArchived).not.toHaveBeenCalled();
640
+ // fetchArchived checked first (archived is the common case), then active
641
+ expect(forumChannel.threads.fetchArchived).toHaveBeenCalled();
642
+ expect(forumChannel.threads.fetchActive).toHaveBeenCalled();
643
+ });
644
+ it('unarchives before editing and re-archives after', async () => {
645
+ const { thread, ctx } = makeThreadCtx({
646
+ threadId: 't1', threadName: 'Old Name', guildId: 'g1',
647
+ parentType: ChannelType.GuildForum, inCache: true,
648
+ });
649
+ thread.archived = true;
650
+ thread.setArchived = vi.fn(async () => { });
651
+ const result = await executeChannelAction({ type: 'threadEdit', threadId: 't1', name: 'New Name' }, ctx);
652
+ expect(result.ok).toBe(true);
653
+ expect(thread.setArchived).toHaveBeenCalledWith(false);
654
+ expect(thread.edit).toHaveBeenCalledWith({ name: 'New Name' });
655
+ expect(thread.setArchived).toHaveBeenCalledWith(true);
656
+ });
657
+ it('does not re-archive a non-archived thread after editing', async () => {
658
+ const { thread, ctx } = makeThreadCtx({
659
+ threadId: 't1', threadName: 'Old Name', guildId: 'g1',
660
+ parentType: ChannelType.GuildForum, inCache: true,
661
+ });
662
+ thread.archived = false;
663
+ thread.setArchived = vi.fn(async () => { });
664
+ const result = await executeChannelAction({ type: 'threadEdit', threadId: 't1', name: 'New Name' }, ctx);
665
+ expect(result.ok).toBe(true);
666
+ expect(thread.edit).toHaveBeenCalledWith({ name: 'New Name' });
667
+ expect(thread.setArchived).not.toHaveBeenCalled();
668
+ });
669
+ it('still edits when setArchived(false) throws', async () => {
670
+ const { thread, ctx } = makeThreadCtx({
671
+ threadId: 't1', threadName: 'Old Name', guildId: 'g1',
672
+ parentType: ChannelType.GuildForum, inCache: true,
673
+ });
674
+ thread.archived = true;
675
+ thread.setArchived = vi.fn(async (v) => {
676
+ if (!v)
677
+ throw new Error('MANAGE_THREADS required');
678
+ });
679
+ const result = await executeChannelAction({ type: 'threadEdit', threadId: 't1', name: 'New Name' }, ctx);
680
+ expect(result.ok).toBe(true);
681
+ expect(thread.edit).toHaveBeenCalledWith({ name: 'New Name' });
682
+ });
683
+ it('reports warning when re-archive fails', async () => {
684
+ const { thread, ctx } = makeThreadCtx({
685
+ threadId: 't1', threadName: 'Old Name', guildId: 'g1',
686
+ parentType: ChannelType.GuildForum, inCache: true,
687
+ });
688
+ thread.archived = true;
689
+ thread.setArchived = vi.fn(async (v) => {
690
+ if (v)
691
+ throw new Error('rate limited');
692
+ });
693
+ const result = await executeChannelAction({ type: 'threadEdit', threadId: 't1', name: 'New Name' }, ctx);
694
+ expect(result.ok).toBe(true);
695
+ expect(result.summary).toContain('warning: failed to re-archive');
696
+ });
697
+ });