kimaki 1.4.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 (335) 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 +10 -1
  15. package/dist/bot.js +14 -6
  16. package/dist/bot.js.map +1 -1
  17. package/dist/btw.e2e.test.js +3 -3
  18. package/dist/builtin-skills.d.ts +1 -1
  19. package/dist/builtin-skills.js +1 -1
  20. package/dist/builtin-skills.js.map +1 -1
  21. package/dist/builtin-skills.test.js +3 -4
  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.js +25 -14
  25. package/dist/cli/bot.js.map +1 -1
  26. package/dist/cli/discord.js +32 -6
  27. package/dist/cli/discord.js.map +1 -1
  28. package/dist/cli/schedule.js +23 -9
  29. package/dist/cli/schedule.js.map +1 -1
  30. package/dist/cli/session.js +37 -12
  31. package/dist/cli/session.js.map +1 -1
  32. package/dist/cli/shared.d.ts +5 -3
  33. package/dist/cli/shared.js +45 -10
  34. package/dist/cli/shared.js.map +1 -1
  35. package/dist/cli-discord.e2e.test.js +13 -8
  36. package/dist/cli-discord.e2e.test.js.map +1 -1
  37. package/dist/cli-lock.e2e.test.js +87 -20
  38. package/dist/cli-lock.e2e.test.js.map +1 -1
  39. package/dist/cli-read.e2e.test.js +18 -9
  40. package/dist/cli-read.e2e.test.js.map +1 -1
  41. package/dist/cli-sessions.e2e.test.js +4 -3
  42. package/dist/cli-sessions.e2e.test.js.map +1 -1
  43. package/dist/cli.js +2 -0
  44. package/dist/cli.js.map +1 -1
  45. package/dist/commands/preference-commands.d.ts +4 -0
  46. package/dist/commands/preference-commands.js +38 -4
  47. package/dist/commands/preference-commands.js.map +1 -1
  48. package/dist/commands/session-commands.js +11 -8
  49. package/dist/commands/session-commands.js.map +1 -1
  50. package/dist/commands/thread-commands.js +94 -24
  51. package/dist/commands/thread-commands.js.map +1 -1
  52. package/dist/commands/worktree-commands.d.ts +5 -6
  53. package/dist/commands/worktree-commands.js +8 -7
  54. package/dist/commands/worktree-commands.js.map +1 -1
  55. package/dist/commands.e2e.test.js +25 -21
  56. package/dist/commands.e2e.test.js.map +1 -1
  57. package/dist/coordinator-agents.e2e.test.d.ts +1 -0
  58. package/dist/coordinator-agents.e2e.test.js +113 -0
  59. package/dist/coordinator-agents.e2e.test.js.map +1 -0
  60. package/dist/coordinator-idle.e2e.test.d.ts +1 -0
  61. package/dist/coordinator-idle.e2e.test.js +100 -0
  62. package/dist/coordinator-idle.e2e.test.js.map +1 -0
  63. package/dist/coordinator-queue.e2e.test.d.ts +1 -0
  64. package/dist/coordinator-queue.e2e.test.js +244 -0
  65. package/dist/coordinator-queue.e2e.test.js.map +1 -0
  66. package/dist/coordinator-warmup.e2e.test.d.ts +1 -0
  67. package/dist/coordinator-warmup.e2e.test.js +131 -0
  68. package/dist/coordinator-warmup.e2e.test.js.map +1 -0
  69. package/dist/coordinator.d.ts +6 -0
  70. package/dist/coordinator.e2e.test.d.ts +1 -0
  71. package/dist/coordinator.e2e.test.js +505 -0
  72. package/dist/coordinator.e2e.test.js.map +1 -0
  73. package/dist/coordinator.js +164 -0
  74. package/dist/coordinator.js.map +1 -0
  75. package/dist/db.d.ts +78 -3
  76. package/dist/db.test.js +1 -0
  77. package/dist/db.test.js.map +1 -1
  78. package/dist/diagram.d.ts +11 -0
  79. package/dist/diagram.js +232 -0
  80. package/dist/diagram.js.map +1 -0
  81. package/dist/diagram.test.d.ts +1 -0
  82. package/dist/diagram.test.js +52 -0
  83. package/dist/diagram.test.js.map +1 -0
  84. package/dist/duration.js +13 -5
  85. package/dist/duration.js.map +1 -1
  86. package/dist/effects.d.ts +6 -1
  87. package/dist/effects.js +97 -26
  88. package/dist/effects.js.map +1 -1
  89. package/dist/event-loop.d.ts +1 -1
  90. package/dist/event-loop.js +73 -43
  91. package/dist/event-loop.js.map +1 -1
  92. package/dist/format-parts.d.ts +21 -4
  93. package/dist/format-parts.js +40 -23
  94. package/dist/format-parts.js.map +1 -1
  95. package/dist/gateway-replay.e2e.test.js +4 -4
  96. package/dist/harness.e2e.test.js +2 -2
  97. package/dist/harness.e2e.test.js.map +1 -1
  98. package/dist/hello-thread.e2e.test.js +56 -5
  99. package/dist/hello-thread.e2e.test.js.map +1 -1
  100. package/dist/ingress.js +47 -12
  101. package/dist/ingress.js.map +1 -1
  102. package/dist/interaction-context.d.ts +4 -4
  103. package/dist/interaction-context.js +9 -7
  104. package/dist/interaction-context.js.map +1 -1
  105. package/dist/interrupt.e2e.test.js +8 -5
  106. package/dist/interrupt.e2e.test.js.map +1 -1
  107. package/dist/legacy.e2e.test.js +1 -1
  108. package/dist/lock-routes.d.ts +83 -31
  109. package/dist/lock-routes.js +68 -8
  110. package/dist/lock-routes.js.map +1 -1
  111. package/dist/lock-server.d.ts +3 -2
  112. package/dist/lock-server.js +23 -7
  113. package/dist/lock-server.js.map +1 -1
  114. package/dist/main.d.ts +4 -2
  115. package/dist/main.js +13 -3
  116. package/dist/main.js.map +1 -1
  117. package/dist/mainmast-idle.e2e.test.d.ts +1 -0
  118. package/dist/mainmast-idle.e2e.test.js +99 -0
  119. package/dist/mainmast-idle.e2e.test.js.map +1 -0
  120. package/dist/mainmast-queue.e2e.test.d.ts +1 -0
  121. package/dist/mainmast-queue.e2e.test.js +244 -0
  122. package/dist/mainmast-queue.e2e.test.js.map +1 -0
  123. package/dist/mainmast-warmup.e2e.test.d.ts +1 -0
  124. package/dist/mainmast-warmup.e2e.test.js +130 -0
  125. package/dist/mainmast-warmup.e2e.test.js.map +1 -0
  126. package/dist/mainmast.d.ts +6 -0
  127. package/dist/mainmast.e2e.test.d.ts +1 -0
  128. package/dist/mainmast.e2e.test.js +428 -0
  129. package/dist/mainmast.e2e.test.js.map +1 -0
  130. package/dist/mainmast.js +156 -0
  131. package/dist/mainmast.js.map +1 -0
  132. package/dist/markdown/markdown.test.js +4 -4
  133. package/dist/markdown/render-markdown.js +6 -5
  134. package/dist/markdown/render-markdown.js.map +1 -1
  135. package/dist/onboarding-cli.e2e.test.js +7 -6
  136. package/dist/onboarding-cli.e2e.test.js.map +1 -1
  137. package/dist/onboarding.d.ts +3 -10
  138. package/dist/onboarding.e2e.test.js +8 -10
  139. package/dist/onboarding.e2e.test.js.map +1 -1
  140. package/dist/onboarding.js +14 -24
  141. package/dist/onboarding.js.map +1 -1
  142. package/dist/permissions.e2e.test.js +3 -3
  143. package/dist/plugin/coordinator.d.ts +4 -0
  144. package/dist/plugin/coordinator.js +389 -0
  145. package/dist/plugin/coordinator.js.map +1 -0
  146. package/dist/plugin/index.js +76 -17
  147. package/dist/plugin/index.js.map +1 -1
  148. package/dist/plugin/mainmast.d.ts +4 -0
  149. package/dist/plugin/mainmast.js +379 -0
  150. package/dist/plugin/mainmast.js.map +1 -0
  151. package/dist/plugin.e2e.test.js +1 -1
  152. package/dist/preference-commands.e2e.test.js +103 -24
  153. package/dist/preference-commands.e2e.test.js.map +1 -1
  154. package/dist/project.d.ts +1 -1
  155. package/dist/prompt.d.ts +22 -9
  156. package/dist/prompt.js +64 -48
  157. package/dist/prompt.js.map +1 -1
  158. package/dist/questions.e2e.test.js +13 -13
  159. package/dist/queue-sources.e2e.test.d.ts +1 -0
  160. package/dist/queue-sources.e2e.test.js +168 -0
  161. package/dist/queue-sources.e2e.test.js.map +1 -0
  162. package/dist/queue.d.ts +9 -1
  163. package/dist/queue.e2e.test.js +48 -30
  164. package/dist/queue.e2e.test.js.map +1 -1
  165. package/dist/queue.js +97 -24
  166. package/dist/queue.js.map +1 -1
  167. package/dist/queue.test.d.ts +1 -0
  168. package/dist/queue.test.js +57 -0
  169. package/dist/queue.test.js.map +1 -0
  170. package/dist/remote-send.e2e.test.js +6 -6
  171. package/dist/remote-send.js +2 -1
  172. package/dist/remote-send.js.map +1 -1
  173. package/dist/renderer.e2e.test.js +7 -7
  174. package/dist/restart.e2e.test.js +2 -2
  175. package/dist/routines.e2e.test.js +2 -2
  176. package/dist/scheduler.d.ts +3 -3
  177. package/dist/scheduler.e2e.test.js +43 -20
  178. package/dist/scheduler.e2e.test.js.map +1 -1
  179. package/dist/scheduler.js +4 -2
  180. package/dist/scheduler.js.map +1 -1
  181. package/dist/schema-sql.d.ts +1 -1
  182. package/dist/schema-sql.js +1 -1
  183. package/dist/schema-sql.js.map +1 -1
  184. package/dist/schema.d.ts +152 -6
  185. package/dist/schema.js +12 -0
  186. package/dist/schema.js.map +1 -1
  187. package/dist/session-commands.e2e.test.js +36 -11
  188. package/dist/session-commands.e2e.test.js.map +1 -1
  189. package/dist/session-events.d.ts +18 -6
  190. package/dist/session-events.js +166 -22
  191. package/dist/session-events.js.map +1 -1
  192. package/dist/session-ops.e2e.test.js +63 -12
  193. package/dist/session-ops.e2e.test.js.map +1 -1
  194. package/dist/sessions.d.ts +44 -21
  195. package/dist/sessions.js +174 -63
  196. package/dist/sessions.js.map +1 -1
  197. package/dist/shell.e2e.test.js +4 -4
  198. package/dist/slash-commands.js +6 -4
  199. package/dist/slash-commands.js.map +1 -1
  200. package/dist/slash-commands.test.js +2 -0
  201. package/dist/slash-commands.test.js.map +1 -1
  202. package/dist/sleep.e2e.test.js +29 -14
  203. package/dist/sleep.e2e.test.js.map +1 -1
  204. package/dist/sleeps.d.ts +2 -1
  205. package/dist/sleeps.js +21 -3
  206. package/dist/sleeps.js.map +1 -1
  207. package/dist/store.d.ts +1 -0
  208. package/dist/store.js +3 -2
  209. package/dist/store.js.map +1 -1
  210. package/dist/system-prompt.d.ts +3 -1
  211. package/dist/system-prompt.js +138 -64
  212. package/dist/system-prompt.js.map +1 -1
  213. package/dist/test/generate-log-plugin/index.d.ts +3 -0
  214. package/dist/test/generate-log-plugin/index.js +15 -0
  215. package/dist/test/generate-log-plugin/index.js.map +1 -0
  216. package/dist/test/harness.d.ts +20 -1
  217. package/dist/test/harness.js +51 -10
  218. package/dist/test/harness.js.map +1 -1
  219. package/dist/test/replay.js +11 -7
  220. package/dist/test/replay.js.map +1 -1
  221. package/dist/thread-reducer.d.ts +29 -3
  222. package/dist/thread-reducer.fixtures.test.js +58 -51
  223. package/dist/thread-reducer.fixtures.test.js.map +1 -1
  224. package/dist/thread-reducer.js +112 -25
  225. package/dist/thread-reducer.js.map +1 -1
  226. package/dist/thread-reducer.test.js +197 -24
  227. package/dist/thread-reducer.test.js.map +1 -1
  228. package/dist/voice-attachments.e2e.test.js +108 -18
  229. package/dist/voice-attachments.e2e.test.js.map +1 -1
  230. package/dist/voice-call.e2e.test.js +9 -9
  231. package/dist/voice-call.js +6 -6
  232. package/dist/voice-call.js.map +1 -1
  233. package/dist/voice-openai.e2e.test.js +59 -23
  234. package/dist/voice-openai.e2e.test.js.map +1 -1
  235. package/dist/voice.d.ts +1 -1
  236. package/dist/voice.js +72 -24
  237. package/dist/voice.js.map +1 -1
  238. package/dist/working-directory.e2e.test.js +11 -11
  239. package/dist/working-directory.e2e.test.js.map +1 -1
  240. package/package.json +12 -6
  241. package/src/agent-ui.e2e.test.ts +27 -13
  242. package/src/agent-ui.ts +2 -1
  243. package/src/analytics.e2e.test.ts +1 -1
  244. package/src/autostart.ts +55 -10
  245. package/src/bot.ts +29 -5
  246. package/src/btw.e2e.test.ts +3 -3
  247. package/src/builtin-skills.test.ts +3 -4
  248. package/src/builtin-skills.ts +1 -1
  249. package/src/catalog-refresh.e2e.test.ts +2 -2
  250. package/src/cli/bot.ts +23 -14
  251. package/src/cli/discord.ts +30 -6
  252. package/src/cli/schedule.ts +23 -9
  253. package/src/cli/session.ts +35 -12
  254. package/src/cli/shared.ts +43 -8
  255. package/src/cli-discord.e2e.test.ts +13 -8
  256. package/src/cli-lock.e2e.test.ts +88 -20
  257. package/src/cli-read.e2e.test.ts +17 -8
  258. package/src/cli-sessions.e2e.test.ts +4 -3
  259. package/src/cli.ts +2 -0
  260. package/src/commands/preference-commands.ts +38 -4
  261. package/src/commands/session-commands.ts +12 -7
  262. package/src/commands/thread-commands.ts +92 -24
  263. package/src/commands/worktree-commands.ts +10 -9
  264. package/src/commands.e2e.test.ts +25 -21
  265. package/src/coordinator-agents.e2e.test.ts +145 -0
  266. package/src/coordinator-idle.e2e.test.ts +122 -0
  267. package/src/coordinator-queue.e2e.test.ts +279 -0
  268. package/src/coordinator-warmup.e2e.test.ts +158 -0
  269. package/src/coordinator.e2e.test.ts +545 -0
  270. package/src/coordinator.ts +168 -0
  271. package/src/db.test.ts +1 -0
  272. package/src/diagram.test.ts +56 -0
  273. package/src/diagram.ts +243 -0
  274. package/src/duration.ts +13 -5
  275. package/src/effects.ts +99 -27
  276. package/src/event-loop.ts +71 -42
  277. package/src/format-parts.ts +53 -22
  278. package/src/gateway-replay.e2e.test.ts +4 -4
  279. package/src/harness.e2e.test.ts +2 -2
  280. package/src/hello-thread.e2e.test.ts +59 -5
  281. package/src/ingress.ts +44 -11
  282. package/src/interaction-context.ts +12 -11
  283. package/src/interrupt.e2e.test.ts +8 -5
  284. package/src/legacy.e2e.test.ts +1 -1
  285. package/src/lock-routes.ts +62 -8
  286. package/src/lock-server.ts +23 -7
  287. package/src/main.ts +22 -7
  288. package/src/markdown/markdown.test.ts +4 -4
  289. package/src/markdown/render-markdown.ts +6 -5
  290. package/src/onboarding-cli.e2e.test.ts +7 -6
  291. package/src/onboarding.e2e.test.ts +8 -10
  292. package/src/onboarding.ts +13 -29
  293. package/src/permissions.e2e.test.ts +3 -3
  294. package/src/plugin/coordinator.ts +401 -0
  295. package/src/plugin/index.ts +68 -15
  296. package/src/plugin.e2e.test.ts +1 -1
  297. package/src/preference-commands.e2e.test.ts +103 -23
  298. package/src/prompt.ts +87 -43
  299. package/src/questions.e2e.test.ts +13 -13
  300. package/src/queue-sources.e2e.test.ts +197 -0
  301. package/src/queue.e2e.test.ts +48 -30
  302. package/src/queue.test.ts +59 -0
  303. package/src/queue.ts +104 -24
  304. package/src/remote-send.e2e.test.ts +6 -6
  305. package/src/remote-send.ts +2 -1
  306. package/src/renderer.e2e.test.ts +7 -7
  307. package/src/restart.e2e.test.ts +2 -2
  308. package/src/routines.e2e.test.ts +2 -2
  309. package/src/scheduler.e2e.test.ts +45 -19
  310. package/src/scheduler.ts +4 -2
  311. package/src/schema-sql.ts +1 -1
  312. package/src/schema.ts +13 -0
  313. package/src/session-commands.e2e.test.ts +38 -11
  314. package/src/session-events.ts +160 -21
  315. package/src/session-ops.e2e.test.ts +64 -11
  316. package/src/sessions.ts +214 -64
  317. package/src/shell.e2e.test.ts +4 -4
  318. package/src/slash-commands.test.ts +2 -0
  319. package/src/slash-commands.ts +6 -4
  320. package/src/sleep.e2e.test.ts +30 -13
  321. package/src/sleeps.ts +20 -3
  322. package/src/store.ts +5 -2
  323. package/src/system-prompt.ts +143 -63
  324. package/src/test/generate-log-plugin/index.ts +16 -0
  325. package/src/test/harness.ts +77 -14
  326. package/src/test/replay.ts +12 -6
  327. package/src/thread-reducer.fixtures.test.ts +58 -51
  328. package/src/thread-reducer.test.ts +207 -26
  329. package/src/thread-reducer.ts +138 -37
  330. package/src/voice-attachments.e2e.test.ts +110 -18
  331. package/src/voice-call.e2e.test.ts +9 -9
  332. package/src/voice-call.ts +6 -6
  333. package/src/voice-openai.e2e.test.ts +64 -24
  334. package/src/voice.ts +93 -26
  335. package/src/working-directory.e2e.test.ts +11 -11
