@meetquinn/animactl 0.1.2 → 0.1.3

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 (289) hide show
  1. package/README.md +11 -6
  2. package/dist/server/{runtime/activity-payload.d.ts → activities/format.d.ts} +4 -1
  3. package/dist/server/activities/format.d.ts.map +1 -0
  4. package/dist/server/{runtime/activity-payload.js → activities/format.js} +16 -1
  5. package/dist/server/agents/agent-skills.d.ts +7 -3
  6. package/dist/server/agents/agent-skills.d.ts.map +1 -1
  7. package/dist/server/agents/agent-skills.js +243 -38
  8. package/dist/server/agents/agent-slack.service.d.ts +15 -0
  9. package/dist/server/agents/agent-slack.service.d.ts.map +1 -1
  10. package/dist/server/agents/agent-slack.service.js +43 -6
  11. package/dist/server/agents/default-skills.d.ts +9 -0
  12. package/dist/server/agents/default-skills.d.ts.map +1 -0
  13. package/dist/server/agents/default-skills.js +52 -0
  14. package/dist/server/cli/runtime-cli.d.ts +7 -1
  15. package/dist/server/cli/runtime-cli.d.ts.map +1 -1
  16. package/dist/server/cli/runtime-cli.js +107 -3
  17. package/dist/server/cli/services-cli.d.ts.map +1 -1
  18. package/dist/server/cli/services-cli.js +39 -0
  19. package/dist/server/inbox/slack-subscriber.d.ts +2 -0
  20. package/dist/server/inbox/slack-subscriber.d.ts.map +1 -1
  21. package/dist/server/inbox/slack-subscriber.js +28 -2
  22. package/dist/server/inbox/slack-subscription.service.d.ts.map +1 -1
  23. package/dist/server/inbox/slack-subscription.service.js +13 -1
  24. package/dist/server/inbox/wake-queue.service.d.ts +3 -1
  25. package/dist/server/inbox/wake-queue.service.d.ts.map +1 -1
  26. package/dist/server/inbox/wake-queue.service.js +5 -3
  27. package/dist/server/provider-usage/http.d.ts +1 -0
  28. package/dist/server/provider-usage/http.d.ts.map +1 -1
  29. package/dist/server/provider-usage/http.js +23 -1
  30. package/dist/server/provider-usage/provider-usage.service.d.ts +1 -4
  31. package/dist/server/provider-usage/provider-usage.service.d.ts.map +1 -1
  32. package/dist/server/provider-usage/provider-usage.service.js +1 -18
  33. package/dist/server/provider-usage/providers/claude.d.ts +1 -1
  34. package/dist/server/provider-usage/providers/claude.d.ts.map +1 -1
  35. package/dist/server/provider-usage/providers/claude.js +1 -1
  36. package/dist/server/provider-usage/providers/codex.d.ts +1 -1
  37. package/dist/server/provider-usage/providers/codex.d.ts.map +1 -1
  38. package/dist/server/provider-usage/providers/codex.js +1 -1
  39. package/dist/server/provider-usage/providers/kimi.d.ts +1 -1
  40. package/dist/server/provider-usage/providers/kimi.d.ts.map +1 -1
  41. package/dist/server/provider-usage/providers/kimi.js +4 -4
  42. package/dist/server/provider-usage/result.d.ts +5 -0
  43. package/dist/server/provider-usage/result.d.ts.map +1 -0
  44. package/dist/server/provider-usage/result.js +18 -0
  45. package/dist/server/providers/active-runtime.d.ts +1 -1
  46. package/dist/server/providers/active-runtime.d.ts.map +1 -1
  47. package/dist/server/providers/claude-events.d.ts +1 -1
  48. package/dist/server/providers/claude-events.d.ts.map +1 -1
  49. package/dist/server/providers/claude-events.js +2 -2
  50. package/dist/server/providers/claude.d.ts +1 -1
  51. package/dist/server/providers/claude.d.ts.map +1 -1
  52. package/dist/server/providers/claude.js +2 -2
  53. package/dist/server/providers/codex-app-server.d.ts +1 -1
  54. package/dist/server/providers/codex-app-server.d.ts.map +1 -1
  55. package/dist/server/providers/codex-app-server.js +1 -1
  56. package/dist/server/providers/codex-events.js +1 -1
  57. package/dist/server/providers/codex.d.ts +1 -1
  58. package/dist/server/providers/codex.d.ts.map +1 -1
  59. package/dist/server/providers/codex.js +2 -2
  60. package/dist/server/{runtime/provider-contract.d.ts → providers/contract.d.ts} +1 -1
  61. package/dist/server/providers/contract.d.ts.map +1 -0
  62. package/dist/server/providers/factory.d.ts +1 -1
  63. package/dist/server/providers/factory.d.ts.map +1 -1
  64. package/dist/server/providers/kimi-events.d.ts +1 -1
  65. package/dist/server/providers/kimi-events.d.ts.map +1 -1
  66. package/dist/server/providers/kimi-events.js +1 -1
  67. package/dist/server/providers/kimi.d.ts +1 -1
  68. package/dist/server/providers/kimi.d.ts.map +1 -1
  69. package/dist/server/providers/kimi.js +3 -2
  70. package/dist/server/providers/reasoning-events.js +1 -1
  71. package/dist/server/runtime/active-run-control.d.ts +19 -0
  72. package/dist/server/runtime/active-run-control.d.ts.map +1 -0
  73. package/dist/server/runtime/active-run-control.js +89 -0
  74. package/dist/server/runtime/activity.js +1 -1
  75. package/dist/server/runtime/agent-runner.d.ts +1 -1
  76. package/dist/server/runtime/agent-runner.d.ts.map +1 -1
  77. package/dist/server/runtime/delivery-prompt.d.ts +6 -0
  78. package/dist/server/runtime/delivery-prompt.d.ts.map +1 -1
  79. package/dist/server/runtime/delivery-prompt.js +53 -34
  80. package/dist/server/runtime/followup-appender.d.ts +20 -0
  81. package/dist/server/runtime/followup-appender.d.ts.map +1 -0
  82. package/dist/server/runtime/followup-appender.js +128 -0
  83. package/dist/server/runtime/host.d.ts +2 -0
  84. package/dist/server/runtime/host.d.ts.map +1 -1
  85. package/dist/server/runtime/host.js +8 -0
  86. package/dist/server/runtime/provider-runner.d.ts +16 -0
  87. package/dist/server/runtime/provider-runner.d.ts.map +1 -0
  88. package/dist/server/runtime/provider-runner.js +86 -0
  89. package/dist/server/runtime/runtime-bridge.d.ts +1 -1
  90. package/dist/server/runtime/runtime-bridge.d.ts.map +1 -1
  91. package/dist/server/runtime/runtime-session.service.d.ts +1 -1
  92. package/dist/server/runtime/runtime-session.service.d.ts.map +1 -1
  93. package/dist/server/runtime/runtime-session.service.js +1 -1
  94. package/dist/server/runtime/runtime-worker.d.ts +2 -10
  95. package/dist/server/runtime/runtime-worker.d.ts.map +1 -1
  96. package/dist/server/runtime/runtime-worker.js +60 -286
  97. package/dist/server/runtime-management/managed-runtime.d.ts.map +1 -0
  98. package/dist/server/runtime-management/runtime-release.d.ts.map +1 -0
  99. package/dist/server/{runtime → runtime-management}/runtime-upgrade.d.ts +1 -0
  100. package/dist/server/runtime-management/runtime-upgrade.d.ts.map +1 -0
  101. package/dist/server/{runtime → runtime-management}/runtime-upgrade.js +14 -0
  102. package/dist/server/services/system.service.d.ts +41 -0
  103. package/dist/server/services/system.service.d.ts.map +1 -0
  104. package/dist/server/services/system.service.js +154 -0
  105. package/dist/server/settings/settings.service.d.ts +2 -1
  106. package/dist/server/settings/settings.service.d.ts.map +1 -1
  107. package/dist/server/settings/settings.service.js +9 -1
  108. package/dist/server/slack/shortcuts.d.ts +0 -3
  109. package/dist/server/slack/shortcuts.d.ts.map +1 -1
  110. package/dist/server/slack/shortcuts.js +0 -3
  111. package/dist/server/slack-interactions/shortcut-ids.d.ts +4 -0
  112. package/dist/server/slack-interactions/shortcut-ids.d.ts.map +1 -0
  113. package/dist/server/slack-interactions/shortcut-ids.js +3 -0
  114. package/dist/server/slack-interactions/shortcut-views.d.ts +65 -0
  115. package/dist/server/slack-interactions/shortcut-views.d.ts.map +1 -0
  116. package/dist/server/slack-interactions/shortcut-views.js +211 -0
  117. package/dist/server/{slack → slack-interactions}/shortcut.service.d.ts +2 -47
  118. package/dist/server/slack-interactions/shortcut.service.d.ts.map +1 -0
  119. package/dist/server/slack-interactions/shortcut.service.js +227 -0
  120. package/dist/server/storage/jsonl-log.d.ts +9 -0
  121. package/dist/server/storage/jsonl-log.d.ts.map +1 -1
  122. package/dist/server/storage/jsonl-log.js +36 -1
  123. package/dist/server/storage/schema/server.store.d.ts +5 -0
  124. package/dist/server/storage/schema/server.store.d.ts.map +1 -1
  125. package/dist/server/storage/schema/server.store.js +2 -1
  126. package/dist/server/storage/schema/wake-queue.store.d.ts +15 -1
  127. package/dist/server/storage/schema/wake-queue.store.d.ts.map +1 -1
  128. package/dist/server/storage/schema/wake-queue.store.js +5 -3
  129. package/dist/server/tests/agent-config.test.d.ts +2 -0
  130. package/dist/server/tests/agent-config.test.d.ts.map +1 -0
  131. package/dist/server/tests/agent-config.test.js +229 -0
  132. package/dist/server/tests/agent-runtime.test.d.ts +2 -0
  133. package/dist/server/tests/agent-runtime.test.d.ts.map +1 -0
  134. package/dist/server/tests/agent-runtime.test.js +1110 -0
  135. package/dist/server/tests/agent-seed-memory.test.d.ts +2 -0
  136. package/dist/server/tests/agent-seed-memory.test.d.ts.map +1 -0
  137. package/dist/server/tests/agent-seed-memory.test.js +16 -0
  138. package/dist/server/tests/agent-skills.test.d.ts +2 -0
  139. package/dist/server/tests/agent-skills.test.d.ts.map +1 -0
  140. package/dist/server/tests/agent-skills.test.js +140 -0
  141. package/dist/server/tests/anima-home.d.ts +2 -0
  142. package/dist/server/tests/anima-home.d.ts.map +1 -0
  143. package/dist/server/tests/anima-home.js +4 -0
  144. package/dist/server/tests/cli-file.test.d.ts +2 -0
  145. package/dist/server/tests/cli-file.test.d.ts.map +1 -0
  146. package/dist/server/tests/cli-file.test.js +596 -0
  147. package/dist/server/tests/cli-message.test.d.ts +2 -0
  148. package/dist/server/tests/cli-message.test.d.ts.map +1 -0
  149. package/dist/server/tests/cli-message.test.js +1196 -0
  150. package/dist/server/tests/client-error-routes.test.d.ts +2 -0
  151. package/dist/server/tests/client-error-routes.test.d.ts.map +1 -0
  152. package/dist/server/tests/client-error-routes.test.js +163 -0
  153. package/dist/server/tests/config.test.d.ts +2 -0
  154. package/dist/server/tests/config.test.d.ts.map +1 -0
  155. package/dist/server/tests/config.test.js +497 -0
  156. package/dist/server/tests/default-skills.test.d.ts +2 -0
  157. package/dist/server/tests/default-skills.test.d.ts.map +1 -0
  158. package/dist/server/tests/default-skills.test.js +57 -0
  159. package/dist/server/tests/helpers/inbox.d.ts +10 -0
  160. package/dist/server/tests/helpers/inbox.d.ts.map +1 -0
  161. package/dist/server/tests/helpers/inbox.js +21 -0
  162. package/dist/server/tests/helpers/slack.d.ts +11 -0
  163. package/dist/server/tests/helpers/slack.d.ts.map +1 -0
  164. package/dist/server/tests/helpers/slack.js +17 -0
  165. package/dist/server/tests/helpers/state.d.ts +19 -0
  166. package/dist/server/tests/helpers/state.d.ts.map +1 -0
  167. package/dist/server/tests/helpers/state.js +69 -0
  168. package/dist/server/tests/inbox-slack-events.test.d.ts +2 -0
  169. package/dist/server/tests/inbox-slack-events.test.d.ts.map +1 -0
  170. package/dist/server/tests/inbox-slack-events.test.js +271 -0
  171. package/dist/server/tests/inbox.test.d.ts +2 -0
  172. package/dist/server/tests/inbox.test.d.ts.map +1 -0
  173. package/dist/server/tests/inbox.test.js +144 -0
  174. package/dist/server/tests/interactive-ask.test.d.ts +2 -0
  175. package/dist/server/tests/interactive-ask.test.d.ts.map +1 -0
  176. package/dist/server/tests/interactive-ask.test.js +387 -0
  177. package/dist/server/tests/kb.test.d.ts +2 -0
  178. package/dist/server/tests/kb.test.d.ts.map +1 -0
  179. package/dist/server/tests/kb.test.js +371 -0
  180. package/dist/server/tests/message.service.test.d.ts +2 -0
  181. package/dist/server/tests/message.service.test.d.ts.map +1 -0
  182. package/dist/server/tests/message.service.test.js +151 -0
  183. package/dist/server/tests/messages.test.d.ts +2 -0
  184. package/dist/server/tests/messages.test.d.ts.map +1 -0
  185. package/dist/server/tests/messages.test.js +12 -0
  186. package/dist/server/tests/prompt-attachments.test.d.ts +2 -0
  187. package/dist/server/tests/prompt-attachments.test.d.ts.map +1 -0
  188. package/dist/server/tests/prompt-attachments.test.js +274 -0
  189. package/dist/server/tests/provider-usage.test.d.ts +2 -0
  190. package/dist/server/tests/provider-usage.test.d.ts.map +1 -0
  191. package/dist/server/tests/provider-usage.test.js +97 -0
  192. package/dist/server/tests/reminders.test.d.ts +2 -0
  193. package/dist/server/tests/reminders.test.d.ts.map +1 -0
  194. package/dist/server/tests/reminders.test.js +383 -0
  195. package/dist/server/tests/runtime-cli.test.d.ts +2 -0
  196. package/dist/server/tests/runtime-cli.test.d.ts.map +1 -0
  197. package/dist/server/tests/runtime-cli.test.js +21 -0
  198. package/dist/server/tests/runtime-upgrade.test.d.ts +2 -0
  199. package/dist/server/tests/runtime-upgrade.test.d.ts.map +1 -0
  200. package/dist/server/tests/runtime-upgrade.test.js +307 -0
  201. package/dist/server/tests/runtime-worker.test.d.ts +2 -0
  202. package/dist/server/tests/runtime-worker.test.d.ts.map +1 -0
  203. package/dist/server/tests/runtime-worker.test.js +1021 -0
  204. package/dist/server/tests/runtime.test.d.ts +2 -0
  205. package/dist/server/tests/runtime.test.d.ts.map +1 -0
  206. package/dist/server/tests/runtime.test.js +354 -0
  207. package/dist/server/tests/services.test.d.ts +2 -0
  208. package/dist/server/tests/services.test.d.ts.map +1 -0
  209. package/dist/server/tests/services.test.js +636 -0
  210. package/dist/server/tests/slack-files.test.d.ts +2 -0
  211. package/dist/server/tests/slack-files.test.d.ts.map +1 -0
  212. package/dist/server/tests/slack-files.test.js +118 -0
  213. package/dist/server/tests/slack-shortcuts.test.d.ts +2 -0
  214. package/dist/server/tests/slack-shortcuts.test.d.ts.map +1 -0
  215. package/dist/server/tests/slack-shortcuts.test.js +310 -0
  216. package/dist/server/tests/slack.test.d.ts +2 -0
  217. package/dist/server/tests/slack.test.d.ts.map +1 -0
  218. package/dist/server/tests/slack.test.js +213 -0
  219. package/dist/server/tests/state-cache.test.d.ts +2 -0
  220. package/dist/server/tests/state-cache.test.d.ts.map +1 -0
  221. package/dist/server/tests/state-cache.test.js +184 -0
  222. package/dist/server/tests/subscriptions.test.d.ts +2 -0
  223. package/dist/server/tests/subscriptions.test.d.ts.map +1 -0
  224. package/dist/server/tests/subscriptions.test.js +292 -0
  225. package/dist/server/tests/system-service.test.d.ts +2 -0
  226. package/dist/server/tests/system-service.test.d.ts.map +1 -0
  227. package/dist/server/tests/system-service.test.js +20 -0
  228. package/dist/server/tests/url-routes.test.d.ts +2 -0
  229. package/dist/server/tests/url-routes.test.d.ts.map +1 -0
  230. package/dist/server/tests/url-routes.test.js +217 -0
  231. package/dist/server/tests/web-api.test.d.ts +2 -0
  232. package/dist/server/tests/web-api.test.d.ts.map +1 -0
  233. package/dist/server/tests/web-api.test.js +1759 -0
  234. package/dist/server/web/app.d.ts.map +1 -1
  235. package/dist/server/web/app.js +2 -0
  236. package/dist/server/web/client-error-routes.d.ts +3 -0
  237. package/dist/server/web/client-error-routes.d.ts.map +1 -0
  238. package/dist/server/web/client-error-routes.js +101 -0
  239. package/dist/server/web/system-routes.d.ts.map +1 -1
  240. package/dist/server/web/system-routes.js +21 -131
  241. package/dist/shared/agent-config.d.ts +8 -18
  242. package/dist/shared/agent-config.d.ts.map +1 -1
  243. package/dist/shared/agent-config.js +3 -4
  244. package/dist/shared/inbox.d.ts +27 -0
  245. package/dist/shared/inbox.d.ts.map +1 -1
  246. package/dist/shared/inbox.js +1 -0
  247. package/dist/shared/server-info.d.ts +6 -5
  248. package/dist/shared/server-info.d.ts.map +1 -1
  249. package/dist/shared/server-info.js +3 -1
  250. package/dist/shared/server-settings.d.ts +6 -0
  251. package/dist/shared/server-settings.d.ts.map +1 -1
  252. package/dist/shared/server-settings.js +1 -0
  253. package/dist/shared/skills.d.ts +24 -3
  254. package/dist/shared/skills.d.ts.map +1 -1
  255. package/dist/web/assets/activity-DHjip8tY.js +7 -0
  256. package/dist/web/assets/clipboard-D2NSC52v.js +1 -0
  257. package/dist/web/assets/{external-link-Dt0qTpmV.js → external-link-Bhr1WdRS.js} +1 -1
  258. package/dist/web/assets/{file-text-Dd9ys6py.js → file-text-CYMA_SMA.js} +1 -1
  259. package/dist/web/assets/{index-CzAQnbuO.js → index-CVpew409.js} +5 -5
  260. package/dist/web/assets/index-mh0uJTkY.css +2 -0
  261. package/dist/web/assets/kb-DDVpYkT9.js +35 -0
  262. package/dist/web/assets/profile-CBy6GNzp.js +1 -0
  263. package/dist/web/assets/reminders-Bbwb9QNT.js +1 -0
  264. package/dist/web/favicon.svg +1 -1
  265. package/dist/web/index.html +2 -2
  266. package/package.json +1 -1
  267. package/templates/runtime-standing-prompt.md +16 -2
  268. package/templates/skills/find-skills/SKILL.md +133 -0
  269. package/dist/server/runtime/activity-payload.d.ts.map +0 -1
  270. package/dist/server/runtime/activity-text.d.ts +0 -4
  271. package/dist/server/runtime/activity-text.d.ts.map +0 -1
  272. package/dist/server/runtime/activity-text.js +0 -16
  273. package/dist/server/runtime/managed-runtime.d.ts.map +0 -1
  274. package/dist/server/runtime/provider-contract.d.ts.map +0 -1
  275. package/dist/server/runtime/runtime-release.d.ts.map +0 -1
  276. package/dist/server/runtime/runtime-upgrade.d.ts.map +0 -1
  277. package/dist/server/slack/shortcut.service.d.ts.map +0 -1
  278. package/dist/server/slack/shortcut.service.js +0 -493
  279. package/dist/web/assets/activity-BP0C81IV.js +0 -7
  280. package/dist/web/assets/globe-DmDZSgDP.js +0 -1
  281. package/dist/web/assets/index-DKecrvN0.css +0 -2
  282. package/dist/web/assets/kb-3wFeP2Jt.js +0 -32
  283. package/dist/web/assets/profile-D7wPZXW_.js +0 -1
  284. package/dist/web/assets/reminders-DOhroVwo.js +0 -1
  285. /package/dist/server/{runtime/provider-contract.js → providers/contract.js} +0 -0
  286. /package/dist/server/{runtime → runtime-management}/managed-runtime.d.ts +0 -0
  287. /package/dist/server/{runtime → runtime-management}/managed-runtime.js +0 -0
  288. /package/dist/server/{runtime → runtime-management}/runtime-release.d.ts +0 -0
  289. /package/dist/server/{runtime → runtime-management}/runtime-release.js +0 -0
