kimaki 1.3.0 → 1.5.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 (346) hide show
  1. package/assets/fonts/Geist-Bold.ttf +0 -0
  2. package/assets/fonts/Geist-Regular.ttf +0 -0
  3. package/assets/fonts/GeistMono-Regular.ttf +0 -0
  4. package/assets/fonts/OFL.txt +92 -0
  5. package/dist/agent-ui.e2e.test.js +27 -13
  6. package/dist/agent-ui.e2e.test.js.map +1 -1
  7. package/dist/agent-ui.js +2 -1
  8. package/dist/agent-ui.js.map +1 -1
  9. package/dist/analytics.e2e.test.js +1 -1
  10. package/dist/analytics.e2e.test.js.map +1 -1
  11. package/dist/autostart.d.ts +4 -0
  12. package/dist/autostart.js +55 -10
  13. package/dist/autostart.js.map +1 -1
  14. package/dist/bot.d.ts +13 -6
  15. package/dist/bot.js +17 -15
  16. package/dist/bot.js.map +1 -1
  17. package/dist/btw.e2e.test.js +3 -3
  18. package/dist/builtin-skills.d.ts +3 -0
  19. package/dist/builtin-skills.js +29 -0
  20. package/dist/builtin-skills.js.map +1 -1
  21. package/dist/builtin-skills.test.js +72 -1
  22. package/dist/builtin-skills.test.js.map +1 -1
  23. package/dist/catalog-refresh.e2e.test.js +2 -2
  24. package/dist/cli/bot.d.ts +1 -1
  25. package/dist/cli/bot.js +33 -18
  26. package/dist/cli/bot.js.map +1 -1
  27. package/dist/cli/discord.js +32 -6
  28. package/dist/cli/discord.js.map +1 -1
  29. package/dist/cli/schedule.js +23 -9
  30. package/dist/cli/schedule.js.map +1 -1
  31. package/dist/cli/session.js +37 -12
  32. package/dist/cli/session.js.map +1 -1
  33. package/dist/cli/shared.d.ts +5 -3
  34. package/dist/cli/shared.js +45 -10
  35. package/dist/cli/shared.js.map +1 -1
  36. package/dist/cli-discord.e2e.test.js +13 -8
  37. package/dist/cli-discord.e2e.test.js.map +1 -1
  38. package/dist/cli-lock.e2e.test.js +87 -20
  39. package/dist/cli-lock.e2e.test.js.map +1 -1
  40. package/dist/cli-read.e2e.test.js +18 -9
  41. package/dist/cli-read.e2e.test.js.map +1 -1
  42. package/dist/cli-sessions.e2e.test.js +4 -3
  43. package/dist/cli-sessions.e2e.test.js.map +1 -1
  44. package/dist/cli.js +2 -0
  45. package/dist/cli.js.map +1 -1
  46. package/dist/commands/preference-commands.d.ts +4 -0
  47. package/dist/commands/preference-commands.js +38 -4
  48. package/dist/commands/preference-commands.js.map +1 -1
  49. package/dist/commands/session-commands.js +11 -8
  50. package/dist/commands/session-commands.js.map +1 -1
  51. package/dist/commands/thread-commands.js +94 -24
  52. package/dist/commands/thread-commands.js.map +1 -1
  53. package/dist/commands/worktree-commands.d.ts +5 -6
  54. package/dist/commands/worktree-commands.js +8 -7
  55. package/dist/commands/worktree-commands.js.map +1 -1
  56. package/dist/commands.e2e.test.js +25 -21
  57. package/dist/commands.e2e.test.js.map +1 -1
  58. package/dist/coordinator-agents.e2e.test.d.ts +1 -0
  59. package/dist/coordinator-agents.e2e.test.js +113 -0
  60. package/dist/coordinator-agents.e2e.test.js.map +1 -0
  61. package/dist/coordinator-idle.e2e.test.d.ts +1 -0
  62. package/dist/coordinator-idle.e2e.test.js +100 -0
  63. package/dist/coordinator-idle.e2e.test.js.map +1 -0
  64. package/dist/coordinator-queue.e2e.test.d.ts +1 -0
  65. package/dist/coordinator-queue.e2e.test.js +244 -0
  66. package/dist/coordinator-queue.e2e.test.js.map +1 -0
  67. package/dist/coordinator-warmup.e2e.test.d.ts +1 -0
  68. package/dist/coordinator-warmup.e2e.test.js +131 -0
  69. package/dist/coordinator-warmup.e2e.test.js.map +1 -0
  70. package/dist/coordinator.d.ts +6 -0
  71. package/dist/coordinator.e2e.test.d.ts +1 -0
  72. package/dist/coordinator.e2e.test.js +505 -0
  73. package/dist/coordinator.e2e.test.js.map +1 -0
  74. package/dist/coordinator.js +164 -0
  75. package/dist/coordinator.js.map +1 -0
  76. package/dist/db.d.ts +78 -3
  77. package/dist/db.test.js +1 -0
  78. package/dist/db.test.js.map +1 -1
  79. package/dist/diagram.d.ts +11 -0
  80. package/dist/diagram.js +232 -0
  81. package/dist/diagram.js.map +1 -0
  82. package/dist/diagram.test.d.ts +1 -0
  83. package/dist/diagram.test.js +52 -0
  84. package/dist/diagram.test.js.map +1 -0
  85. package/dist/duration.js +13 -5
  86. package/dist/duration.js.map +1 -1
  87. package/dist/effects.d.ts +6 -1
  88. package/dist/effects.js +97 -26
  89. package/dist/effects.js.map +1 -1
  90. package/dist/event-loop.d.ts +2 -3
  91. package/dist/event-loop.js +74 -44
  92. package/dist/event-loop.js.map +1 -1
  93. package/dist/format-parts.d.ts +21 -4
  94. package/dist/format-parts.js +40 -23
  95. package/dist/format-parts.js.map +1 -1
  96. package/dist/gateway-replay.e2e.test.js +4 -4
  97. package/dist/harness.e2e.test.js +2 -2
  98. package/dist/harness.e2e.test.js.map +1 -1
  99. package/dist/hello-thread.e2e.test.js +56 -5
  100. package/dist/hello-thread.e2e.test.js.map +1 -1
  101. package/dist/ingress.js +47 -12
  102. package/dist/ingress.js.map +1 -1
  103. package/dist/interaction-context.d.ts +4 -4
  104. package/dist/interaction-context.js +9 -7
  105. package/dist/interaction-context.js.map +1 -1
  106. package/dist/interrupt.e2e.test.js +8 -5
  107. package/dist/interrupt.e2e.test.js.map +1 -1
  108. package/dist/legacy.e2e.test.js +1 -1
  109. package/dist/lock-routes.d.ts +83 -31
  110. package/dist/lock-routes.js +68 -8
  111. package/dist/lock-routes.js.map +1 -1
  112. package/dist/lock-server.d.ts +3 -2
  113. package/dist/lock-server.js +23 -7
  114. package/dist/lock-server.js.map +1 -1
  115. package/dist/main.d.ts +6 -3
  116. package/dist/main.js +14 -4
  117. package/dist/main.js.map +1 -1
  118. package/dist/mainmast-idle.e2e.test.d.ts +1 -0
  119. package/dist/mainmast-idle.e2e.test.js +99 -0
  120. package/dist/mainmast-idle.e2e.test.js.map +1 -0
  121. package/dist/mainmast-queue.e2e.test.d.ts +1 -0
  122. package/dist/mainmast-queue.e2e.test.js +244 -0
  123. package/dist/mainmast-queue.e2e.test.js.map +1 -0
  124. package/dist/mainmast-warmup.e2e.test.d.ts +1 -0
  125. package/dist/mainmast-warmup.e2e.test.js +130 -0
  126. package/dist/mainmast-warmup.e2e.test.js.map +1 -0
  127. package/dist/mainmast.d.ts +6 -0
  128. package/dist/mainmast.e2e.test.d.ts +1 -0
  129. package/dist/mainmast.e2e.test.js +428 -0
  130. package/dist/mainmast.e2e.test.js.map +1 -0
  131. package/dist/mainmast.js +156 -0
  132. package/dist/mainmast.js.map +1 -0
  133. package/dist/markdown/markdown.test.js +14 -4
  134. package/dist/markdown/markdown.test.js.map +1 -1
  135. package/dist/markdown/render-markdown.js +9 -6
  136. package/dist/markdown/render-markdown.js.map +1 -1
  137. package/dist/onboarding-cli.e2e.test.js +7 -6
  138. package/dist/onboarding-cli.e2e.test.js.map +1 -1
  139. package/dist/onboarding.d.ts +3 -10
  140. package/dist/onboarding.e2e.test.js +8 -10
  141. package/dist/onboarding.e2e.test.js.map +1 -1
  142. package/dist/onboarding.js +14 -24
  143. package/dist/onboarding.js.map +1 -1
  144. package/dist/permissions.e2e.test.js +3 -3
  145. package/dist/plugin/coordinator.d.ts +4 -0
  146. package/dist/plugin/coordinator.js +389 -0
  147. package/dist/plugin/coordinator.js.map +1 -0
  148. package/dist/plugin/index.js +93 -61
  149. package/dist/plugin/index.js.map +1 -1
  150. package/dist/plugin/mainmast.d.ts +4 -0
  151. package/dist/plugin/mainmast.js +379 -0
  152. package/dist/plugin/mainmast.js.map +1 -0
  153. package/dist/plugin.e2e.test.js +1 -1
  154. package/dist/preference-commands.e2e.test.js +103 -24
  155. package/dist/preference-commands.e2e.test.js.map +1 -1
  156. package/dist/project.d.ts +1 -1
  157. package/dist/prompt.d.ts +22 -9
  158. package/dist/prompt.js +64 -48
  159. package/dist/prompt.js.map +1 -1
  160. package/dist/questions.e2e.test.js +13 -13
  161. package/dist/queue-sources.e2e.test.d.ts +1 -0
  162. package/dist/queue-sources.e2e.test.js +168 -0
  163. package/dist/queue-sources.e2e.test.js.map +1 -0
  164. package/dist/queue.d.ts +9 -1
  165. package/dist/queue.e2e.test.js +48 -30
  166. package/dist/queue.e2e.test.js.map +1 -1
  167. package/dist/queue.js +97 -24
  168. package/dist/queue.js.map +1 -1
  169. package/dist/queue.test.d.ts +1 -0
  170. package/dist/queue.test.js +57 -0
  171. package/dist/queue.test.js.map +1 -0
  172. package/dist/remote-send.e2e.test.js +6 -6
  173. package/dist/remote-send.js +2 -1
  174. package/dist/remote-send.js.map +1 -1
  175. package/dist/renderer.e2e.test.js +7 -7
  176. package/dist/restart.e2e.test.js +2 -2
  177. package/dist/routines.e2e.test.js +2 -2
  178. package/dist/scheduler.d.ts +3 -3
  179. package/dist/scheduler.e2e.test.js +43 -20
  180. package/dist/scheduler.e2e.test.js.map +1 -1
  181. package/dist/scheduler.js +4 -2
  182. package/dist/scheduler.js.map +1 -1
  183. package/dist/schema-sql.d.ts +1 -1
  184. package/dist/schema-sql.js +1 -1
  185. package/dist/schema-sql.js.map +1 -1
  186. package/dist/schema.d.ts +152 -6
  187. package/dist/schema.js +12 -0
  188. package/dist/schema.js.map +1 -1
  189. package/dist/session-commands.e2e.test.js +36 -11
  190. package/dist/session-commands.e2e.test.js.map +1 -1
  191. package/dist/session-events.d.ts +18 -6
  192. package/dist/session-events.js +175 -27
  193. package/dist/session-events.js.map +1 -1
  194. package/dist/session-ops.e2e.test.js +63 -12
  195. package/dist/session-ops.e2e.test.js.map +1 -1
  196. package/dist/sessions.d.ts +44 -21
  197. package/dist/sessions.js +177 -65
  198. package/dist/sessions.js.map +1 -1
  199. package/dist/shell.e2e.test.js +4 -4
  200. package/dist/slash-commands.js +6 -4
  201. package/dist/slash-commands.js.map +1 -1
  202. package/dist/slash-commands.test.js +2 -0
  203. package/dist/slash-commands.test.js.map +1 -1
  204. package/dist/sleep.e2e.test.js +29 -14
  205. package/dist/sleep.e2e.test.js.map +1 -1
  206. package/dist/sleeps.d.ts +2 -1
  207. package/dist/sleeps.js +21 -3
  208. package/dist/sleeps.js.map +1 -1
  209. package/dist/store.d.ts +1 -0
  210. package/dist/store.js +3 -2
  211. package/dist/store.js.map +1 -1
  212. package/dist/system-prompt.d.ts +5 -15
  213. package/dist/system-prompt.js +180 -131
  214. package/dist/system-prompt.js.map +1 -1
  215. package/dist/test/generate-log-plugin/index.d.ts +3 -0
  216. package/dist/test/generate-log-plugin/index.js +15 -0
  217. package/dist/test/generate-log-plugin/index.js.map +1 -0
  218. package/dist/test/harness.d.ts +20 -2
  219. package/dist/test/harness.js +51 -11
  220. package/dist/test/harness.js.map +1 -1
  221. package/dist/test/replay.js +11 -8
  222. package/dist/test/replay.js.map +1 -1
  223. package/dist/thread-reducer.d.ts +29 -6
  224. package/dist/thread-reducer.fixtures.test.js +58 -51
  225. package/dist/thread-reducer.fixtures.test.js.map +1 -1
  226. package/dist/thread-reducer.js +109 -50
  227. package/dist/thread-reducer.js.map +1 -1
  228. package/dist/thread-reducer.test.js +190 -48
  229. package/dist/thread-reducer.test.js.map +1 -1
  230. package/dist/tunnel.d.ts +9 -0
  231. package/dist/tunnel.js +117 -28
  232. package/dist/tunnel.js.map +1 -1
  233. package/dist/tunnel.test.js +81 -5
  234. package/dist/tunnel.test.js.map +1 -1
  235. package/dist/voice-attachments.e2e.test.js +108 -18
  236. package/dist/voice-attachments.e2e.test.js.map +1 -1
  237. package/dist/voice-call.e2e.test.js +9 -9
  238. package/dist/voice-call.js +6 -6
  239. package/dist/voice-call.js.map +1 -1
  240. package/dist/voice-openai.e2e.test.js +59 -23
  241. package/dist/voice-openai.e2e.test.js.map +1 -1
  242. package/dist/voice.d.ts +1 -1
  243. package/dist/voice.js +72 -24
  244. package/dist/voice.js.map +1 -1
  245. package/dist/working-directory.e2e.test.js +11 -11
  246. package/dist/working-directory.e2e.test.js.map +1 -1
  247. package/package.json +11 -5
  248. package/skills/kimaki-tunnel/SKILL.md +3 -2
  249. package/src/agent-ui.e2e.test.ts +27 -13
  250. package/src/agent-ui.ts +2 -1
  251. package/src/analytics.e2e.test.ts +1 -1
  252. package/src/autostart.ts +55 -10
  253. package/src/bot.ts +36 -19
  254. package/src/btw.e2e.test.ts +3 -3
  255. package/src/builtin-skills.test.ts +73 -1
  256. package/src/builtin-skills.ts +33 -0
  257. package/src/catalog-refresh.e2e.test.ts +2 -2
  258. package/src/cli/bot.ts +30 -19
  259. package/src/cli/discord.ts +30 -6
  260. package/src/cli/schedule.ts +23 -9
  261. package/src/cli/session.ts +35 -12
  262. package/src/cli/shared.ts +43 -8
  263. package/src/cli-discord.e2e.test.ts +13 -8
  264. package/src/cli-lock.e2e.test.ts +88 -20
  265. package/src/cli-read.e2e.test.ts +17 -8
  266. package/src/cli-sessions.e2e.test.ts +4 -3
  267. package/src/cli.ts +2 -0
  268. package/src/commands/preference-commands.ts +38 -4
  269. package/src/commands/session-commands.ts +12 -7
  270. package/src/commands/thread-commands.ts +92 -24
  271. package/src/commands/worktree-commands.ts +10 -9
  272. package/src/commands.e2e.test.ts +25 -21
  273. package/src/coordinator-agents.e2e.test.ts +145 -0
  274. package/src/coordinator-idle.e2e.test.ts +122 -0
  275. package/src/coordinator-queue.e2e.test.ts +279 -0
  276. package/src/coordinator-warmup.e2e.test.ts +158 -0
  277. package/src/coordinator.e2e.test.ts +545 -0
  278. package/src/coordinator.ts +168 -0
  279. package/src/db.test.ts +1 -0
  280. package/src/diagram.test.ts +56 -0
  281. package/src/diagram.ts +243 -0
  282. package/src/duration.ts +13 -5
  283. package/src/effects.ts +99 -27
  284. package/src/event-loop.ts +71 -45
  285. package/src/format-parts.ts +53 -22
  286. package/src/gateway-replay.e2e.test.ts +4 -4
  287. package/src/harness.e2e.test.ts +2 -2
  288. package/src/hello-thread.e2e.test.ts +59 -5
  289. package/src/ingress.ts +44 -11
  290. package/src/interaction-context.ts +12 -11
  291. package/src/interrupt.e2e.test.ts +8 -5
  292. package/src/legacy.e2e.test.ts +1 -1
  293. package/src/lock-routes.ts +62 -8
  294. package/src/lock-server.ts +23 -7
  295. package/src/main.ts +26 -10
  296. package/src/markdown/markdown.test.ts +15 -4
  297. package/src/markdown/render-markdown.ts +9 -6
  298. package/src/onboarding-cli.e2e.test.ts +7 -6
  299. package/src/onboarding.e2e.test.ts +8 -10
  300. package/src/onboarding.ts +13 -29
  301. package/src/permissions.e2e.test.ts +3 -3
  302. package/src/plugin/coordinator.ts +401 -0
  303. package/src/plugin/index.ts +86 -59
  304. package/src/plugin.e2e.test.ts +1 -1
  305. package/src/preference-commands.e2e.test.ts +103 -23
  306. package/src/prompt.ts +87 -43
  307. package/src/questions.e2e.test.ts +13 -13
  308. package/src/queue-sources.e2e.test.ts +197 -0
  309. package/src/queue.e2e.test.ts +48 -30
  310. package/src/queue.test.ts +59 -0
  311. package/src/queue.ts +104 -24
  312. package/src/remote-send.e2e.test.ts +6 -6
  313. package/src/remote-send.ts +2 -1
  314. package/src/renderer.e2e.test.ts +7 -7
  315. package/src/restart.e2e.test.ts +2 -2
  316. package/src/routines.e2e.test.ts +2 -2
  317. package/src/scheduler.e2e.test.ts +45 -19
  318. package/src/scheduler.ts +4 -2
  319. package/src/schema-sql.ts +1 -1
  320. package/src/schema.ts +13 -0
  321. package/src/session-commands.e2e.test.ts +38 -11
  322. package/src/session-events.ts +168 -25
  323. package/src/session-ops.e2e.test.ts +64 -11
  324. package/src/sessions.ts +217 -67
  325. package/src/shell.e2e.test.ts +4 -4
  326. package/src/slash-commands.test.ts +2 -0
  327. package/src/slash-commands.ts +6 -4
  328. package/src/sleep.e2e.test.ts +30 -13
  329. package/src/sleeps.ts +20 -3
  330. package/src/store.ts +5 -2
  331. package/src/system-prompt.ts +190 -134
  332. package/src/test/generate-log-plugin/index.ts +16 -0
  333. package/src/test/harness.ts +77 -17
  334. package/src/test/replay.ts +12 -7
  335. package/src/thread-reducer.fixtures.test.ts +58 -51
  336. package/src/thread-reducer.test.ts +199 -49
  337. package/src/thread-reducer.ts +135 -66
  338. package/src/tunnel.test.ts +81 -5
  339. package/src/tunnel.ts +124 -25
  340. package/src/voice-attachments.e2e.test.ts +110 -18
  341. package/src/voice-call.e2e.test.ts +9 -9
  342. package/src/voice-call.ts +6 -6
  343. package/src/voice-openai.e2e.test.ts +64 -24
  344. package/src/voice.ts +93 -26
  345. package/src/working-directory.e2e.test.ts +11 -11
  346. package/src/message-tool.e2e.test.ts +0 -45