@@ -0,0 +1,122 @@
1
+ // The coordinator plugin warmup (plugin/coordinator.ts) with a short window: an idle
2
+ // coordinator with no running threads ends its warmup at the first check and is compacted.
3
+
4
+ import fs from 'node:fs'
5
+ import path from 'node:path'
6
+ import { afterAll, beforeAll, expect, test } from 'vitest'
7
+
8
+ import type { BotHandle } from './main.ts'
9
+ import {
10
+ TEST_MODEL,
11
+ TEST_USER_ID,
12
+ seedProjectChannel,
13
+ startOpencodeTestServer,
14
+ startTestBot,
15
+ startTwin,
16
+ tempDataDir,
17
+ textParts,
18
+ waitFor,
19
+ waitForFooter,
20
+ warmUp,
21
+ type OpencodeTestServer,
22
+ type TestTwin,
23
+ } from './test/harness.ts'
24
+
25
+ let server: OpencodeTestServer
26
+ let twin: TestTwin
27
+ let bot: BotHandle
28
+ const dataDir = tempDataDir()
29
+ // A large context, so the compaction is worth it (plugin/coordinator.ts MIN_COMPACT_TOKENS),
30
+ // in a 1M window like Claude Opus: only 12% of it, far below automatic compaction.
31
+ const bigUsage = { inputTokens: 120_000, outputTokens: 1, totalTokens: 120_001 }
32
+
33
+ beforeAll(async () => {
34
+ ;[server, twin] = await Promise.all([
35
+ startOpencodeTestServer({
36
+ models: { [TEST_MODEL]: { name: TEST_MODEL, limit: { context: 1_000_000, output: 32_000 } } },
37
+ matchers: [
38
+ // OpenCode rejects a summary without the template headings.
39
+ { id: 'compaction-summary', priority: 900, when: { latestUserTextIncludes: 'You MUST use this format for your response' }, then: { parts: textParts('## Objective\n- Keep coordinating') } },
40
+ {
41
+ id: 'hello',
42
+ priority: 100,
43
+ when: { latestUserTextIncludes: 'idle-marker' },
44
+ then: { parts: [{ type: 'text-start', id: 't' }, { type: 'text-delta', id: 't', delta: 'Hi there.' }, { type: 'text-end', id: 't' }, { type: 'finish', finishReason: 'stop', usage: bigUsage }] },
45
+ },
46
+ ],
47
+ }),
48
+ startTwin(),
49
+ ])
50
+ await seedProjectChannel({ dataDir, channelId: twin.channelId, guildId: twin.discord.guildId, directory: server.projectDirectory })
51
+ await warmUp({ server })
52
+ bot = await startTestBot({ dataDir, twin, server, coordinator: true, warmupTimers: { intervalMs: 1_200, maxMs: 60_000 } })
53
+ }, 60_000)
54
+
55
+ afterAll(async () => {
56
+ await bot?.stop()
57
+ await twin?.stop()
58
+ await server?.stop()
59
+ fs.rmSync(dataDir, { recursive: true, force: true })
60
+ })
61
+
62
+ test('an idle coordinator gets compacted when its warmup ends', async () => {
63
+ const { discord, channelId } = twin
64
+ await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'Hello idle-marker' })
65
+ await waitForFooter({ discord, threadId: channelId })
66
+ const client = await server.client()
67
+ const root = bot.store.getState().roots[channelId]!
68
+ await waitFor({
69
+ label: 'compaction of the idle coordinator',
70
+ check: async () => (await client.message.list({ sessionID: root, order: 'desc', limit: 10 })).data.some((message) => message.type === 'compaction') || null,
71
+ })
72
+
73
+ expect(await discord.channel(channelId).text()).toMatchInlineSnapshot(`
74
+ "--- from: user (tommy)
75
+ Hello idle-marker
76
+ --- from: assistant (TestBot)
77
+ -# *using deterministic-provider/deterministic-v2 ⋅ build*
78
+ -# 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\`.
79
+ Hi there.
80
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
81
+ `)
82
+ const messages = await client.message.list({ sessionID: root, order: 'asc', limit: 20 })
83
+ expect(messages.data.map((message) => message.type)).toMatchInlineSnapshot(`
84
+ [
85
+ "synthetic",
86
+ "synthetic",
87
+ "user",
88
+ "assistant",
89
+ "idle",
90
+ "compaction",
91
+ "idle",
92
+ ]
93
+ `)
94
+ })
95
+
96
+ // OpenCode reloads the plugin when its files change (every Kimaki build or upgrade).
97
+ test('a plugin reload keeps the pending compaction', async () => {
98
+ const { discord, channelId } = twin
99
+ const client = await server.client()
100
+ const root = bot.store.getState().roots[channelId]!
101
+ const compactions = async () => (await client.message.list({ sessionID: root, order: 'desc', limit: 20 })).data.filter((message) => message.type === 'compaction').length
102
+ expect(await compactions()).toBe(1)
103
+ await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'Again idle-marker' })
104
+ await waitForFooter({ discord, threadId: channelId, count: 2 })
105
+ fs.appendFileSync(path.join(server.configDir, 'plugins', 'kimaki', 'index.js'), '// reload\n')
106
+ await waitFor({ label: 'compaction after the plugin reload', check: async () => (await compactions()) === 2 || null })
107
+
108
+ const messages = await client.message.list({ sessionID: root, order: 'asc', limit: 30 })
109
+ const firstCompaction = messages.data.findIndex((message) => message.type === 'compaction')
110
+ expect(messages.data.slice(firstCompaction + 1).map((message) => message.type)).toMatchInlineSnapshot(`
111
+ [
112
+ "idle",
113
+ "synthetic",
114
+ "synthetic",
115
+ "user",
116
+ "assistant",
117
+ "idle",
118
+ "compaction",
119
+ "idle",
120
+ ]
121
+ `)
122
+ })
@@ -0,0 +1,279 @@
1
+ // The queue in a coordinator channel: the channel itself is the conversation, so
2
+ // acks and "Queued message sent" replies land in the channel. Every queued
3
+ // prompt that got an ack is marked when it starts, also when the coordinator
4
+ // answers IDLE; its answer is then not a second reply to the same message.
5
+
6
+ import { execFile } from 'node:child_process'
7
+ import fs from 'node:fs'
8
+ import path from 'node:path'
9
+ import { promisify } from 'node:util'
10
+ import type { DeterministicMatcher } from 'opencode-deterministic-provider'
11
+ import { afterAll, beforeAll, expect, test } from 'vitest'
12
+
13
+ import type { BotHandle } from './main.ts'
14
+ import {
15
+ TEST_USER_ID,
16
+ isFooter,
17
+ seedProjectChannel,
18
+ slowTextMatcher,
19
+ startOpencodeTestServer,
20
+ startTestBot,
21
+ startTwin,
22
+ tempDataDir,
23
+ textParts,
24
+ waitFor,
25
+ waitForBotMessageContaining,
26
+ waitForFooter,
27
+ warmUp,
28
+ type OpencodeTestServer,
29
+ type TestTwin,
30
+ } from './test/harness.ts'
31
+
32
+ const exec = promisify(execFile)
33
+
34
+ function reply(marker: string, text: string): DeterministicMatcher {
35
+ return { id: marker, priority: 50, when: { latestUserTextIncludes: marker }, then: { parts: textParts(text) } }
36
+ }
37
+
38
+ const matchers: DeterministicMatcher[] = [
39
+ slowTextMatcher({ marker: 'slow-marker', text: 'slow-done', delayMs: 1_200 }),
40
+ reply('queued-idle', 'IDLE'),
41
+ reply('queued-one', 'queued one ok'),
42
+ reply('queued-two', 'queued two ok'),
43
+ reply('steer-marker', 'steer ok'),
44
+ reply('idle-marker', 'idle ok'),
45
+ reply('hello-marker', 'hello'),
46
+ ]
47
+
48
+ let server: OpencodeTestServer
49
+ let twin: TestTwin
50
+ let bot: BotHandle
51
+ const dataDir = tempDataDir()
52
+
53
+ beforeAll(async () => {
54
+ ;[server, twin] = await Promise.all([startOpencodeTestServer({ matchers }), startTwin()])
55
+ await seedProjectChannel({ dataDir, channelId: twin.channelId, guildId: twin.discord.guildId, directory: server.projectDirectory })
56
+ await warmUp({ server })
57
+ bot = await startTestBot({ dataDir, twin, server, coordinator: true })
58
+ // The coordinator session exists before any test: its banner is not part of one test's snapshot.
59
+ await twin.discord.channel(twin.channelId).user(TEST_USER_ID).sendMessage({ content: 'Start hello-marker' })
60
+ await waitForFooter({ discord: twin.discord, threadId: twin.channelId })
61
+ }, 60_000)
62
+
63
+ afterAll(async () => {
64
+ await bot?.stop()
65
+ await twin?.stop()
66
+ await server?.stop()
67
+ fs.rmSync(dataDir, { recursive: true, force: true })
68
+ })
69
+
70
+ // The channel holds every test's turns: the text from the user message that starts with `content`.
71
+ async function channelTextFrom(content: string): Promise<string> {
72
+ const text = await twin.discord.channel(twin.channelId).text({ showReplies: true })
73
+ return text.slice(text.lastIndexOf('--- from:', text.lastIndexOf(`\n${content}`)))
74
+ }
75
+
76
+ function footers(): Promise<number> {
77
+ return twin.discord.channel(twin.channelId).getMessages().then((messages) => messages.filter(isFooter).length)
78
+ }
79
+
80
+ // Sends a slow prompt and waits until the coordinator runs it.
81
+ async function startSlowTurn(content: string) {
82
+ const channel = twin.discord.channel(twin.channelId)
83
+ channel.clearTypingEvents()
84
+ await channel.user(TEST_USER_ID).sendMessage({ content: `${content} slow-marker` })
85
+ await channel.waitForTypingEvent()
86
+ }
87
+
88
+ async function waitForIdle() {
89
+ await waitFor({ label: 'coordinator idle', check: async () => !bot.store.getState().threads[twin.channelId]?.turn || null })
90
+ }
91
+
92
+ function rootSession(): string {
93
+ const sessionID = bot.store.getState().roots[twin.channelId]
94
+ if (!sessionID) throw new Error('no coordinator session')
95
+ return sessionID
96
+ }
97
+
98
+ test('. queue in a busy coordinator: each queued message gets a reply when it starts, also when it answers IDLE', async () => {
99
+ const before = await footers()
100
+ await startSlowTurn('First a long one')
101
+ const user = twin.discord.channel(twin.channelId).user(TEST_USER_ID)
102
+ const silent = await user.sendMessage({ content: 'Start a worker queued-idle. queue' })
103
+ await waitForBotMessageContaining({ discord: twin.discord, threadId: twin.channelId, text: 'position 1' })
104
+ const answered = await user.sendMessage({ content: 'Then answer queued-two. queue' })
105
+ await waitForBotMessageContaining({ discord: twin.discord, threadId: twin.channelId, text: 'queued two ok' })
106
+ // The IDLE turn posts no footer: slow-done and queued two get one each.
107
+ await waitForFooter({ discord: twin.discord, threadId: twin.channelId, count: before + 2 })
108
+ expect(await channelTextFrom('First a long one')).toMatchInlineSnapshot(`
109
+ "--- from: user (tommy)
110
+ First a long one slow-marker
111
+ Start a worker queued-idle. queue
112
+ --- from: assistant (TestBot)
113
+ [reply to tommy: Start a worker queued-idle. queue]
114
+ -# Queued
115
+ --- from: user (tommy)
116
+ Then answer queued-two. queue
117
+ --- from: assistant (TestBot)
118
+ [reply to tommy: Then answer queued-two. queue]
119
+ -# Queued
120
+ [reply to tommy: First a long one slow-marker]
121
+ slow-done
122
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
123
+ [reply to tommy: Start a worker queued-idle. queue]
124
+ -# Queued message sent
125
+ [reply to tommy: Then answer queued-two. queue]
126
+ -# Queued message sent
127
+ queued two ok
128
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
129
+ `)
130
+ const messages = await twin.discord.channel(twin.channelId).getMessages()
131
+ const sent = messages.filter((message) => message.content === '-# Queued message sent')
132
+ expect(sent.map((message) => message.message_reference?.message_id)).toEqual([silent.id, answered.id])
133
+ })
134
+
135
+ test('/queue and /clear-queue in a coordinator', async () => {
136
+ const before = await footers()
137
+ await startSlowTurn('Slash queue here')
138
+ const user = twin.discord.channel(twin.channelId).user(TEST_USER_ID)
139
+ await user.runSlashCommand({ name: 'queue', options: [{ name: 'message', type: 3, value: 'Slash queued-one' }] })
140
+ await waitForBotMessageContaining({ discord: twin.discord, threadId: twin.channelId, text: 'position 1' })
141
+ await user.runSlashCommand({ name: 'queue', options: [{ name: 'message', type: 3, value: 'Slash queued-two' }] })
142
+ await waitForBotMessageContaining({ discord: twin.discord, threadId: twin.channelId, text: 'position 2' })
143
+ await user.runSlashCommand({ name: 'clear-queue', options: [{ name: 'position', type: 4, value: 1 }] })
144
+ await waitForBotMessageContaining({ discord: twin.discord, threadId: twin.channelId, text: 'queued two ok' })
145
+ await waitForFooter({ discord: twin.discord, threadId: twin.channelId, count: before + 2 })
146
+ expect(await channelTextFrom('Slash queue here')).toMatchInlineSnapshot(`
147
+ "--- from: user (tommy)
148
+ Slash queue here slow-marker
149
+ --- from: assistant (TestBot)
150
+ -# » **tommy:** Slash queued-one
151
+ [reply to TestBot: -# » **tommy:** Slash queued-one]
152
+ -# Removed from queue
153
+ -# » **tommy:** Slash queued-two
154
+ [reply to TestBot: -# » **tommy:** Slash queued-two]
155
+ -# Queued
156
+ -# Cleared 1 queued message
157
+ [reply to tommy: Slash queue here slow-marker]
158
+ slow-done
159
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
160
+ [reply to TestBot: -# » **tommy:** Slash queued-two]
161
+ -# Queued message sent
162
+ queued two ok
163
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
164
+ `)
165
+ })
166
+
167
+ test('deleting a queued coordinator message removes it from the queue', async () => {
168
+ const before = await footers()
169
+ await startSlowTurn('Delete in coordinator')
170
+ const user = twin.discord.channel(twin.channelId).user(TEST_USER_ID)
171
+ const queued = await user.sendMessage({ content: 'Never mind queued-one. queue' })
172
+ await waitForBotMessageContaining({ discord: twin.discord, threadId: twin.channelId, text: 'position 1' })
173
+ await user.deleteMessage({ messageId: queued.id })
174
+ await waitForBotMessageContaining({ discord: twin.discord, threadId: twin.channelId, text: 'Removed from queue' })
175
+ await waitForFooter({ discord: twin.discord, threadId: twin.channelId, count: before + 1 })
176
+ await waitForIdle()
177
+ expect(await channelTextFrom('Delete in coordinator')).toMatchInlineSnapshot(`
178
+ "--- from: user (tommy)
179
+ Delete in coordinator slow-marker
180
+ --- from: assistant (TestBot)
181
+ [reply to unknown message]
182
+ -# Removed from queue
183
+ [reply to tommy: Delete in coordinator slow-marker]
184
+ slow-done
185
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
186
+ `)
187
+ })
188
+
189
+ test('kimaki send . queue and a TUI prompt queued on the coordinator show their text, then a reply when they run', async () => {
190
+ const before = await footers()
191
+ await startSlowTurn('Other sources')
192
+ await exec(process.execPath, ['--import', 'tsx', path.resolve('src/cli.ts'), 'send', '--session', rootSession(), '--prompt', 'From the CLI queued-one. queue', '--data-dir', dataDir], {
193
+ env: { ...process.env, KIMAKI_LOCK_PORT: String(bot.lock.port) },
194
+ })
195
+ await waitForBotMessageContaining({ discord: twin.discord, threadId: twin.channelId, text: 'position 1' })
196
+ await (await server.client()).session.prompt({ sessionID: rootSession(), text: 'From the TUI queued-two', delivery: 'queue' })
197
+ await waitForBotMessageContaining({ discord: twin.discord, threadId: twin.channelId, text: 'queued two ok' })
198
+ await waitForFooter({ discord: twin.discord, threadId: twin.channelId, count: before + 3 })
199
+ expect(await channelTextFrom('Other sources')).toMatchInlineSnapshot(`
200
+ "--- from: user (tommy)
201
+ Other sources slow-marker
202
+ --- from: assistant (TestBot)
203
+ -# » **CLI:** From the CLI queued-one
204
+ -# » **user:** From the TUI queued-two
205
+ [reply to tommy: Other sources slow-marker]
206
+ slow-done
207
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
208
+ [reply to TestBot: -# » **CLI:** From the CLI queued-one]
209
+ -# Queued message sent
210
+ queued one ok
211
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
212
+ [reply to TestBot: -# » **user:** From the TUI queued-two]
213
+ -# Queued message sent
214
+ queued two ok
215
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
216
+ `)
217
+ })
218
+
219
+ test('a plain message in a busy coordinator runs first, then the queued one gets its reply', async () => {
220
+ const before = await footers()
221
+ await startSlowTurn('Steer the coordinator')
222
+ const user = twin.discord.channel(twin.channelId).user(TEST_USER_ID)
223
+ await user.sendMessage({ content: 'Afterwards queued-one. queue' })
224
+ await waitForBotMessageContaining({ discord: twin.discord, threadId: twin.channelId, text: 'position 1' })
225
+ await user.sendMessage({ content: 'Right now steer-marker' })
226
+ await waitForBotMessageContaining({ discord: twin.discord, threadId: twin.channelId, text: 'queued one ok' })
227
+ await waitForFooter({ discord: twin.discord, threadId: twin.channelId, count: before + 2 })
228
+ expect(await channelTextFrom('Steer the coordinator')).toMatchInlineSnapshot(`
229
+ "--- from: user (tommy)
230
+ Steer the coordinator slow-marker
231
+ Afterwards queued-one. queue
232
+ --- from: assistant (TestBot)
233
+ [reply to tommy: Afterwards queued-one. queue]
234
+ -# Queued
235
+ --- from: user (tommy)
236
+ Right now steer-marker
237
+ --- from: assistant (TestBot)
238
+ [reply to tommy: Right now steer-marker]
239
+ steer ok
240
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
241
+ [reply to tommy: Afterwards queued-one. queue]
242
+ -# Queued message sent
243
+ queued one ok
244
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
245
+ `)
246
+ })
247
+
248
+ test('/abort in a coordinator stops the run and clears the queue', async () => {
249
+ await startSlowTurn('Abort the coordinator')
250
+ const user = twin.discord.channel(twin.channelId).user(TEST_USER_ID)
251
+ await user.sendMessage({ content: 'Never runs queued-one. queue' })
252
+ await waitForBotMessageContaining({ discord: twin.discord, threadId: twin.channelId, text: 'position 1' })
253
+ await user.runSlashCommand({ name: 'abort' })
254
+ await waitForBotMessageContaining({ discord: twin.discord, threadId: twin.channelId, text: 'Removed from queue' })
255
+ await waitForIdle()
256
+ expect(await channelTextFrom('Abort the coordinator')).toMatchInlineSnapshot(`
257
+ "--- from: user (tommy)
258
+ Abort the coordinator slow-marker
259
+ Never runs queued-one. queue
260
+ --- from: assistant (TestBot)
261
+ [reply to tommy: Never runs queued-one. queue]
262
+ -# Removed from queue
263
+ Request **aborted**, cleared 1 queued message"
264
+ `)
265
+ })
266
+
267
+ test('. queue on an idle coordinator runs at once: the answer replies to the message', async () => {
268
+ const before = await footers()
269
+ await twin.discord.channel(twin.channelId).user(TEST_USER_ID).sendMessage({ content: 'Nothing waits idle-marker. queue' })
270
+ await waitForFooter({ discord: twin.discord, threadId: twin.channelId, count: before + 1 })
271
+ expect(await channelTextFrom('Nothing waits idle-marker')).toMatchInlineSnapshot(`
272
+ "--- from: user (tommy)
273
+ Nothing waits idle-marker. queue
274
+ --- from: assistant (TestBot)
275
+ [reply to tommy: Nothing waits idle-marker. queue]
276
+ idle ok
277
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
278
+ `)
279
+ })
@@ -0,0 +1,158 @@
1
+ // The coordinator warmup (plugin/coordinator.ts) with short windows: keep-alives while
2
+ // a worker thread runs, compaction when the channel is idle again, and the cap
3
+ // that stops the warmup while a worker is stuck.
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 {
11
+ TEST_MODEL,
12
+ TEST_USER_ID,
13
+ seedProjectChannel,
14
+ startOpencodeTestServer,
15
+ startTestBot,
16
+ startTwin,
17
+ tempDataDir,
18
+ textParts,
19
+ toolParts,
20
+ waitFor,
21
+ waitForFooter,
22
+ warmUp,
23
+ type OpencodeTestServer,
24
+ type TestTwin,
25
+ } from './test/harness.ts'
26
+
27
+ let server: OpencodeTestServer
28
+ let twin: TestTwin
29
+ let bot: BotHandle
30
+ const dataDir = tempDataDir()
31
+ // Big enough to be worth compacting (plugin/coordinator.ts MIN_COMPACT_TOKENS).
32
+ const bigUsage = { inputTokens: 120_000, outputTokens: 1, totalTokens: 120_001 }
33
+ const WARM_INTERVAL_MS = 400
34
+ const WARM_MAX_MS = 1_500
35
+
36
+ function bigText(text: string) {
37
+ return [
38
+ { type: 'text-start', id: 't' },
39
+ { type: 'text-delta', id: 't', delta: text },
40
+ { type: 'text-end', id: 't' },
41
+ { type: 'finish', finishReason: 'stop', usage: bigUsage },
42
+ ] as const
43
+ }
44
+
45
+ // The coordinator starts a worker thread, then answers IDLE.
46
+ function delegate({ marker, worker, channelId }: { marker: string; worker: string; channelId: string }) {
47
+ const command = `kimaki send --channel ${channelId} --name 'Worker ${worker}' --prompt '${worker} run' --parent-session "$OPENCODE_SESSION_ID"`
48
+ return [
49
+ {
50
+ id: `${marker}-send`,
51
+ priority: 100,
52
+ when: { latestUserTextIncludes: marker },
53
+ then: { parts: toolParts({ toolCallId: `${marker}-call`, toolName: 'shell', input: { command, description: 'Start the worker', hasSideEffect: true } }) },
54
+ },
55
+ { id: `${marker}-idle`, priority: 101, when: { latestUserTextIncludes: marker, rawPromptIncludes: `${marker}-call` }, then: { parts: [...bigText('IDLE')] } },
56
+ ]
57
+ }
58
+
59
+ beforeAll(async () => {
60
+ twin = await startTwin()
61
+ server = await startOpencodeTestServer({
62
+ plugins: [path.resolve('src/test/generate-log-plugin')],
63
+ models: { [TEST_MODEL]: { name: TEST_MODEL, limit: { context: 1_000_000, output: 32_000 } } },
64
+ matchers: [
65
+ // OpenCode rejects a summary without the template headings.
66
+ { id: 'compaction-summary', priority: 900, when: { latestUserTextIncludes: 'You MUST use this format for your response' }, then: { parts: textParts('## Objective\n- Keep coordinating') } },
67
+ ...delegate({ marker: 'keepwarm-marker', worker: 'quickjob-marker', channelId: twin.channelId }),
68
+ ...delegate({ marker: 'capped-marker', worker: 'stuckjob-marker', channelId: twin.channelId }),
69
+ { id: 'quickjob', priority: 100, when: { latestUserTextIncludes: 'quickjob-marker' }, then: { parts: textParts('Quick job done.'), partDelaysMs: [0, 1_200, 0, 0] } },
70
+ { id: 'stuckjob', priority: 100, when: { latestUserTextIncludes: 'stuckjob-marker' }, then: { parts: textParts('Stuck job done.'), partDelaysMs: [0, 3_000, 0, 0] } },
71
+ { id: 'woken', priority: 300, when: { latestUserTextIncludes: '<session-finished' }, then: { parts: [...bigText('The worker finished.')] } },
72
+ ],
73
+ })
74
+ await seedProjectChannel({ dataDir, channelId: twin.channelId, guildId: twin.discord.guildId, directory: server.projectDirectory })
75
+ await warmUp({ server })
76
+ bot = await startTestBot({ dataDir, twin, server, coordinator: true, warmupTimers: { intervalMs: WARM_INTERVAL_MS, maxMs: WARM_MAX_MS } })
77
+ }, 60_000)
78
+
79
+ afterAll(async () => {
80
+ await bot?.stop()
81
+ await twin?.stop()
82
+ await server?.stop()
83
+ fs.rmSync(dataDir, { recursive: true, force: true })
84
+ })
85
+
86
+ // Keep-alive requests of a session, from the test plugin.
87
+ function keepAlives(sessionId: string): number {
88
+ const file = path.join(server.root, 'generate.log')
89
+ if (!fs.existsSync(file)) return 0
90
+ return fs.readFileSync(file, 'utf8').split('\n').filter((line) => line === sessionId).length
91
+ }
92
+
93
+ async function compactions(sessionId: string): Promise<number> {
94
+ const client = await server.client()
95
+ const messages = await client.message.list({ sessionID: sessionId, order: 'desc', limit: 50 })
96
+ return messages.data.filter((message) => message.type === 'compaction').length
97
+ }
98
+
99
+ test('the coordinator stays warm while its worker runs, then compacts', async () => {
100
+ const { discord, channelId } = twin
101
+ await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'Start keepwarm-marker' })
102
+ const root = await waitFor({ label: 'coordinator session', check: async () => bot.store.getState().roots[channelId] })
103
+ await waitForFooter({ discord, threadId: channelId })
104
+ await waitFor({ label: 'compaction after the wake', check: async () => (await compactions(root)) === 1 || null })
105
+
106
+ expect(await discord.channel(channelId).text()).toMatchInlineSnapshot(`
107
+ "--- from: user (tommy)
108
+ Start keepwarm-marker
109
+ --- from: assistant (TestBot)
110
+ -# *using deterministic-provider/deterministic-v2 ⋅ build*
111
+ -# 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\`.
112
+ -# ┣ shell _Start the worker_
113
+ The worker finished.
114
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
115
+ `)
116
+ // The worker runs 1.2s, a keep-alive is due every 0.4s.
117
+ expect(keepAlives(root)).toBeGreaterThan(0)
118
+ const client = await server.client()
119
+ const messages = await client.message.list({ sessionID: root, order: 'asc', limit: 30 })
120
+ // No compaction before the wake: the channel was busy.
121
+ expect(messages.data.map((message) => message.type)).toMatchInlineSnapshot(`
122
+ [
123
+ "synthetic",
124
+ "synthetic",
125
+ "user",
126
+ "assistant",
127
+ "assistant",
128
+ "idle",
129
+ "synthetic",
130
+ "assistant",
131
+ "idle",
132
+ "compaction",
133
+ "idle",
134
+ ]
135
+ `)
136
+ })
137
+
138
+ test('a stuck worker stops the warmup at the cap', async () => {
139
+ const { discord, channelId } = twin
140
+ const root = bot.store.getState().roots[channelId]!
141
+ const before = keepAlives(root)
142
+ await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'Start capped-marker' })
143
+ const worker = await waitFor({
144
+ label: 'stuck worker thread',
145
+ check: async () => (await discord.channel(channelId).getThreads()).find((thread) => thread.name?.includes('stuckjob')),
146
+ })
147
+ // The cap ends the warmup while the worker still runs: compaction, no footer in the worker yet.
148
+ await waitFor({ label: 'compaction at the cap', check: async () => (await compactions(root)) === 2 || null })
149
+ const workerText = await discord.thread(worker.id).text()
150
+ const atCap = keepAlives(root)
151
+ await waitForFooter({ discord, threadId: worker.id })
152
+
153
+ expect(workerText).not.toContain('-# *main')
154
+ // 1.5s cap, one keep-alive every 0.4s: at most 3, none after the cap.
155
+ expect(atCap - before).toBeGreaterThan(0)
156
+ expect(atCap - before).toBeLessThanOrEqual(3)
157
+ expect(keepAlives(root)).toBe(atCap)
158
+ })