@@ -0,0 +1,1196 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { once } from 'node:events';
3
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
4
+ import { createServer } from 'node:http';
5
+ import { tmpdir } from 'node:os';
6
+ import { join, resolve } from 'node:path';
7
+ import test from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import { allActivities, loadState } from './helpers/state.js';
10
+ import { activitiesForInboxItemWindow } from '../runtime/item-activities.js';
11
+ import { slackRuntimeDecision } from '../inbox/slack-subscription.service.js';
12
+ import { clearActiveRuntimeItem, setActiveRuntimeItem } from '../runtime/active-item.js';
13
+ import { activityServiceForAgent } from '../activities/activity.service.js';
14
+ import { WakeQueueService } from '../inbox/wake-queue.service.js';
15
+ import { makeSlackEvent } from './helpers/slack.js';
16
+ import { ingestEvent } from './helpers/inbox.js';
17
+ import { withAnimaHome } from './anima-home.js';
18
+ const adminCliPath = resolve('dist/server/cli/animactl.js');
19
+ const cliPath = resolve('dist/server/cli/anima.js');
20
+ test('message send records an audited Slack output', async () => {
21
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-cli-message-test-'));
22
+ const posts = [];
23
+ const slackApi = await startSlackApiMock((method, body) => {
24
+ if (method === 'users.list') {
25
+ return {
26
+ members: [{ id: 'U123', name: 'alice' }],
27
+ ok: true,
28
+ };
29
+ }
30
+ if (method === 'conversations.list') {
31
+ return {
32
+ channels: [{ id: 'C-product', name: 'product', name_normalized: 'product' }],
33
+ ok: true,
34
+ };
35
+ }
36
+ if (method === 'conversations.info') {
37
+ return {
38
+ channel: { id: 'C-product', is_channel: true, name: 'product', name_normalized: 'product' },
39
+ ok: true,
40
+ };
41
+ }
42
+ if (method === 'conversations.members') {
43
+ return {
44
+ members: ['U123'],
45
+ ok: true,
46
+ };
47
+ }
48
+ if (method !== 'chat.postMessage')
49
+ throw new Error(`unexpected method ${method}`);
50
+ posts.push(slackRequestBody(body));
51
+ return {
52
+ ok: true,
53
+ channel: 'C-product',
54
+ ts: '1770000200.000123',
55
+ };
56
+ });
57
+ try {
58
+ await withAnimaHome(stateDir, async () => {
59
+ await writeSlackConfig(stateDir);
60
+ const itemId = await ingestSlackThread(stateDir);
61
+ await slackRuntimeDecision({
62
+ channel: 'C-product',
63
+ channel_type: 'channel',
64
+ text: '<@U-scout> help in channel',
65
+ ts: '1770000100.000001',
66
+ type: 'app_mention',
67
+ user: 'U123',
68
+ }, { agentId: 'scout', nowMs: Date.now() - 60 * 60 * 1000 });
69
+ const send = await runNode([cliPath, 'message', 'send', '--channel', 'C-product', '--thread-ts', '1770000200.000001'], {
70
+ env: { ...process.env, ANIMA_AGENT_ID: 'scout', ANIMA_HOME: stateDir, ANIMA_INBOX_ITEM_ID: itemId, ANIMA_SLACK_API_URL: slackApi.url },
71
+ input: '我倾向*一起带进去*——用 @alice 和 #product,字段 *role*。',
72
+ });
73
+ assert.equal(send.status, 0, send.stderr || send.stdout);
74
+ assert.equal(send.stdout.trim(), 'sent successfully. channel=#product, thread_ts=1770000200.000001, message_ts=1770000200.000123.');
75
+ assert.deepEqual(posts, [
76
+ {
77
+ blocks: JSON.stringify([{ type: 'markdown', text: '我倾向*一起带进去*——用 <@U123> 和 <#C-product>,字段 *role*。' }]),
78
+ channel: 'C-product',
79
+ text: '我倾向*一起带进去*——用 <@U123> 和 <#C-product>,字段 *role*。',
80
+ thread_ts: '1770000200.000001',
81
+ },
82
+ ]);
83
+ const activities = await activitiesForInboxItemWindow('scout', itemId);
84
+ const completed = activities.at(-1);
85
+ assert.equal(completed?.type, 'external.effect.completed');
86
+ assert.equal(completed?.payload?.['effect'], 'slack.message.send');
87
+ assert.equal(completed?.payload?.['tool'], 'anima.message.send');
88
+ assert.equal(completed?.payload?.['status'], 'sent');
89
+ assert.equal(completed?.payload?.['ts'], '1770000200.000123');
90
+ assert.equal(completed?.payload?.['text'], '我倾向*一起带进去*——用 @alice 和 #product,字段 *role*。');
91
+ assert.equal(completed?.payload?.['slackText'], '我倾向*一起带进去*——用 <@U123> 和 <#C-product>,字段 *role*。');
92
+ assert.equal(completed?.payload?.['channelKind'], 'channel');
93
+ assert.equal(completed?.payload?.['channelDisplayName'], '#product');
94
+ assert.equal(completed?.payload?.['threadTs'], '1770000200.000001');
95
+ assert.equal(completed?.payload?.['threadDisplayName'], 'Thread 1770000200.000001 in #product');
96
+ const completedThreadSubscription = completed?.payload?.['threadSubscription'];
97
+ assert.deepEqual(completedThreadSubscription, {
98
+ subscriptionId: 'slack-subscription:scout:C-product:thread:1770000200.000001',
99
+ kind: 'thread',
100
+ threadTs: '1770000200.000001',
101
+ });
102
+ const renewedState = await loadState();
103
+ assert.equal(Object.values(renewedState.subscriptions).some((subscription) => subscription.kind === 'channel'), false);
104
+ const sentThreadSubscription = Object.values(renewedState.subscriptions).find((subscription) => subscription.kind === 'thread' && subscription.threadTs === '1770000200.000001');
105
+ assert.ok(sentThreadSubscription);
106
+ const list = await runNode([cliPath, 'subscription', 'list'], {
107
+ env: { ...process.env, ANIMA_AGENT_ID: 'scout', ANIMA_HOME: stateDir },
108
+ });
109
+ assert.equal(list.status, 0, list.stderr || list.stdout);
110
+ assert.match(list.stdout, /Channels:\n- none/);
111
+ assert.match(list.stdout, /\[following\] channel=C-product thread_ts=1770000100\.000001/);
112
+ assert.match(list.stdout, /\[following\] channel=C-product thread_ts=1770000200\.000001/);
113
+ const mute = await runNode([cliPath, 'subscription', 'mute', '--channel', 'C-product', '--thread-ts', '1770000200.000001'], {
114
+ env: { ...process.env, ANIMA_AGENT_ID: 'scout', ANIMA_HOME: stateDir, ANIMA_INBOX_ITEM_ID: itemId, ANIMA_SLACK_API_URL: slackApi.url },
115
+ });
116
+ assert.equal(mute.status, 0, mute.stderr || mute.stdout);
117
+ assert.equal(mute.stdout.trim(), 'muted successfully. channel=C-product thread_ts=1770000200.000001.');
118
+ const mutedState = await loadState();
119
+ assert.ok(Object.values(mutedState.subscriptions).some((subscription) => subscription.kind === 'thread' && subscription.threadTs === '1770000200.000001' && subscription.mutedAt));
120
+ });
121
+ }
122
+ finally {
123
+ await slackApi.close();
124
+ await rm(stateDir, { force: true, recursive: true });
125
+ }
126
+ });
127
+ test('message send resolves the active runtime item without ANIMA_INBOX_ITEM_ID env', async () => {
128
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-cli-message-active-item-test-'));
129
+ const posts = [];
130
+ const slackApi = await startSlackApiMock((method, body) => {
131
+ if (method === 'conversations.info') {
132
+ return {
133
+ channel: { id: 'C-product', is_channel: true, name: 'product', name_normalized: 'product' },
134
+ ok: true,
135
+ };
136
+ }
137
+ if (method !== 'chat.postMessage')
138
+ throw new Error(`unexpected method ${method}`);
139
+ posts.push(slackRequestBody(body));
140
+ return {
141
+ ok: true,
142
+ channel: 'C-product',
143
+ ts: '1770000300.000123',
144
+ };
145
+ });
146
+ try {
147
+ await withAnimaHome(stateDir, async () => {
148
+ await writeSlackConfig(stateDir);
149
+ const itemId = await ingestSlackThread(stateDir);
150
+ const workerId = 'worker-active-item-test';
151
+ const queue = new WakeQueueService('scout');
152
+ const claimed = await queue.claimNext(workerId);
153
+ assert.equal(claimed?.id, itemId);
154
+ await setActiveRuntimeItem({ agentId: 'scout', itemId, workerId });
155
+ const send = await runNode([cliPath, 'message', 'send', '--channel', 'C-product'], {
156
+ env: { ...process.env, ANIMA_AGENT_ID: 'scout', ANIMA_HOME: stateDir, ANIMA_INBOX_ITEM_ID: '', ANIMA_SLACK_API_URL: slackApi.url },
157
+ input: 'Top-level via active item.',
158
+ });
159
+ assert.equal(send.status, 0, send.stderr || send.stdout);
160
+ assert.deepEqual(posts, [{
161
+ blocks: JSON.stringify([{ type: 'markdown', text: 'Top-level via active item.' }]),
162
+ channel: 'C-product',
163
+ text: 'Top-level via active item.',
164
+ }]);
165
+ const state = await loadState();
166
+ const activities = await activitiesForInboxItemWindow('scout', itemId);
167
+ const completed = activities.at(-1);
168
+ assert.equal(completed?.type, 'external.effect.completed');
169
+ assert.equal(completed?.payload?.['effect'], 'slack.message.send');
170
+ assert.equal(completed?.payload?.['tool'], 'anima.message.send');
171
+ assert.equal(completed?.payload?.['ts'], '1770000300.000123');
172
+ const threadSubscription = completed?.payload?.['threadSubscription'];
173
+ assert.equal(threadSubscription?.['subscriptionId'], 'slack-subscription:scout:C-product:thread:1770000300.000123');
174
+ assert.equal(threadSubscription?.['threadTs'], '1770000300.000123');
175
+ assert.equal(completed?.payload?.['renewedSubscription'], undefined);
176
+ assert.equal(Object.values(state.subscriptions).some((subscription) => subscription.kind === 'channel'), false);
177
+ assert.ok(Object.values(state.subscriptions).some((subscription) => subscription.kind === 'thread' && subscription.threadTs === '1770000300.000123'));
178
+ });
179
+ }
180
+ finally {
181
+ await slackApi.close();
182
+ await rm(stateDir, { force: true, recursive: true });
183
+ }
184
+ });
185
+ test('message send follows a new channel thread using an active channel subscription', async () => {
186
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-cli-message-channel-follow-test-'));
187
+ const posts = [];
188
+ const slackApi = await startSlackApiMock((method, body) => {
189
+ if (method === 'conversations.info') {
190
+ return {
191
+ channel: { id: 'C-product', is_channel: true, name: 'product', name_normalized: 'product' },
192
+ ok: true,
193
+ };
194
+ }
195
+ if (method !== 'chat.postMessage')
196
+ throw new Error(`unexpected method ${method}`);
197
+ posts.push(slackRequestBody(body));
198
+ return {
199
+ ok: true,
200
+ channel: 'C-product',
201
+ ts: '1770000300.000124',
202
+ };
203
+ });
204
+ try {
205
+ await withAnimaHome(stateDir, async () => {
206
+ await writeSlackConfig(stateDir);
207
+ await slackRuntimeDecision({
208
+ channel: 'C-product',
209
+ channel_type: 'channel',
210
+ text: '<@U-scout> keep an eye here',
211
+ ts: '1770000200.000001',
212
+ type: 'app_mention',
213
+ user: 'U123',
214
+ }, { agentId: 'scout', nowMs: Date.now() - 60 * 60 * 1000 });
215
+ const send = await runNode([cliPath, 'message', 'send', '--channel', 'C-product'], {
216
+ env: { ...process.env, ANIMA_AGENT_ID: 'scout', ANIMA_HOME: stateDir, ANIMA_INBOX_ITEM_ID: '', ANIMA_SLACK_API_URL: slackApi.url },
217
+ input: 'Top-level from channel context.',
218
+ });
219
+ assert.equal(send.status, 0, send.stderr || send.stdout);
220
+ assert.deepEqual(posts, [{
221
+ blocks: JSON.stringify([{ type: 'markdown', text: 'Top-level from channel context.' }]),
222
+ channel: 'C-product',
223
+ text: 'Top-level from channel context.',
224
+ }]);
225
+ const state = await loadState();
226
+ const completed = allActivities(state).find((activity) => activity.payload?.['ts'] === '1770000300.000124');
227
+ assert.equal(completed?.type, 'external.effect.completed');
228
+ const threadSubscription = completed?.payload?.['threadSubscription'];
229
+ assert.equal(threadSubscription?.['subscriptionId'], 'slack-subscription:scout:C-product:thread:1770000300.000124');
230
+ assert.equal(threadSubscription?.['threadTs'], '1770000300.000124');
231
+ assert.ok(Object.values(state.subscriptions).some((subscription) => subscription.kind === 'thread' && subscription.threadTs === '1770000300.000124'));
232
+ });
233
+ }
234
+ finally {
235
+ await slackApi.close();
236
+ await rm(stateDir, { force: true, recursive: true });
237
+ }
238
+ });
239
+ test('message send resolves a just-settled runtime item for audit', async () => {
240
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-cli-message-settled-item-test-'));
241
+ const posts = [];
242
+ const slackApi = await startSlackApiMock((method, body) => {
243
+ if (method === 'conversations.info') {
244
+ return {
245
+ channel: { id: 'C-product', is_channel: true, name: 'product', name_normalized: 'product' },
246
+ ok: true,
247
+ };
248
+ }
249
+ if (method !== 'chat.postMessage')
250
+ throw new Error(`unexpected method ${method}`);
251
+ posts.push(slackRequestBody(body));
252
+ return {
253
+ ok: true,
254
+ channel: 'C-product',
255
+ ts: '1770000300.000125',
256
+ };
257
+ });
258
+ try {
259
+ await withAnimaHome(stateDir, async () => {
260
+ await writeSlackConfig(stateDir);
261
+ const itemId = await ingestSlackThread(stateDir);
262
+ const workerId = 'worker-settled-item-test';
263
+ const queue = new WakeQueueService('scout');
264
+ const claimed = await queue.claimNext(workerId);
265
+ assert.equal(claimed?.id, itemId);
266
+ await setActiveRuntimeItem({ agentId: 'scout', itemId, workerId });
267
+ await queue.complete(itemId);
268
+ await clearActiveRuntimeItem({ agentId: 'scout', itemId, workerId });
269
+ const send = await runNode([cliPath, 'message', 'send', '--channel', 'C-product'], {
270
+ env: { ...process.env, ANIMA_AGENT_ID: 'scout', ANIMA_HOME: stateDir, ANIMA_INBOX_ITEM_ID: '', ANIMA_SLACK_API_URL: slackApi.url },
271
+ input: 'Via settled item grace.',
272
+ });
273
+ assert.equal(send.status, 0, send.stderr || send.stdout);
274
+ assert.deepEqual(posts, [{
275
+ blocks: JSON.stringify([{ type: 'markdown', text: 'Via settled item grace.' }]),
276
+ channel: 'C-product',
277
+ text: 'Via settled item grace.',
278
+ }]);
279
+ const completed = allActivities(await loadState()).find((activity) => activity.payload?.['ts'] === '1770000300.000125');
280
+ assert.equal(completed?.type, 'external.effect.completed');
281
+ assert.equal(completed?.payload?.['effect'], 'slack.message.send');
282
+ assert.equal(completed?.payload?.['tool'], 'anima.message.send');
283
+ assert.equal(completed?.payload?.['ts'], '1770000300.000125');
284
+ });
285
+ }
286
+ finally {
287
+ await slackApi.close();
288
+ await rm(stateDir, { force: true, recursive: true });
289
+ }
290
+ });
291
+ test('message send records audit without an active runtime item', async () => {
292
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-cli-message-no-active-item-test-'));
293
+ let postCount = 0;
294
+ const slackApi = await startSlackApiMock((method) => {
295
+ if (method === 'conversations.info') {
296
+ return {
297
+ channel: { id: 'C-product', is_channel: true, name: 'product', name_normalized: 'product' },
298
+ ok: true,
299
+ };
300
+ }
301
+ if (method === 'chat.postMessage') {
302
+ postCount += 1;
303
+ return { ok: true, channel: 'C-product', ts: '1770000300.000124' };
304
+ }
305
+ throw new Error(`unexpected method ${method}`);
306
+ });
307
+ try {
308
+ await withAnimaHome(stateDir, async () => {
309
+ await writeSlackConfig(stateDir, { teamId: 'T-config' });
310
+ const send = await runNode([cliPath, 'message', 'send', '--channel', 'C-product'], {
311
+ env: { ...process.env, ANIMA_AGENT_ID: 'scout', ANIMA_HOME: stateDir, ANIMA_INBOX_ITEM_ID: '', ANIMA_SLACK_API_URL: slackApi.url },
312
+ input: 'Posts without a item.',
313
+ });
314
+ assert.equal(send.status, 0, send.stderr || send.stdout);
315
+ assert.equal(postCount, 1);
316
+ const completed = allActivities(await loadState()).at(-1);
317
+ assert.equal(completed?.type, 'external.effect.completed');
318
+ assert.equal(completed?.payload?.['effect'], 'slack.message.send');
319
+ const threadSubscription = completed?.payload?.['threadSubscription'];
320
+ assert.equal(threadSubscription?.['subscriptionId'], 'slack-subscription:scout:C-product:thread:1770000300.000124');
321
+ assert.equal(Object.hasOwn(completed ?? {}, 'itemId'), false);
322
+ });
323
+ }
324
+ finally {
325
+ await slackApi.close();
326
+ await rm(stateDir, { force: true, recursive: true });
327
+ }
328
+ });
329
+ test('message send output describes DM targets', async () => {
330
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-cli-message-dm-output-test-'));
331
+ const posts = [];
332
+ const slackApi = await startSlackApiMock((method, body) => {
333
+ if (method === 'conversations.info') {
334
+ return { channel: { id: 'D-alice', is_im: true, user: 'U-alice' }, ok: true };
335
+ }
336
+ if (method === 'users.info') {
337
+ return {
338
+ ok: true,
339
+ user: {
340
+ id: 'U-alice',
341
+ name: 'alice',
342
+ profile: { display_name: 'Alice Cooper' },
343
+ },
344
+ };
345
+ }
346
+ if (method !== 'chat.postMessage')
347
+ throw new Error(`unexpected method ${method}`);
348
+ posts.push(slackRequestBody(body));
349
+ return {
350
+ ok: true,
351
+ channel: 'D-alice',
352
+ ts: '1770000200.000125',
353
+ };
354
+ });
355
+ try {
356
+ await withAnimaHome(stateDir, async () => {
357
+ await writeSlackConfig(stateDir);
358
+ const itemId = await ingestSlackThread(stateDir);
359
+ const send = await runNode([cliPath, 'message', 'send', '--channel', 'D-alice'], {
360
+ env: { ...process.env, ANIMA_AGENT_ID: 'scout', ANIMA_HOME: stateDir, ANIMA_INBOX_ITEM_ID: itemId, ANIMA_SLACK_API_URL: slackApi.url },
361
+ input: 'DM hello.',
362
+ });
363
+ assert.equal(send.status, 0, send.stderr || send.stdout);
364
+ assert.equal(send.stdout.trim(), 'sent successfully. dm=@alice, message_ts=1770000200.000125.');
365
+ assert.deepEqual(posts, [{
366
+ blocks: JSON.stringify([{ type: 'markdown', text: 'DM hello.' }]),
367
+ channel: 'D-alice',
368
+ text: 'DM hello.',
369
+ }]);
370
+ const completed = (await activitiesForInboxItemWindow('scout', itemId)).at(-1);
371
+ assert.equal(completed?.payload?.['channelKind'], 'dm');
372
+ assert.equal(completed?.payload?.['channelDisplayName'], 'DM with @alice');
373
+ assert.equal(completed?.payload?.['dmHandle'], 'alice');
374
+ assert.equal(completed?.payload?.['dmUserId'], 'U-alice');
375
+ assert.equal(completed?.payload?.['permalink'], 'https://slack.com/app_redirect?channel=D-alice&message_ts=1770000200.000125');
376
+ });
377
+ }
378
+ finally {
379
+ await slackApi.close();
380
+ await rm(stateDir, { force: true, recursive: true });
381
+ }
382
+ });
383
+ test('inbox and outbox commands show recent received and sent history', async () => {
384
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-cli-inbox-outbox-test-'));
385
+ try {
386
+ await withAnimaHome(stateDir, async () => {
387
+ await writeSlackConfig(stateDir);
388
+ await ingestEvent(makeSlackEvent({
389
+ actor: { displayName: 'Alice Cooper', handle: 'alice' },
390
+ channelId: 'C-product',
391
+ channelName: 'product',
392
+ eventId: 'evt_history_one',
393
+ teamId: 'T-demo',
394
+ text: 'Can you summarize the launch thread?',
395
+ timestamp: '2026-05-11T00:00:00.000Z',
396
+ threadTs: '1770000200.000001',
397
+ ts: '1770000200.000002',
398
+ userId: 'U-alice',
399
+ }), { agentId: 'scout', stateDir });
400
+ await ingestEvent(makeSlackEvent({
401
+ actor: { handle: 'bob' },
402
+ channelId: 'C-product',
403
+ channelName: 'product',
404
+ eventId: 'evt_history_two',
405
+ teamId: 'T-demo',
406
+ text: 'Second message for pagination.',
407
+ timestamp: '2026-05-11T00:05:00.000Z',
408
+ ts: '1770000205.000001',
409
+ userId: 'U-bob',
410
+ }), { agentId: 'scout', stateDir });
411
+ await activityServiceForAgent('scout').record({
412
+ createdAt: '2026-05-11T00:06:00.000Z',
413
+ payload: {
414
+ channel: 'D-alice',
415
+ channelDisplayName: 'DM with @alice',
416
+ channelKind: 'dm',
417
+ dmHandle: 'alice',
418
+ dmUserId: 'U-alice',
419
+ effect: 'slack.message.send',
420
+ permalink: 'https://anima.slack.com/archives/D-alice/p1770000206000001',
421
+ status: 'completed',
422
+ text: 'Sent the summary.',
423
+ tool: 'anima.message.send',
424
+ ts: '1770000206.000001',
425
+ },
426
+ type: 'external.effect.completed',
427
+ });
428
+ });
429
+ const env = { ...process.env, ANIMA_AGENT_ID: 'scout', ANIMA_HOME: stateDir };
430
+ const inbox = await runNode([cliPath, 'inbox', '--limit', '1'], { env });
431
+ assert.equal(inbox.status, 0, inbox.stderr || inbox.stdout);
432
+ assert.match(inbox.stdout, /^Inbox \(1 entry, newest first\)/);
433
+ assert.match(inbox.stdout, /\[time=2026-05-11T00:05:00\.000Z channel=#product channel_id=C-product message_ts=1770000205\.000001\] @bob: Second message for pagination\./);
434
+ assert.match(inbox.stdout, /\[page has_more=true next_cursor=2026-05-11T00:05:00\.000Z\]/);
435
+ const secondPage = await runNode([cliPath, 'inbox', '--before', '2026-05-11T00:05:00.000Z'], { env });
436
+ assert.equal(secondPage.status, 0, secondPage.stderr || secondPage.stdout);
437
+ assert.match(secondPage.stdout, /\[time=2026-05-11T00:00:00\.000Z channel=#product channel_id=C-product thread_ts=1770000200\.000001 message_ts=1770000200\.000002\] Alice Cooper \(@alice\): Can you summarize the launch thread\?/);
438
+ const outbox = await runNode([cliPath, 'outbox'], { env });
439
+ assert.equal(outbox.status, 0, outbox.stderr || outbox.stdout);
440
+ assert.match(outbox.stdout, /^Outbox \(1 entry, newest first\)/);
441
+ assert.match(outbox.stdout, /\[time=2026-05-11T00:06:00\.000Z channel=@alice channel_id=D-alice message_ts=1770000206\.000001\] sent: Sent the summary\./);
442
+ }
443
+ finally {
444
+ await rm(stateDir, { force: true, recursive: true });
445
+ }
446
+ });
447
+ test('inbox command defaults to twenty entries', async () => {
448
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-cli-inbox-default-limit-test-'));
449
+ try {
450
+ await withAnimaHome(stateDir, async () => {
451
+ await writeSlackConfig(stateDir);
452
+ for (let i = 0; i < 21; i += 1) {
453
+ const suffix = String(i).padStart(2, '0');
454
+ await ingestEvent(makeSlackEvent({
455
+ actor: { handle: `user${suffix}` },
456
+ channelId: 'C-product',
457
+ channelName: 'product',
458
+ eventId: `evt_history_limit_${suffix}`,
459
+ teamId: 'T-demo',
460
+ text: `Message ${suffix}`,
461
+ timestamp: `2026-05-11T00:${suffix}:00.000Z`,
462
+ ts: `17700003${suffix}.000001`,
463
+ userId: `U-${suffix}`,
464
+ }), { agentId: 'scout', stateDir });
465
+ }
466
+ });
467
+ const inbox = await runNode([cliPath, 'inbox'], {
468
+ env: { ...process.env, ANIMA_AGENT_ID: 'scout', ANIMA_HOME: stateDir },
469
+ });
470
+ assert.equal(inbox.status, 0, inbox.stderr || inbox.stdout);
471
+ assert.match(inbox.stdout, /^Inbox \(20 entries, newest first\)/);
472
+ assert.equal(inbox.stdout.split('\n').filter((line) => line.startsWith('[time=')).length, 20);
473
+ assert.match(inbox.stdout, /\[time=2026-05-11T00:20:00\.000Z channel=#product channel_id=C-product message_ts=1770000320\.000001\] @user20: Message 20/);
474
+ assert.doesNotMatch(inbox.stdout, /Message 00/);
475
+ assert.match(inbox.stdout, /\[page has_more=true next_cursor=2026-05-11T00:01:00\.000Z\]/);
476
+ }
477
+ finally {
478
+ await rm(stateDir, { force: true, recursive: true });
479
+ }
480
+ });
481
+ test('message send normalizes raw Slack user ids outside code spans', async () => {
482
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-cli-message-raw-user-id-test-'));
483
+ const posts = [];
484
+ const slackApi = await startSlackApiMock((method, body) => {
485
+ if (method === 'conversations.info') {
486
+ return {
487
+ channel: { id: 'C-product', is_channel: true, name: 'product', name_normalized: 'product' },
488
+ ok: true,
489
+ };
490
+ }
491
+ if (method === 'conversations.members') {
492
+ return {
493
+ members: ['U123'],
494
+ ok: true,
495
+ };
496
+ }
497
+ if (method !== 'chat.postMessage')
498
+ throw new Error(`unexpected method ${method}`);
499
+ posts.push(slackRequestBody(body));
500
+ return {
501
+ ok: true,
502
+ channel: 'C-product',
503
+ ts: '1770000200.000126',
504
+ };
505
+ });
506
+ try {
507
+ await withAnimaHome(stateDir, async () => {
508
+ await writeSlackConfig(stateDir);
509
+ const itemId = await ingestSlackThread(stateDir);
510
+ const send = await runNode([cliPath, 'message', 'send', '--channel', 'C-product'], {
511
+ env: { ...process.env, ANIMA_AGENT_ID: 'scout', ANIMA_HOME: stateDir, ANIMA_INBOX_ITEM_ID: itemId, ANIMA_SLACK_API_URL: slackApi.url },
512
+ input: 'cc @U123 and `@U456`.',
513
+ });
514
+ assert.equal(send.status, 0, send.stderr || send.stdout);
515
+ assert.equal(send.stdout.trim(), 'sent successfully. channel=#product, message_ts=1770000200.000126.');
516
+ assert.deepEqual(posts, [{
517
+ blocks: JSON.stringify([{ type: 'markdown', text: 'cc <@U123> and `@U456`.' }]),
518
+ channel: 'C-product',
519
+ text: 'cc <@U123> and `@U456`.',
520
+ }]);
521
+ const completed = (await activitiesForInboxItemWindow('scout', itemId)).at(-1);
522
+ assert.equal(completed?.payload?.['slackText'], 'cc <@U123> and `@U456`.');
523
+ assert.deepEqual(completed?.payload?.['resolvedMentions'], [{ id: 'U123', label: '@U123', type: 'user' }]);
524
+ });
525
+ }
526
+ finally {
527
+ await slackApi.close();
528
+ await rm(stateDir, { force: true, recursive: true });
529
+ }
530
+ });
531
+ test('message send warns for unresolved and out-of-channel mentions', async () => {
532
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-cli-message-mention-warning-test-'));
533
+ const posts = [];
534
+ const slackApi = await startSlackApiMock((method, body) => {
535
+ if (method === 'users.list') {
536
+ return {
537
+ members: [{ id: 'U123', name: 'alice' }],
538
+ ok: true,
539
+ };
540
+ }
541
+ if (method === 'conversations.info') {
542
+ return {
543
+ channel: { id: 'C-product', is_channel: true, name: 'product', name_normalized: 'product' },
544
+ ok: true,
545
+ };
546
+ }
547
+ if (method === 'conversations.members') {
548
+ return {
549
+ members: [],
550
+ ok: true,
551
+ };
552
+ }
553
+ if (method !== 'chat.postMessage')
554
+ throw new Error(`unexpected method ${method}`);
555
+ posts.push(slackRequestBody(body));
556
+ return {
557
+ ok: true,
558
+ channel: 'C-product',
559
+ ts: '1770000200.000129',
560
+ };
561
+ });
562
+ try {
563
+ await withAnimaHome(stateDir, async () => {
564
+ await writeSlackConfig(stateDir);
565
+ const itemId = await ingestSlackThread(stateDir);
566
+ const send = await runNode([cliPath, 'message', 'send', '--channel', 'C-product'], {
567
+ env: { ...process.env, ANIMA_AGENT_ID: 'scout', ANIMA_HOME: stateDir, ANIMA_INBOX_ITEM_ID: itemId, ANIMA_SLACK_API_URL: slackApi.url },
568
+ input: 'Heads up @alice and @missing.',
569
+ });
570
+ assert.equal(send.status, 0, send.stderr || send.stdout);
571
+ assert.equal(send.stdout.trim(), 'sent successfully. channel=#product, message_ts=1770000200.000129. Warning: mention did not resolve: @missing. mentioned users not in #product: @alice.');
572
+ assert.deepEqual(posts, [{
573
+ blocks: JSON.stringify([{ type: 'markdown', text: 'Heads up <@U123> and @missing.' }]),
574
+ channel: 'C-product',
575
+ text: 'Heads up <@U123> and @missing.',
576
+ }]);
577
+ const completed = (await activitiesForInboxItemWindow('scout', itemId)).at(-1);
578
+ assert.deepEqual(completed?.payload?.['warnings'], [
579
+ 'mention did not resolve: @missing.',
580
+ 'mentioned users not in #product: @alice.',
581
+ ]);
582
+ });
583
+ }
584
+ finally {
585
+ await slackApi.close();
586
+ await rm(stateDir, { force: true, recursive: true });
587
+ }
588
+ });
589
+ test('message send only targets a thread when explicitly requested', async () => {
590
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-cli-message-thread-default-test-'));
591
+ const posts = [];
592
+ const slackApi = await startSlackApiMock((method, body) => {
593
+ if (method === 'conversations.list') {
594
+ return {
595
+ channels: [{ id: 'C-product', name: 'product', name_normalized: 'product' }],
596
+ ok: true,
597
+ };
598
+ }
599
+ if (method === 'conversations.info') {
600
+ return {
601
+ channel: { id: 'C-product', is_channel: true, name: 'product', name_normalized: 'product' },
602
+ ok: true,
603
+ };
604
+ }
605
+ if (method !== 'chat.postMessage')
606
+ throw new Error(`unexpected method ${method}`);
607
+ posts.push(slackRequestBody(body));
608
+ return {
609
+ ok: true,
610
+ channel: 'C-product',
611
+ ts: '1770000200.000124',
612
+ };
613
+ });
614
+ try {
615
+ await writeSlackConfig(stateDir);
616
+ const itemId = await ingestSlackThread(stateDir);
617
+ const send = await runNode([cliPath, 'message', 'send', '--channel', 'C-product'], {
618
+ env: {
619
+ ...process.env,
620
+ ANIMA_AGENT_ID: 'scout',
621
+ ANIMA_HOME: stateDir,
622
+ ANIMA_INBOX_ITEM_ID: itemId,
623
+ ANIMA_SLACK_API_URL: slackApi.url,
624
+ ANIMA_THREAD: '1770000200.000001',
625
+ ANIMA_THREAD_TS: '1770000200.000001',
626
+ },
627
+ input: 'Top-level by default.',
628
+ });
629
+ assert.equal(send.status, 0, send.stderr || send.stdout);
630
+ assert.deepEqual(posts, [
631
+ {
632
+ blocks: JSON.stringify([{ type: 'markdown', text: 'Top-level by default.' }]),
633
+ channel: 'C-product',
634
+ text: 'Top-level by default.',
635
+ },
636
+ ]);
637
+ }
638
+ finally {
639
+ await slackApi.close();
640
+ await rm(stateDir, { force: true, recursive: true });
641
+ }
642
+ });
643
+ test('message send uses a Slack markdown block while preserving one API call', async () => {
644
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-cli-message-send-blocks-test-'));
645
+ const posts = [];
646
+ const slackApi = await startSlackApiMock((method, body) => {
647
+ if (method === 'conversations.info') {
648
+ return {
649
+ channel: { id: 'C-product', is_channel: true, name: 'product', name_normalized: 'product' },
650
+ ok: true,
651
+ };
652
+ }
653
+ if (method !== 'chat.postMessage')
654
+ throw new Error(`unexpected method ${method}`);
655
+ posts.push(slackRequestBody(body));
656
+ return {
657
+ ok: true,
658
+ channel: 'C-product',
659
+ ts: '1770000200.000126',
660
+ };
661
+ });
662
+ try {
663
+ await withAnimaHome(stateDir, async () => {
664
+ await writeSlackConfig(stateDir);
665
+ const itemId = await ingestSlackThread(stateDir);
666
+ const longText = ['# Daily report', 'A'.repeat(1600), 'B'.repeat(1600), 'C'.repeat(1600)].join('\n\n');
667
+ const send = await runNode([cliPath, 'message', 'send', '--channel', 'C-product'], {
668
+ env: { ...process.env, ANIMA_AGENT_ID: 'scout', ANIMA_HOME: stateDir, ANIMA_INBOX_ITEM_ID: itemId, ANIMA_SLACK_API_URL: slackApi.url },
669
+ input: longText,
670
+ });
671
+ assert.equal(send.status, 0, send.stderr || send.stdout);
672
+ assert.equal(posts.length, 1);
673
+ assert.equal(posts[0]?.channel, 'C-product');
674
+ const blocks = slackBlocks(posts[0]);
675
+ assert.ok(blocks);
676
+ assert.equal(blocks.length, 1);
677
+ assert.equal(blocks[0]?.type, 'markdown');
678
+ assert.equal(blocks[0]?.text, longText);
679
+ assert.ok(posts[0]?.text.length <= 3900);
680
+ const completed = (await activitiesForInboxItemWindow('scout', itemId)).at(-1);
681
+ assert.equal(completed?.payload?.['messageFormat'], 'markdown');
682
+ assert.equal(completed?.payload?.['blockCount'], 1);
683
+ });
684
+ }
685
+ finally {
686
+ await slackApi.close();
687
+ await rm(stateDir, { force: true, recursive: true });
688
+ }
689
+ });
690
+ test('message read fetches a Slack thread through configured credentials', async () => {
691
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-cli-message-read-test-'));
692
+ const slackApi = await startSlackApiMock((method) => {
693
+ if (method === 'auth.test') {
694
+ return { ok: true, team_id: 'T-demo' };
695
+ }
696
+ if (method === 'conversations.info') {
697
+ return {
698
+ channel: { id: 'C-product', is_channel: true, name: 'product', name_normalized: 'product' },
699
+ ok: true,
700
+ };
701
+ }
702
+ if (method === 'users.info') {
703
+ return {
704
+ ok: true,
705
+ user: { id: 'U1', name: 'alice' },
706
+ };
707
+ }
708
+ if (method !== 'conversations.replies')
709
+ throw new Error(`unexpected method ${method}`);
710
+ return {
711
+ ok: true,
712
+ messages: [{ text: 'thread root', thread_ts: '1770000200.000001', ts: '1770000200.000001', type: 'message', user: 'U1' }],
713
+ };
714
+ });
715
+ try {
716
+ await writeSlackConfig(stateDir);
717
+ const read = await runNode([
718
+ cliPath,
719
+ 'message',
720
+ 'read',
721
+ '--channel',
722
+ 'C-product',
723
+ '--thread-ts',
724
+ '1770000200.000001',
725
+ ], {
726
+ env: { ...process.env, ANIMA_AGENT_ID: 'scout', ANIMA_HOME: stateDir, ANIMA_INBOX_ITEM_ID: '', ANIMA_SLACK_API_URL: slackApi.url },
727
+ });
728
+ assert.equal(read.status, 0, read.stderr || read.stdout);
729
+ assert.match(read.stdout, /\[channel=C-product thread_ts=1770000200\.000001 message_ts=1770000200\.000001/);
730
+ assert.match(read.stdout, /@alice: thread root/);
731
+ }
732
+ finally {
733
+ await slackApi.close();
734
+ await rm(stateDir, { force: true, recursive: true });
735
+ }
736
+ });
737
+ test('message update records an audited Slack output update', async () => {
738
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-cli-message-update-test-'));
739
+ const updates = [];
740
+ const slackApi = await startSlackApiMock((method, body) => {
741
+ if (method === 'users.list') {
742
+ return {
743
+ members: [{ id: 'U123', name: 'alice' }],
744
+ ok: true,
745
+ };
746
+ }
747
+ if (method === 'conversations.list') {
748
+ return {
749
+ channels: [{ id: 'C-product', name: 'product', name_normalized: 'product' }],
750
+ ok: true,
751
+ };
752
+ }
753
+ if (method === 'conversations.info') {
754
+ return {
755
+ channel: { id: 'C-product', is_channel: true, name: 'product', name_normalized: 'product' },
756
+ ok: true,
757
+ };
758
+ }
759
+ if (method === 'conversations.members') {
760
+ return {
761
+ members: ['U123'],
762
+ ok: true,
763
+ };
764
+ }
765
+ if (method !== 'chat.update')
766
+ throw new Error(`unexpected method ${method}`);
767
+ updates.push(slackRequestBody(body));
768
+ return {
769
+ ok: true,
770
+ channel: 'C-product',
771
+ ts: '1770000200.000123',
772
+ };
773
+ });
774
+ try {
775
+ await writeSlackConfig(stateDir);
776
+ const itemId = await ingestSlackThread(stateDir);
777
+ const update = await runNode([cliPath, 'message', 'update', '--channel', 'C-product', '--message-ts', '1770000200.000123'], {
778
+ env: { ...process.env, ANIMA_AGENT_ID: 'scout', ANIMA_HOME: stateDir, ANIMA_INBOX_ITEM_ID: itemId, ANIMA_SLACK_API_URL: slackApi.url },
779
+ input: 'Updated for @alice in #product.',
780
+ });
781
+ assert.equal(update.status, 0, update.stderr || update.stdout);
782
+ assert.equal(update.stdout.trim(), 'updated successfully. channel=#product, message_ts=1770000200.000123.');
783
+ assert.deepEqual(updates, [
784
+ {
785
+ blocks: JSON.stringify([{ type: 'markdown', text: 'Updated for <@U123> in <#C-product>.' }]),
786
+ channel: 'C-product',
787
+ text: 'Updated for <@U123> in <#C-product>.',
788
+ ts: '1770000200.000123',
789
+ },
790
+ ]);
791
+ const activities = await withAnimaHome(stateDir, async () => await activitiesForInboxItemWindow('scout', itemId));
792
+ const completed = activities.at(-1);
793
+ assert.equal(completed?.type, 'external.effect.completed');
794
+ assert.equal(completed?.payload?.['effect'], 'slack.message.update');
795
+ assert.equal(completed?.payload?.['tool'], 'anima.message.update');
796
+ assert.equal(completed?.payload?.['status'], 'updated');
797
+ assert.equal(completed?.payload?.['targetTs'], '1770000200.000123');
798
+ assert.equal(completed?.payload?.['text'], 'Updated for @alice in #product.');
799
+ assert.equal(completed?.payload?.['slackText'], 'Updated for <@U123> in <#C-product>.');
800
+ assert.equal(completed?.payload?.['channelKind'], 'channel');
801
+ assert.equal(completed?.payload?.['channelDisplayName'], '#product');
802
+ }
803
+ finally {
804
+ await slackApi.close();
805
+ await rm(stateDir, { force: true, recursive: true });
806
+ }
807
+ });
808
+ test('message update uses a Slack markdown block', async () => {
809
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-cli-message-update-blocks-test-'));
810
+ const updates = [];
811
+ const slackApi = await startSlackApiMock((method, body) => {
812
+ if (method === 'conversations.info') {
813
+ return {
814
+ channel: { id: 'C-product', is_channel: true, name: 'product', name_normalized: 'product' },
815
+ ok: true,
816
+ };
817
+ }
818
+ if (method !== 'chat.update')
819
+ throw new Error(`unexpected method ${method}`);
820
+ updates.push(slackRequestBody(body));
821
+ return {
822
+ ok: true,
823
+ channel: 'C-product',
824
+ ts: '1770000200.000127',
825
+ };
826
+ });
827
+ try {
828
+ await writeSlackConfig(stateDir);
829
+ const itemId = await ingestSlackThread(stateDir);
830
+ const longText = ['Update report', 'A'.repeat(1600), 'B'.repeat(1600), 'C'.repeat(1600)].join('\n\n');
831
+ const update = await runNode([cliPath, 'message', 'update', '--channel', 'C-product', '--message-ts', '1770000200.000123'], {
832
+ env: { ...process.env, ANIMA_AGENT_ID: 'scout', ANIMA_HOME: stateDir, ANIMA_INBOX_ITEM_ID: itemId, ANIMA_SLACK_API_URL: slackApi.url },
833
+ input: longText,
834
+ });
835
+ assert.equal(update.status, 0, update.stderr || update.stdout);
836
+ assert.equal(updates.length, 1);
837
+ assert.equal(updates[0]?.channel, 'C-product');
838
+ assert.equal(updates[0]?.ts, '1770000200.000123');
839
+ const blocks = slackBlocks(updates[0]);
840
+ assert.ok(blocks);
841
+ assert.equal(blocks.length, 1);
842
+ assert.equal(blocks[0]?.type, 'markdown');
843
+ assert.equal(blocks[0]?.text, longText);
844
+ const activities = await withAnimaHome(stateDir, async () => await activitiesForInboxItemWindow('scout', itemId));
845
+ const completed = activities.at(-1);
846
+ assert.equal(completed?.payload?.['messageFormat'], 'markdown');
847
+ assert.equal(completed?.payload?.['blockCount'], 1);
848
+ }
849
+ finally {
850
+ await slackApi.close();
851
+ await rm(stateDir, { force: true, recursive: true });
852
+ }
853
+ });
854
+ test('message send rejects text that cannot fit in one Slack markdown block', async () => {
855
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-cli-message-send-too-long-blocks-test-'));
856
+ let postCount = 0;
857
+ const slackApi = await startSlackApiMock((method) => {
858
+ if (method === 'conversations.info') {
859
+ return {
860
+ channel: { id: 'C-product', is_channel: true, name: 'product', name_normalized: 'product' },
861
+ ok: true,
862
+ };
863
+ }
864
+ if (method === 'chat.postMessage') {
865
+ postCount += 1;
866
+ return { ok: true, channel: 'C-product', ts: '1770000200.000128' };
867
+ }
868
+ throw new Error(`unexpected method ${method}`);
869
+ });
870
+ try {
871
+ await withAnimaHome(stateDir, async () => {
872
+ await writeSlackConfig(stateDir);
873
+ const itemId = await ingestSlackThread(stateDir);
874
+ const send = await runNode([cliPath, 'message', 'send', '--channel', 'C-product'], {
875
+ env: { ...process.env, ANIMA_AGENT_ID: 'scout', ANIMA_HOME: stateDir, ANIMA_INBOX_ITEM_ID: itemId, ANIMA_SLACK_API_URL: slackApi.url },
876
+ input: 'X'.repeat(12_001),
877
+ });
878
+ assert.notEqual(send.status, 0);
879
+ assert.match(send.stderr, /too long for Slack markdown block/);
880
+ assert.equal(postCount, 0);
881
+ });
882
+ }
883
+ finally {
884
+ await slackApi.close();
885
+ await rm(stateDir, { force: true, recursive: true });
886
+ }
887
+ });
888
+ test('reaction add records an audited reaction add', async () => {
889
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-cli-message-react-test-'));
890
+ const reactions = [];
891
+ const slackApi = await startSlackApiMock((method, body) => {
892
+ if (method === 'conversations.list') {
893
+ return {
894
+ channels: [{ id: 'C-product', name: 'product', name_normalized: 'product' }],
895
+ ok: true,
896
+ };
897
+ }
898
+ if (method === 'conversations.info') {
899
+ return {
900
+ channel: { id: 'C-product', is_channel: true, name: 'product', name_normalized: 'product' },
901
+ ok: true,
902
+ };
903
+ }
904
+ if (method !== 'reactions.add')
905
+ throw new Error(`unexpected method ${method}`);
906
+ reactions.push(slackRequestBody(body));
907
+ return { ok: true };
908
+ });
909
+ try {
910
+ await withAnimaHome(stateDir, async () => {
911
+ await writeSlackConfig(stateDir);
912
+ const itemId = await ingestSlackThread(stateDir);
913
+ const react = await runNode([cliPath, 'reaction', 'add', '--channel', 'C-product', '--message-ts', '1770000200.000123', '--name', ':white_check_mark:'], {
914
+ env: { ...process.env, ANIMA_AGENT_ID: 'scout', ANIMA_HOME: stateDir, ANIMA_INBOX_ITEM_ID: itemId, ANIMA_SLACK_API_URL: slackApi.url },
915
+ });
916
+ assert.equal(react.status, 0, react.stderr || react.stdout);
917
+ assert.equal(react.stdout.trim(), 'reaction added successfully. channel=#product, message_ts=1770000200.000123, reaction=:white_check_mark:.');
918
+ assert.deepEqual(reactions, [
919
+ { channel: 'C-product', name: 'white_check_mark', timestamp: '1770000200.000123' },
920
+ ]);
921
+ const completed = (await activitiesForInboxItemWindow('scout', itemId)).at(-1);
922
+ assert.equal(completed?.type, 'external.effect.completed');
923
+ assert.equal(completed?.payload?.['effect'], 'slack.reaction');
924
+ assert.equal(completed?.payload?.['tool'], 'anima.message.react');
925
+ assert.equal(completed?.payload?.['action'], 'added');
926
+ assert.equal(completed?.payload?.['name'], 'white_check_mark');
927
+ assert.equal(completed?.payload?.['targetTs'], '1770000200.000123');
928
+ assert.equal(completed?.payload?.['status'], 'added');
929
+ assert.equal(completed?.payload?.['channelDisplayName'], '#product');
930
+ });
931
+ }
932
+ finally {
933
+ await slackApi.close();
934
+ await rm(stateDir, { force: true, recursive: true });
935
+ }
936
+ });
937
+ test('reaction aliases accept react/message react and --emoji', async () => {
938
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-cli-message-react-alias-test-'));
939
+ const reactions = [];
940
+ const slackApi = await startSlackApiMock((method, body) => {
941
+ if (method === 'conversations.list') {
942
+ return { channels: [{ id: 'C-product', name: 'product', name_normalized: 'product' }], ok: true };
943
+ }
944
+ if (method === 'conversations.info') {
945
+ return { channel: { id: 'C-product', is_channel: true, name: 'product', name_normalized: 'product' }, ok: true };
946
+ }
947
+ if (method !== 'reactions.add')
948
+ throw new Error(`unexpected method ${method}`);
949
+ reactions.push(slackRequestBody(body));
950
+ return { ok: true };
951
+ });
952
+ try {
953
+ await withAnimaHome(stateDir, async () => {
954
+ await writeSlackConfig(stateDir);
955
+ const itemId = await ingestSlackThread(stateDir);
956
+ const env = {
957
+ ...process.env,
958
+ ANIMA_AGENT_ID: 'scout',
959
+ ANIMA_HOME: stateDir,
960
+ ANIMA_INBOX_ITEM_ID: itemId,
961
+ ANIMA_SLACK_API_URL: slackApi.url,
962
+ };
963
+ const topLevelAlias = await runNode([cliPath, 'react', 'add', '--channel', 'C-product', '--message-ts', '1770000200.000123', '--emoji', '+1'], { env });
964
+ assert.equal(topLevelAlias.status, 0, topLevelAlias.stderr || topLevelAlias.stdout);
965
+ const messageAlias = await runNode([cliPath, 'message', 'react', '--channel', 'C-product', '--message-ts', '1770000200.000124', '--emoji', 'white_check_mark'], { env });
966
+ assert.equal(messageAlias.status, 0, messageAlias.stderr || messageAlias.stdout);
967
+ const reactionDefaultAdd = await runNode([cliPath, 'reaction', '--channel', 'C-product', '--message-ts', '1770000200.000125', '--emoji', 'eyes'], { env });
968
+ assert.equal(reactionDefaultAdd.status, 0, reactionDefaultAdd.stderr || reactionDefaultAdd.stdout);
969
+ assert.deepEqual(reactions, [
970
+ { channel: 'C-product', name: '+1', timestamp: '1770000200.000123' },
971
+ { channel: 'C-product', name: 'white_check_mark', timestamp: '1770000200.000124' },
972
+ { channel: 'C-product', name: 'eyes', timestamp: '1770000200.000125' },
973
+ ]);
974
+ });
975
+ }
976
+ finally {
977
+ await slackApi.close();
978
+ await rm(stateDir, { force: true, recursive: true });
979
+ }
980
+ });
981
+ test('reaction remove calls reactions.remove and tolerates no_reaction as noop', async () => {
982
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-cli-message-react-remove-test-'));
983
+ const slackApi = await startSlackApiMock((method) => {
984
+ if (method === 'conversations.list') {
985
+ return { channels: [{ id: 'C-product', name: 'product', name_normalized: 'product' }], ok: true };
986
+ }
987
+ if (method === 'conversations.info') {
988
+ return { channel: { id: 'C-product', is_channel: true, name: 'product', name_normalized: 'product' }, ok: true };
989
+ }
990
+ if (method !== 'reactions.remove')
991
+ throw new Error(`unexpected method ${method}`);
992
+ return { ok: false, error: 'no_reaction' };
993
+ });
994
+ try {
995
+ await withAnimaHome(stateDir, async () => {
996
+ await writeSlackConfig(stateDir);
997
+ const itemId = await ingestSlackThread(stateDir);
998
+ const react = await runNode([cliPath, 'reaction', 'remove', '--channel', 'C-product', '--message-ts', '1770000200.000123', '--name', 'eyes'], {
999
+ env: { ...process.env, ANIMA_AGENT_ID: 'scout', ANIMA_HOME: stateDir, ANIMA_INBOX_ITEM_ID: itemId, ANIMA_SLACK_API_URL: slackApi.url },
1000
+ });
1001
+ assert.equal(react.status, 0, react.stderr || react.stdout);
1002
+ assert.equal(react.stdout.trim(), 'reaction already absent (noop). channel=#product, message_ts=1770000200.000123, reaction=:eyes:.');
1003
+ const completed = (await activitiesForInboxItemWindow('scout', itemId)).at(-1);
1004
+ assert.equal(completed?.type, 'external.effect.completed');
1005
+ assert.equal(completed?.payload?.['effect'], 'slack.reaction');
1006
+ assert.equal(completed?.payload?.['action'], 'removed');
1007
+ assert.equal(completed?.payload?.['noop'], true);
1008
+ });
1009
+ }
1010
+ finally {
1011
+ await slackApi.close();
1012
+ await rm(stateDir, { force: true, recursive: true });
1013
+ }
1014
+ });
1015
+ test('reaction commands require --channel, --message-ts, and --name', async () => {
1016
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-cli-message-react-validation-test-'));
1017
+ try {
1018
+ await writeSlackConfig(stateDir);
1019
+ const itemId = await ingestSlackThread(stateDir);
1020
+ const baseEnv = { ...process.env, ANIMA_AGENT_ID: 'scout', ANIMA_HOME: stateDir, ANIMA_INBOX_ITEM_ID: itemId };
1021
+ const noChannel = await runNode([cliPath, 'reaction', 'add', '--message-ts', '1770000200.000123', '--name', 'eyes'], { env: baseEnv });
1022
+ assert.notEqual(noChannel.status, 0);
1023
+ assert.match(noChannel.stderr, /requires --channel/);
1024
+ const noTs = await runNode([cliPath, 'reaction', 'add', '--channel', 'C-product', '--name', 'eyes'], { env: baseEnv });
1025
+ assert.notEqual(noTs.status, 0);
1026
+ assert.match(noTs.stderr, /requires --message-ts/);
1027
+ const noName = await runNode([cliPath, 'reaction', 'add', '--channel', 'C-product', '--message-ts', '1770000200.000123'], { env: baseEnv });
1028
+ assert.notEqual(noName.status, 0);
1029
+ assert.match(noName.stderr, /requires --name/);
1030
+ }
1031
+ finally {
1032
+ await rm(stateDir, { force: true, recursive: true });
1033
+ }
1034
+ });
1035
+ test('server preflights Socket Mode token before constructing the Slack app', async () => {
1036
+ const stateDir = await mkdtemp(join(tmpdir(), 'anima-cli-server-slack-preflight-test-'));
1037
+ const slackApi = await startSlackApiMock((method) => {
1038
+ if (method === 'auth.test')
1039
+ return { ok: true, user_id: 'U-scout' };
1040
+ if (method === 'apps.connections.open')
1041
+ return { error: 'invalid_auth', ok: false };
1042
+ throw new Error(`unexpected method ${method}`);
1043
+ });
1044
+ try {
1045
+ const homePath = join(stateDir, 'home');
1046
+ const agentDir = join(stateDir, 'agents', 'scout');
1047
+ await mkdir(homePath, { recursive: true });
1048
+ await mkdir(agentDir, { recursive: true });
1049
+ await writeFile(join(stateDir, 'config.json'), `${JSON.stringify({}, null, 2)}\n`, 'utf8');
1050
+ await writeFile(join(agentDir, 'config.json'), `${JSON.stringify({
1051
+ id: 'scout',
1052
+ provider: { kind: 'codex-cli', model: 'gpt-5.5' },
1053
+ slack: { appToken: 'xapp-valid-format', botToken: 'xoxb-valid-format' },
1054
+ homePath,
1055
+ }, null, 2)}\n`, 'utf8');
1056
+ const server = await runNode([adminCliPath, '--agent', 'scout', 'server'], {
1057
+ env: { ...process.env, ANIMA_HOME: stateDir, ANIMA_SLACK_API_URL: slackApi.url },
1058
+ });
1059
+ assert.notEqual(server.status, 0);
1060
+ assert.match(server.stderr || server.stdout, /apps\.connections\.open failed/);
1061
+ assert.doesNotMatch(server.stderr + server.stdout, /SocketModeClient/);
1062
+ }
1063
+ finally {
1064
+ await slackApi.close();
1065
+ await rm(stateDir, { force: true, recursive: true });
1066
+ }
1067
+ });
1068
+ test('message commands reject non-ts timestamp flags', async () => {
1069
+ const send = await runNode([cliPath, 'message', 'send', '--channel', 'C-product', '--thread', '1770000200.000001'], {
1070
+ input: 'legacy thread flag.',
1071
+ });
1072
+ assert.notEqual(send.status, 0);
1073
+ assert.match(send.stderr, /unknown option '--thread'/);
1074
+ const update = await runNode([cliPath, 'message', 'update', '--channel', 'C-product', '--ts', '1770000200.000123'], {
1075
+ input: 'legacy ts flag.',
1076
+ });
1077
+ assert.notEqual(update.status, 0);
1078
+ assert.match(update.stderr, /unknown option '--ts'/);
1079
+ });
1080
+ test('message commands reject agent and item flags', async () => {
1081
+ const agent = await runNode([cliPath, 'message', '--agent', 'scout', 'read', '--channel', 'C-product']);
1082
+ assert.notEqual(agent.status, 0);
1083
+ assert.match(agent.stderr, /unknown option '--agent'/);
1084
+ const item = await runNode([cliPath, 'message', '--item', 'turn_123', 'send', '--channel', 'C-product'], {
1085
+ input: 'legacy item flag.',
1086
+ });
1087
+ assert.notEqual(item.status, 0);
1088
+ assert.match(item.stderr, /unknown option '--item'/);
1089
+ });
1090
+ async function writeSlackConfig(configDir, slack = {}, profile = {}) {
1091
+ await mkdir(configDir, { recursive: true });
1092
+ const agent = {
1093
+ id: 'scout',
1094
+ profile,
1095
+ slack: {
1096
+ appToken: slack.appToken ?? 'xapp-test',
1097
+ botToken: slack.botToken ?? 'xoxb-test',
1098
+ teamId: slack.teamId ?? 'T-demo',
1099
+ },
1100
+ };
1101
+ const agentDir = join(configDir, 'agents', agent.id);
1102
+ await mkdir(agentDir, { recursive: true });
1103
+ await writeFile(join(configDir, 'config.json'), `${JSON.stringify({}, null, 2)}\n`, 'utf8');
1104
+ await writeFile(join(agentDir, 'config.json'), `${JSON.stringify(agent, null, 2)}\n`, 'utf8');
1105
+ }
1106
+ async function ingestSlackThread(stateDir) {
1107
+ return withAnimaHome(stateDir, async () => {
1108
+ const ctx = await ingestEvent(exampleSlackThread(), { agentId: 'scout', stateDir });
1109
+ return ctx.item.id;
1110
+ });
1111
+ }
1112
+ function exampleSlackThread() {
1113
+ return makeSlackEvent({
1114
+ channelId: 'C-product',
1115
+ channelName: 'product',
1116
+ eventId: 'evt_example_slack_thread',
1117
+ teamId: 'T-demo',
1118
+ text: 'Can you turn this discussion into a tracked product spike?',
1119
+ timestamp: '2026-05-11T00:00:00.000Z',
1120
+ threadTs: '1770000200.000001',
1121
+ ts: '1770000200.000002',
1122
+ userId: 'U-alice',
1123
+ });
1124
+ }
1125
+ async function runNode(args, options = {}) {
1126
+ const child = spawn(process.execPath, args, {
1127
+ cwd: options.cwd,
1128
+ env: options.env,
1129
+ stdio: ['pipe', 'pipe', 'pipe'],
1130
+ });
1131
+ let stdout = '';
1132
+ let stderr = '';
1133
+ child.stdout.setEncoding('utf8');
1134
+ child.stderr.setEncoding('utf8');
1135
+ child.stdout.on('data', (chunk) => {
1136
+ stdout += chunk;
1137
+ });
1138
+ child.stderr.on('data', (chunk) => {
1139
+ stderr += chunk;
1140
+ });
1141
+ child.stdin.end(options.input);
1142
+ const [status] = (await once(child, 'exit'));
1143
+ return { status, stderr, stdout };
1144
+ }
1145
+ async function startSlackApiMock(handler) {
1146
+ const server = createServer(async (request, response) => {
1147
+ const body = await readBody(request);
1148
+ try {
1149
+ const pathname = new URL(request.url ?? '/', 'http://127.0.0.1').pathname;
1150
+ const normalizedMethod = pathname.replace(/^\/api\//, '');
1151
+ const payload = handler(normalizedMethod, body);
1152
+ response.writeHead(200, { 'content-type': 'application/json' });
1153
+ response.end(JSON.stringify(payload));
1154
+ }
1155
+ catch (error) {
1156
+ response.writeHead(500, { 'content-type': 'application/json' });
1157
+ response.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error), ok: false }));
1158
+ }
1159
+ });
1160
+ server.listen(0, '127.0.0.1');
1161
+ await once(server, 'listening');
1162
+ const address = server.address();
1163
+ if (!address || typeof address === 'string') {
1164
+ throw new Error('Expected Slack API mock to listen on a TCP address.');
1165
+ }
1166
+ return {
1167
+ close: async () => {
1168
+ server.close();
1169
+ await once(server, 'close');
1170
+ },
1171
+ url: `http://127.0.0.1:${address.port}/api`,
1172
+ };
1173
+ }
1174
+ function slackRequestBody(body) {
1175
+ try {
1176
+ return JSON.parse(body);
1177
+ }
1178
+ catch {
1179
+ return Object.fromEntries(new URLSearchParams(body));
1180
+ }
1181
+ }
1182
+ function slackBlocks(body) {
1183
+ if (Array.isArray(body.blocks)) {
1184
+ return body.blocks;
1185
+ }
1186
+ assert.equal(typeof body.blocks, 'string');
1187
+ return JSON.parse(body.blocks);
1188
+ }
1189
+ async function readBody(request) {
1190
+ let body = '';
1191
+ request.setEncoding('utf8');
1192
+ for await (const chunk of request) {
1193
+ body += chunk;
1194
+ }
1195
+ return body;
1196
+ }