@@ -0,0 +1,545 @@
1
+ // Coordinator channels: the channel is the conversation of one long session, it
2
+ // starts worker threads with `kimaki send --parent-session`, and a finished
3
+ // worker wakes it with a <session-finished> note.
4
+
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
7
+ import { afterAll, beforeAll, expect, test } from 'vitest'
8
+
9
+ import type { BotHandle } from './main.ts'
10
+ import { send } from './prompt.ts'
11
+ import { readSessionMarkdown } from './session-events.ts'
12
+ import {
13
+ TEST_USER_ID,
14
+ isFooter,
15
+ scriptedTurn,
16
+ seedProjectChannel,
17
+ startOpencodeTestServer,
18
+ startTestBot,
19
+ startTwin,
20
+ tempDataDir,
21
+ textParts,
22
+ waitFor,
23
+ waitForFooter,
24
+ warmUp,
25
+ type OpencodeTestServer,
26
+ type TestTwin,
27
+ } from './test/harness.ts'
28
+
29
+ let server: OpencodeTestServer
30
+ let twin: TestTwin
31
+ let bot: BotHandle
32
+ const dataDir = tempDataDir()
33
+
34
+ beforeAll(async () => {
35
+ // The twin first: the delegating matcher needs its channel ID.
36
+ twin = await startTwin()
37
+ server = await startOpencodeTestServer({
38
+ matchers: [
39
+ { id: 'hello', priority: 100, when: { latestUserTextIncludes: 'hello-marker' }, then: { parts: textParts('Hello from the coordinator.') } },
40
+ // The coordinator delegates: one shell call to `kimaki send`, then IDLE (the new thread already shows).
41
+ ...scriptedTurn({
42
+ marker: 'delegate-marker',
43
+ steps: [{
44
+ id: 'call-send-worker',
45
+ tool: 'shell',
46
+ input: {
47
+ command: `kimaki send --channel ${twin.channelId} --name 'Worker task' --prompt 'worker-marker do the job' --parent-session "$OPENCODE_SESSION_ID"`,
48
+ description: 'Start the worker thread',
49
+ hasSideEffect: true,
50
+ },
51
+ }],
52
+ finalText: 'IDLE',
53
+ }),
54
+ // The default: the coordinator waits for the worker in the same shell call.
55
+ ...scriptedTurn({
56
+ marker: 'waitjob-marker',
57
+ steps: [{
58
+ id: 'call-send-wait',
59
+ tool: 'shell',
60
+ input: {
61
+ command: `kimaki send --channel ${twin.channelId} --name 'Waited task' --prompt 'waited-worker-marker do the job' --parent-session "$OPENCODE_SESSION_ID" --wait`,
62
+ description: 'Run the worker and wait',
63
+ hasSideEffect: true,
64
+ },
65
+ }],
66
+ finalText: 'The waited worker is done.',
67
+ }),
68
+ { id: 'waited-worker', priority: 100, when: { latestUserTextIncludes: 'waited-worker-marker' }, then: { parts: textParts('Waited job done.') } },
69
+ // A bounded wait runs out; the coordinator is still busy when the worker ends, so the note must still come.
70
+ ...scriptedTurn({
71
+ marker: 'boundedwait-marker',
72
+ steps: [
73
+ {
74
+ id: 'call-send-bounded',
75
+ tool: 'shell',
76
+ input: {
77
+ command: `kimaki send --channel ${twin.channelId} --name 'Bounded task' --prompt 'boundedwait-job-marker do the job' --parent-session "$OPENCODE_SESSION_ID" --wait 0.1`,
78
+ description: 'Start the worker, wait briefly',
79
+ hasSideEffect: true,
80
+ },
81
+ },
82
+ { id: 'call-stay-busy', tool: 'shell', input: { command: 'sleep 2', description: 'Stay busy', hasSideEffect: false } },
83
+ ],
84
+ finalText: 'IDLE',
85
+ }),
86
+ { id: 'bounded-worker', priority: 100, when: { latestUserTextIncludes: 'boundedwait-job-marker' }, then: { parts: textParts('Bounded job done.'), defaultPartDelayMs: 100 } },
87
+ { id: 'woken-by-bounded', priority: 345, when: { latestUserTextRegex: '<session-finished[\\s\\S]*Bounded job done' }, then: { parts: textParts('The bounded job finished.') } },
88
+ // Slow enough that the delegating run ends first: the note then wakes an idle coordinator.
89
+ { id: 'worker', priority: 100, when: { latestUserTextIncludes: 'worker-marker' }, then: { parts: textParts('Job done in the worker.'), defaultPartDelayMs: 300 } },
90
+ // A worker that uploads a screenshot; session read lists its URL, the note does not.
91
+ ...scriptedTurn({
92
+ marker: 'upload-marker',
93
+ steps: [{ id: 'call-upload', tool: 'shell', input: { command: 'kimaki upload-to-discord shot.png', description: 'Upload the screenshot', hasSideEffect: true } }],
94
+ finalText: 'Took the screenshot.',
95
+ }),
96
+ { id: 'woken-with-media', priority: 320, when: { latestUserTextRegex: '<session-finished[\\s\\S]*Took the screenshot' }, then: { parts: textParts('Here is the screenshot.') } },
97
+ { id: 'user-steer', priority: 100, when: { latestUserTextIncludes: 'user-steer-marker' }, then: { parts: textParts('Steered job done.') } },
98
+ { id: 'follow-up', priority: 100, when: { latestUserTextIncludes: 'follow-up-marker' }, then: { parts: textParts('Follow-up done.') } },
99
+ { id: 'woken-by-follow-up', priority: 340, when: { latestUserTextRegex: '<session-finished[\\s\\S]*Follow-up done' }, then: { parts: textParts('The follow-up finished.') } },
100
+ { id: 'fork', priority: 100, when: { latestUserTextIncludes: 'fork-marker' }, then: { parts: textParts('Fork job done.') } },
101
+ { id: 'slow-job', priority: 100, when: { latestUserTextIncludes: 'slow-job-marker' }, then: { parts: textParts('Slow job done.'), defaultPartDelayMs: 500 } },
102
+ { id: 'woken-by-slow-job', priority: 330, when: { latestUserTextRegex: '<session-finished[\\s\\S]*Slow job done' }, then: { parts: textParts('The slow job finished.') } },
103
+ { id: 'woken-by-fork', priority: 310, when: { latestUserTextIncludes: '<session-finished', rawPromptIncludes: 'Fork job done.' }, then: { parts: textParts('The fork finished the job.') } },
104
+ { id: 'btw', priority: 100, when: { latestUserTextIncludes: 'btw-marker' }, then: { parts: textParts('Side answer.') } },
105
+ { id: 'side', priority: 100, when: { latestUserTextIncludes: 'side-marker' }, then: { parts: textParts('Side check done.') } },
106
+ // The silent note of the side thread is in the history only when it arrived.
107
+ { id: 'recap-seen', priority: 200, when: { latestUserTextIncludes: 'recap-marker', rawPromptIncludes: 'Side check done.' }, then: { parts: textParts('A side thread finished meanwhile.') } },
108
+ { id: 'recap-unseen', priority: 150, when: { latestUserTextIncludes: 'recap-marker' }, then: { parts: textParts('Nothing new.') } },
109
+ { id: 'woken', priority: 300, when: { latestUserTextIncludes: '<session-finished' }, then: { parts: textParts('The worker finished the job.') } },
110
+ { id: 'stale-wake', priority: 350, when: { latestUserTextIncludes: 'stale-wake-marker' }, then: { parts: textParts('Woke on the stale role text.') } },
111
+ ],
112
+ })
113
+ await seedProjectChannel({ dataDir, channelId: twin.channelId, guildId: twin.discord.guildId, directory: server.projectDirectory })
114
+ await warmUp({ server })
115
+ bot = await startTestBot({ dataDir, twin, server, coordinator: true })
116
+ }, 60_000)
117
+
118
+ // Role texts the kimaki plugin stored in a session (plugin/coordinator.ts), oldest first.
119
+ async function roleTexts(sessionID: string): Promise<string[]> {
120
+ const client = await server.client()
121
+ const messages = await client.message.list({ sessionID, order: 'desc', limit: 100 })
122
+ return [...messages.data].reverse().flatMap((message) => (message.type === 'synthetic' && message.text.includes('<kimaki-coordinator>') ? [message.text] : []))
123
+ }
124
+
125
+ // Token counts and run times differ between runs.
126
+ function usageless(note: string): string {
127
+ return note.replace(/ context="[^"]+"/, ' context="N tokens"').replace(/ duration="[^"]+"/, ' duration="Ns"')
128
+ }
129
+
130
+ afterAll(async () => {
131
+ await bot?.stop()
132
+ await twin?.stop()
133
+ await server?.stop()
134
+ fs.rmSync(dataDir, { recursive: true, force: true })
135
+ })
136
+
137
+ test('channel messages go to one session that replies in the channel', async () => {
138
+ const { discord, channelId } = twin
139
+ await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'Say hi hello-marker' })
140
+ await waitForFooter({ discord, threadId: channelId })
141
+ await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'Again hello-marker once-marker' })
142
+ await waitForFooter({ discord, threadId: channelId, count: 2 })
143
+
144
+ expect(await discord.channel(channelId).text({ showReplies: true })).toMatchInlineSnapshot(`
145
+ "--- from: user (tommy)
146
+ Say hi hello-marker
147
+ --- from: assistant (TestBot)
148
+ -# *using deterministic-provider/deterministic-v2 ⋅ build*
149
+ -# Coordinator mode is on: this channel is one session that starts and manages project threads for you. Use /new-session for a thread without it. Turn it off with /toggle-coordinator or start kimaki with \`--coordinator false\`.
150
+ [reply to tommy: Say hi hello-marker]
151
+ Hello from the coordinator.
152
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
153
+ --- from: user (tommy)
154
+ Again hello-marker once-marker
155
+ --- from: assistant (TestBot)
156
+ [reply to tommy: Again hello-marker once-marker]
157
+ Hello from the coordinator.
158
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
159
+ `)
160
+ expect(await discord.channel(channelId).getThreads()).toHaveLength(0)
161
+ // The role text reaches the model once per request, also when the plugin checks the request.
162
+ const request = server.model.requests.findLast((item) => item.latestUserText.includes('once-marker'))
163
+ expect(request?.raw.split('## coordinator: you coordinate').length).toBe(2)
164
+ // The coordinator role comes from the plugin, once: not from the frozen instructions entry.
165
+ const root = bot.store.getState().roots[channelId]!
166
+ const roles = await roleTexts(root)
167
+ expect(roles).toHaveLength(1)
168
+ expect(roles[0]).toContain('## coordinator: you coordinate this channel')
169
+ expect(roles[0]).toContain(`--parent-session ${root}`)
170
+ const entries = await (await server.client()).session.instructions.entry.list({ sessionID: root })
171
+ expect(JSON.stringify(entries)).not.toContain('## coordinator')
172
+ })
173
+
174
+ test('a worker started with --parent-session gets a thread and wakes the coordinator when done', async () => {
175
+ const { discord, channelId } = twin
176
+ const footers = (await discord.channel(channelId).getMessages()).filter(isFooter)
177
+ await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'Fix it delegate-marker' })
178
+ const thread = await discord.channel(channelId).waitForThread({ timeout: 8_000 })
179
+ await waitForFooter({ discord, threadId: thread.id })
180
+ await waitFor({
181
+ label: 'woken reply',
182
+ check: async () => (await discord.channel(channelId).text()).includes('The worker finished the job.') || null,
183
+ })
184
+ // The dispatch turn answered IDLE after its tool line: only the woken report gets a footer.
185
+ await waitForFooter({ discord, threadId: channelId, count: footers.length + 1 })
186
+
187
+ // Only the first answer text of a user prompt replies to it: never tool lines, the wake answer or a worker thread.
188
+ expect((await discord.channel(channelId).text({ showReplies: true })).replaceAll(thread.id, 'worker-thread')).toMatchInlineSnapshot(`
189
+ "--- from: user (tommy)
190
+ Say hi hello-marker
191
+ --- from: assistant (TestBot)
192
+ -# *using deterministic-provider/deterministic-v2 ⋅ build*
193
+ -# Coordinator mode is on: this channel is one session that starts and manages project threads for you. Use /new-session for a thread without it. Turn it off with /toggle-coordinator or start kimaki with \`--coordinator false\`.
194
+ [reply to tommy: Say hi hello-marker]
195
+ Hello from the coordinator.
196
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
197
+ --- from: user (tommy)
198
+ Again hello-marker once-marker
199
+ --- from: assistant (TestBot)
200
+ [reply to tommy: Again hello-marker once-marker]
201
+ Hello from the coordinator.
202
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
203
+ --- from: user (tommy)
204
+ Fix it delegate-marker
205
+ --- from: assistant (TestBot)
206
+ -# ┣ shell _Start the worker thread_
207
+ The worker finished the job.
208
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
209
+ `)
210
+ expect(await discord.thread(thread.id).text({ showReplies: true })).toMatchInlineSnapshot(`
211
+ "--- from: assistant (TestBot)
212
+ -# » **CLI:** worker-marker do the job
213
+ -# *using deterministic-provider/deterministic-v2 ⋅ build*
214
+ Job done in the worker.
215
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
216
+ `)
217
+ expect(thread.name).toBe('Worker task')
218
+ const workerRoles = await roleTexts(bot.store.getState().roots[thread.id]!)
219
+ expect(workerRoles).toHaveLength(1)
220
+ expect(workerRoles[0]).toContain(`## worker of a coordinator`)
221
+ expect(workerRoles[0]).toContain(`kimaki session read ${channelId}`)
222
+ })
223
+
224
+ test('a btw fork of a worker sent with --parent-session wakes the coordinator when done', async () => {
225
+ const { discord, channelId } = twin
226
+ const root = bot.store.getState().roots[channelId]!
227
+ const worker = (await discord.channel(channelId).getThreads()).find((candidate) => candidate.name === 'Worker task')
228
+ if (!worker) throw new Error('no Worker task thread')
229
+ const footers = (await discord.channel(channelId).getMessages()).filter(isFooter).length
230
+ // What the coordinator runs: kimaki send --thread <worker> --prompt '... btw' --parent-session <coordinator>
231
+ const forked = await send(bot, { threadId: worker.id, prompt: 'fork-marker related job. btw', parentSessionId: root, senderSessionId: root })
232
+ if (forked instanceof Error) throw forked
233
+ await waitForFooter({ discord, threadId: forked.threadId })
234
+ await waitForFooter({ discord, threadId: channelId, count: footers + 1 })
235
+
236
+ const text = await discord.channel(channelId).text()
237
+ expect(text.slice(text.lastIndexOf('The worker finished the job.'))).toMatchInlineSnapshot(`
238
+ "The worker finished the job.
239
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
240
+ The fork finished the job.
241
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
242
+ `)
243
+ const fork = await (await server.client()).session.get({ sessionID: forked.sessionId! })
244
+ expect(Reflect.get(Object(fork.metadata?.['kimaki']), 'parentSessionId')).toBe(root)
245
+ expect((await roleTexts(fork.id)).at(-1)).toContain('## worker of a coordinator')
246
+ })
247
+
248
+ test('session read lists the Discord URL of a file a worker uploaded; its note does not', async () => {
249
+ const { discord, channelId } = twin
250
+ const root = bot.store.getState().roots[channelId]!
251
+ fs.writeFileSync(path.join(server.projectDirectory, 'shot.png'), 'png')
252
+ const footers = (await discord.channel(channelId).getMessages()).filter(isFooter).length
253
+ const started = await send(bot, { channelId, name: 'Screenshot task', prompt: 'upload-marker take a screenshot', parentSessionId: root, senderSessionId: root })
254
+ if (started instanceof Error) throw started
255
+ await waitForFooter({ discord, threadId: started.threadId })
256
+ await waitForFooter({ discord, threadId: channelId, count: footers + 1 })
257
+
258
+ const text = await discord.channel(channelId).text()
259
+ expect(text.slice(text.lastIndexOf('-# *main', text.lastIndexOf('-# *main') - 1))).toMatchInlineSnapshot(`
260
+ "-# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
261
+ Here is the screenshot.
262
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
263
+ `)
264
+ const client = await server.client()
265
+ const messages = await client.message.list({ sessionID: root, order: 'desc', limit: 10 })
266
+ const note = messages.data.find((message) => message.type === 'synthetic' && message.text.includes('Screenshot task'))
267
+ expect(note?.type === 'synthetic' && usageless(note.text.replaceAll(started.sessionId!, 'ses_worker').replaceAll(started.threadId, 'worker-thread'))).toMatchInlineSnapshot(`
268
+ "<session-finished sessionID="ses_worker" threadID="worker-thread" state="completed" title="Screenshot task" context="N tokens" duration="Ns" wake="true">
269
+ Took the screenshot.
270
+ More: kimaki session read ses_worker
271
+ The user sees nothing else about this. When it is work they asked for, a failure or a decision they must make, tell them the outcome in a few plain sentences, with the thread link <#worker-thread> once. Otherwise reply exactly IDLE.
272
+ </session-finished>"
273
+ `)
274
+ const read = await readSessionMarkdown({ client, sessionId: started.sessionId! })
275
+ expect(read instanceof Error ? read : read.slice(read.indexOf('Took the screenshot.'), read.indexOf('Only prompts'))).toMatchInlineSnapshot(`
276
+ "Took the screenshot.
277
+
278
+ Media uploaded to Discord:
279
+ - data:application/octet-stream;base64,cG5n
280
+
281
+ "
282
+ `)
283
+ // --full and transcripts hide tool outputs but keep the uploaded media on the tool line.
284
+ const full = await readSessionMarkdown({ client, sessionId: started.sessionId!, full: true, thinking: true })
285
+ expect(full instanceof Error ? full : full.slice(full.indexOf('tool shell'), full.indexOf('finish: tool-calls')).trim()).toMatchInlineSnapshot(`
286
+ "tool shell [completed] {"command":"kimaki upload-to-discord shot.png","description":"Upload the screenshot","hasSideEffect":true}
287
+ Media uploaded to Discord:
288
+ - data:application/octet-stream;base64,cG5n"
289
+ `)
290
+ })
291
+
292
+ test('a run the user prompts in a worker thread is a silent note; a follow-up the coordinator sends wakes it', async () => {
293
+ const { discord, channelId } = twin
294
+ const root = bot.store.getState().roots[channelId]!
295
+ const client = await server.client()
296
+ const worker = (await discord.channel(channelId).getThreads()).find((candidate) => candidate.name === 'Worker task')
297
+ if (!worker) throw new Error('no Worker task thread')
298
+ const footers = () => discord.channel(channelId).getMessages().then((messages) => messages.filter(isFooter).length)
299
+ const before = await footers()
300
+ const workerFooters = (await discord.thread(worker.id).getMessages()).filter(isFooter).length
301
+ await discord.thread(worker.id).user(TEST_USER_ID).sendMessage({ content: 'Also check the logs user-steer-marker' })
302
+ await waitForFooter({ discord, threadId: worker.id, count: workerFooters + 1 })
303
+ const note = await waitFor({
304
+ label: 'silent note of the steered run',
305
+ check: async () => {
306
+ const inbox = await client.session.inbox.list({ sessionID: root })
307
+ const item = inbox.find((candidate) => candidate.type === 'synthetic' && candidate.payload.text.includes('Steered job done.'))
308
+ return item?.type === 'synthetic' ? item.payload.text : null
309
+ },
310
+ })
311
+ expect(usageless(note.replaceAll(worker.id, 'worker-thread').replace(/ses_\w+/g, 'ses_worker'))).toMatchInlineSnapshot(`
312
+ "<session-finished sessionID="ses_worker" threadID="worker-thread" state="completed" title="Worker task" context="N tokens" duration="Ns" wake="false">
313
+ <user-prompt username="tommy">
314
+ Also check the logs user-steer-marker
315
+ </user-prompt>
316
+ Steered job done.
317
+ More: kimaki session read ses_worker
318
+ </session-finished>"
319
+ `)
320
+ // Not woken: no new footer in the channel.
321
+ for (let i = 0; i < 10; i++) {
322
+ await new Promise((resolve) => setTimeout(resolve, 20))
323
+ expect(await footers()).toBe(before)
324
+ }
325
+
326
+ // What the coordinator runs: kimaki send --thread <worker> --prompt '...', from its own shell.
327
+ const followed = await send(bot, { threadId: worker.id, prompt: 'Now the follow-up-marker', senderSessionId: root })
328
+ if (followed instanceof Error) throw followed
329
+ await waitForFooter({ discord, threadId: channelId, count: before + 1 })
330
+ const text = await discord.channel(channelId).text()
331
+ expect(text.slice(text.lastIndexOf('-# *main', text.lastIndexOf('-# *main') - 1))).toMatchInlineSnapshot(`
332
+ "-# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
333
+ The follow-up finished.
334
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
335
+ `)
336
+ })
337
+
338
+ test('a worker started with --wait answers through the wait, with no note', async () => {
339
+ const { discord, channelId } = twin
340
+ const footers = (await discord.channel(channelId).getMessages()).filter(isFooter)
341
+ const threads = (await discord.channel(channelId).getThreads()).length
342
+ await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'Fix it waitjob-marker' })
343
+ await waitForFooter({ discord, threadId: channelId, count: footers.length + 1 })
344
+ const thread = (await discord.channel(channelId).getThreads()).find((candidate) => candidate.name === 'Waited task')
345
+ if (!thread) throw new Error('no Waited task thread')
346
+ await waitForFooter({ discord, threadId: thread.id })
347
+ const root = bot.store.getState().roots[channelId]!
348
+ const client = await server.client()
349
+ // No note arrives: not in the inbox, no wake, no "finished" line.
350
+ for (let i = 0; i < 10; i++) {
351
+ await new Promise((resolve) => setTimeout(resolve, 20))
352
+ const inbox = await client.session.inbox.list({ sessionID: root })
353
+ expect(inbox.filter((item) => item.type === 'synthetic' && item.payload.text.includes('Waited task'))).toHaveLength(0)
354
+ }
355
+
356
+ const text = (await discord.channel(channelId).text()).replaceAll(thread.id, 'worker-thread')
357
+ // The answer of the busy prompt, then the queued one starts: its IDLE answer posts nothing.
358
+ expect(text.slice(text.lastIndexOf('[reply to tommy: Take a while'))).toMatchInlineSnapshot(`"*"`)
359
+ expect(await discord.channel(channelId).getThreads()).toHaveLength(threads + 1)
360
+ const messages = await client.message.list({ sessionID: root, order: 'desc', limit: 20 })
361
+ expect(messages.data.some((message) => message.type === 'synthetic' && JSON.stringify(message).includes('Waited task'))).toBe(false)
362
+ })
363
+
364
+ test('a worker that outlives a bounded --wait wakes the coordinator with a note', async () => {
365
+ const { discord, channelId } = twin
366
+ const footers = (await discord.channel(channelId).getMessages()).filter(isFooter)
367
+ await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'Do it boundedwait-marker' })
368
+ await waitForFooter({ discord, threadId: channelId, count: footers.length + 1 })
369
+ const text = await discord.channel(channelId).text()
370
+ expect(text.slice(text.lastIndexOf('Do it boundedwait-marker'))).toMatchInlineSnapshot(`
371
+ "Do it boundedwait-marker
372
+ --- from: assistant (TestBot)
373
+ -# ┣ shell _Start the worker, wait briefly_
374
+ The bounded job finished.
375
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
376
+ `)
377
+ expect(server.model.requests.some((request) => request.raw.includes('is still running after 0.1s'))).toBe(true)
378
+ })
379
+
380
+ test('another thread of the channel leaves a silent note for the next prompt', async () => {
381
+ const { discord, channelId } = twin
382
+ const footers = (await discord.channel(channelId).getMessages()).filter(isFooter)
383
+ const started = await send(bot, { channelId, prompt: 'side-marker check something' })
384
+ if (started instanceof Error) throw started
385
+ await waitForFooter({ discord, threadId: started.threadId })
386
+ const client = await server.client()
387
+ const root = bot.store.getState().roots[channelId]!
388
+ await waitFor({
389
+ label: 'silent note in the coordinator',
390
+ check: async () => {
391
+ // Not delivered yet: a note without wake waits in the inbox for the next run.
392
+ const inbox = await client.session.inbox.list({ sessionID: root })
393
+ return inbox.some((item) => item.type === 'synthetic' && item.payload.text.includes('wake="false"')) || null
394
+ },
395
+ })
396
+ // Not woken: no new footer in the channel until the user writes.
397
+ expect((await discord.channel(channelId).getMessages()).filter(isFooter)).toHaveLength(footers.length)
398
+ await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'What happened? recap-marker' })
399
+ await waitForFooter({ discord, threadId: channelId, count: footers.length + 1 })
400
+
401
+ const text = await discord.channel(channelId).text()
402
+ expect(text.slice(text.lastIndexOf('--- from: user'))).toMatchInlineSnapshot(`
403
+ "--- from: user (tommy)
404
+ What happened? recap-marker
405
+ --- from: assistant (TestBot)
406
+ A side thread finished meanwhile.
407
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
408
+ `)
409
+ })
410
+
411
+ test('a btw fork of the coordinator is a worker: no coordinator timers', async () => {
412
+ const { discord, channelId } = twin
413
+ const before = (await discord.channel(channelId).getThreads()).length
414
+ await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'Why that choice btw-marker. btw' })
415
+ const thread = await waitFor({
416
+ label: 'btw thread',
417
+ check: async () => (await discord.channel(channelId).getThreads()).slice(before).find((candidate) => candidate.name?.startsWith('btw:')) ?? null,
418
+ })
419
+ await waitForFooter({ discord, threadId: thread.id })
420
+ const client = await server.client()
421
+ const fork = await client.session.get({ sessionID: bot.store.getState().roots[thread.id]! })
422
+ expect(Reflect.get(Object(fork.metadata?.['kimaki']), 'coordinator')).toBeUndefined()
423
+ // The fork copied the coordinator role; its own worker role comes after it.
424
+ const roles = await roleTexts(fork.id)
425
+ expect(roles.map((text) => (text.includes('## worker of a coordinator') ? 'worker' : 'coordinator'))).toEqual(['coordinator', 'worker'])
426
+ })
427
+
428
+ test('a stale coordinator role text is replaced: on a wake for that request, then stored at the next prompt', async () => {
429
+ const { discord, channelId } = twin
430
+ const root = bot.store.getState().roots[channelId]!
431
+ const client = await server.client()
432
+ const current = (await roleTexts(root)).at(-1)!
433
+ const footers = (await discord.channel(channelId).getMessages()).filter(isFooter).length
434
+ // Like a role text of older plugin code, as the latest one in the history, waking the session.
435
+ await client.session.synthetic({ sessionID: root, text: '<system-reminder>\n<kimaki-coordinator>\nold role text stale-wake-marker\n</kimaki-coordinator>\n</system-reminder>' })
436
+ await waitForFooter({ discord, threadId: channelId, count: footers + 1 })
437
+ await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'Again hello-marker' })
438
+ await waitForFooter({ discord, threadId: channelId, count: footers + 2 })
439
+
440
+ const text = await discord.channel(channelId).text()
441
+ expect(text.slice(text.lastIndexOf('Session forked!')).replace(/<#\d+>/, '<#btw-thread>')).toMatchInlineSnapshot(`
442
+ "Session forked! Continue in <#btw-thread>
443
+ Woke on the stale role text.
444
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
445
+ --- from: user (tommy)
446
+ Again hello-marker
447
+ --- from: assistant (TestBot)
448
+ Hello from the coordinator.
449
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
450
+ `)
451
+ // The wake request already had the current role text after the stale one.
452
+ const wake = server.model.requests.findLast((item) => item.latestUserText.includes('stale-wake-marker'))
453
+ expect(wake?.latestUserText).toMatch(/stale-wake-marker[\s\S]*## coordinator: you coordinate this channel/)
454
+ const roles = await roleTexts(root)
455
+ expect(roles.at(-1)).toBe(current)
456
+ expect(roles.at(-2)).toContain('stale-wake-marker')
457
+ })
458
+
459
+ test('the coordinator shows typing while a worker runs in the background', async () => {
460
+ const { discord, channelId } = twin
461
+ const root = bot.store.getState().roots[channelId]!
462
+ const footers = (await discord.channel(channelId).getMessages()).filter(isFooter).length
463
+ discord.channel(channelId).clearTypingEvents()
464
+ const started = await send(bot, { channelId, name: 'Slow task', prompt: 'slow-job-marker take your time', parentSessionId: root, senderSessionId: root })
465
+ if (started instanceof Error) throw started
466
+ await discord.channel(channelId).waitForTypingEvent()
467
+ // Read when typing starts: it must come from the running worker, not from the wake after it.
468
+ const workerAtTyping = await discord.thread(started.threadId).text()
469
+ await waitForFooter({ discord, threadId: started.threadId })
470
+ await waitForFooter({ discord, threadId: channelId, count: footers + 1 })
471
+
472
+ const text = await discord.channel(channelId).text()
473
+ expect(text.slice(text.lastIndexOf('-# *main', text.lastIndexOf('-# *main') - 1))).toMatchInlineSnapshot(`
474
+ "-# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
475
+ The slow job finished.
476
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
477
+ `)
478
+ expect(workerAtTyping).not.toContain('-# *main')
479
+ })
480
+
481
+ test('/toggle-coordinator turns coordinator mode off and on per channel; the choice survives a restart', async () => {
482
+ const { discord, channelId } = twin
483
+ const root = bot.store.getState().roots[channelId]
484
+ const toggle = async () => {
485
+ const { id: interactionId } = await discord.channel(channelId).user(TEST_USER_ID).runSlashCommand({ name: 'toggle-coordinator' })
486
+ await discord.channel(channelId).waitForInteractionAck({ interactionId, timeout: 4_000 })
487
+ }
488
+ await toggle()
489
+ const threads = new Set((await discord.channel(channelId).getThreads()).map((thread) => thread.id))
490
+ await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'Mode off hello-marker' })
491
+ const thread = await discord.channel(channelId).waitForThread({ timeout: 8_000, predicate: (found) => !threads.has(found.id) })
492
+ await waitForFooter({ discord, threadId: thread.id })
493
+ expect(bot.store.getState().roots[channelId]).toBe(root)
494
+
495
+ await toggle()
496
+ // Started with coordinator mode off by default: the channel keeps its choice and its coordinator.
497
+ await bot.stop()
498
+ bot = await startTestBot({ dataDir, twin, server })
499
+ const footers = (await discord.channel(channelId).getMessages()).filter(isFooter).length
500
+ await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'Mode on hello-marker' })
501
+ await waitForFooter({ discord, threadId: channelId, count: footers + 1 })
502
+ expect(bot.store.getState().roots[channelId]).toBe(root)
503
+
504
+ const text = await discord.channel(channelId).text()
505
+ expect(text.slice(text.lastIndexOf('Coordinator mode **off**'))).toMatchInlineSnapshot(`
506
+ "Coordinator mode **off** for this channel. Each channel message starts a new thread.
507
+ --- from: user (tommy)
508
+ Mode off hello-marker
509
+ --- from: assistant (TestBot)
510
+ Coordinator mode **on** for this channel. Channel messages go to one long coordinator session that starts threads for the work. It continues the coordinator session this channel had before; /reset-coordinator starts a fresh one.
511
+ --- from: user (tommy)
512
+ Mode on hello-marker
513
+ --- from: assistant (TestBot)
514
+ Hello from the coordinator.
515
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
516
+ `)
517
+ })
518
+
519
+ test('the coordinator survives a bot restart; /reset-coordinator starts a fresh one', async () => {
520
+ const { discord, channelId } = twin
521
+ const root = bot.store.getState().roots[channelId]
522
+ await bot.stop()
523
+ bot = await startTestBot({ dataDir, twin, server, coordinator: true })
524
+ expect(bot.store.getState().roots[channelId]).toBe(root)
525
+
526
+ const { id: interactionId } = await discord.channel(channelId).user(TEST_USER_ID).runSlashCommand({ name: 'reset-coordinator' })
527
+ await discord.channel(channelId).waitForInteractionAck({ interactionId, timeout: 4_000 })
528
+ expect(bot.store.getState().roots[channelId]).toBeUndefined()
529
+ const footers = (await discord.channel(channelId).getMessages()).filter(isFooter).length
530
+ await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'Fresh start hello-marker' })
531
+ await waitForFooter({ discord, threadId: channelId, count: footers + 1 })
532
+
533
+ const fresh = bot.store.getState().roots[channelId]
534
+ expect(fresh && fresh !== root).toBe(true)
535
+ const text = await discord.channel(channelId).text()
536
+ expect(text.slice(text.lastIndexOf('--- from: user'))).toMatchInlineSnapshot(`
537
+ "--- from: user (tommy)
538
+ Fresh start hello-marker
539
+ --- from: assistant (TestBot)
540
+ -# *using deterministic-provider/deterministic-v2 ⋅ build*
541
+ -# Coordinator mode is on: this channel is one session that starts and manages project threads for you. Use /new-session for a thread without it. Turn it off with /toggle-coordinator or start kimaki with \`--coordinator false\`.
542
+ Hello from the coordinator.
543
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
544
+ `)
545
+ })