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
@@ -32,6 +32,7 @@ function reply(marker: string, text: string, extra: DeterministicMatcher['when']
32
32
  const matchers: DeterministicMatcher[] = [
33
33
  reply('new-marker', 'saw the file', { rawPromptIncludes: 'file-body-marker' }),
34
34
  reply('first-marker', 'first answer'),
35
+ reply('empty-marker', 'started on the first message'),
35
36
  reply('second-marker', 'second answer', {}, 510),
36
37
  reply('tui-marker', 'tui answer'),
37
38
  reply('resumed-marker', 'resumed answer'),
@@ -137,16 +138,42 @@ test('/new-session starts a thread with attached files and an agent', async () =
137
138
  `)
138
139
  expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
139
140
  "--- from: assistant (TestBot)
140
- » **tommy:** Read it new-marker
141
- Files: notes.txt
141
+ -# » **tommy:** Read it new-marker
142
+ -# Files: notes.txt
142
143
  -# *using deterministic-provider/deterministic-v2 ⋅ plan*
143
144
  saw the file
144
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2 ⋅ plan*"
145
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2 ⋅ plan*"
145
146
  `)
146
147
  // A slash command thread has no start message: the user must be added explicitly.
147
148
  expect(await discord.channel(thread.id).getMemberIds()).toContain(TEST_USER_ID)
148
149
  })
149
150
 
151
+ test('/new-session without a prompt waits for the first thread message', async () => {
152
+ const { discord, channelId } = twin
153
+ const user = discord.channel(channelId).user(TEST_USER_ID)
154
+ const client = await server.client()
155
+ const thread = await newThread(() =>
156
+ user.runSlashCommand({ name: 'new-session', options: [{ name: 'agent', type: 3, value: 'plan' }] }),
157
+ )
158
+ await waitFor({ label: 'ready note', check: async () => (await discord.thread(thread.id).text()).includes('Send a message') })
159
+ const sessionId = bot.store.getState().roots[thread.id]
160
+ if (!sessionId) throw new Error('thread has no session')
161
+ expect((await client.message.list({ sessionID: sessionId, order: 'asc', limit: 10 })).data).toEqual([])
162
+ await discord.thread(thread.id).user(TEST_USER_ID).sendMessage({ content: 'Go empty-marker' })
163
+ await waitForFooter({ discord, threadId: thread.id })
164
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
165
+ "--- from: assistant (TestBot)
166
+ Send a message to start working.
167
+ --- from: user (tommy)
168
+ Go empty-marker
169
+ --- from: assistant (TestBot)
170
+ -# *using deterministic-provider/deterministic-v2 ⋅ plan*
171
+ started on the first message
172
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2 ⋅ plan*"
173
+ `)
174
+ expect(thread.name).toBe('Kimaki session')
175
+ })
176
+
150
177
  test('/resume binds an existing session to a new thread and moves the binding', async () => {
151
178
  const { discord, channelId } = twin
152
179
  // A session started outside Discord (for example in the TUI).
@@ -180,10 +207,10 @@ test('/resume binds an existing session to a new thread and moves the binding',
180
207
  Go on resumed-marker
181
208
  --- from: assistant (TestBot)
182
209
  resumed answer
183
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
184
- » **user:** Back in the TUI tui-marker
210
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
211
+ -# » **user:** Back in the TUI tui-marker
185
212
  tui answer
186
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
213
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
187
214
  `)
188
215
 
189
216
  // Resuming again moves the session: the old thread stops following it.
@@ -229,7 +256,7 @@ test('/fork forks before a user message into a new thread', async () => {
229
256
  Check fork-check
230
257
  --- from: assistant (TestBot)
231
258
  fork has only the first message
232
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
259
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
233
260
  `)
234
261
  expect(hide(await discord.thread(source.id).text())).toMatchInlineSnapshot(`
235
262
  "--- from: user (tommy)
@@ -237,12 +264,12 @@ test('/fork forks before a user message into a new thread', async () => {
237
264
  --- from: assistant (TestBot)
238
265
  -# *using deterministic-provider/deterministic-v2 ⋅ build*
239
266
  first answer
240
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
267
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
241
268
  --- from: user (tommy)
242
269
  Then second-marker
243
270
  --- from: assistant (TestBot)
244
271
  second answer
245
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
272
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
246
273
  Session forked! Continue in <#THREAD>"
247
274
  `)
248
275
  })
@@ -296,7 +323,7 @@ test('/fork-subagent forks a subagent session into a new thread', async () => {
296
323
  More child-followup
297
324
  --- from: assistant (TestBot)
298
325
  child fork continues
299
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2 ⋅ general*"
326
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2 ⋅ general*"
300
327
  `)
301
328
  })
302
329
 
@@ -321,6 +348,6 @@ test('/resume of a session waiting on a question shows the question in the new t
321
348
  Which color?
322
349
  ✓ _Red_
323
350
  ok
324
- -# *project ⋅ main ⋅ Ns ⋅ deterministic-v2*"
351
+ -# *main ⋅ Ns ⋅ deterministic-v2*"
325
352
  `)
326
353
  })
@@ -14,7 +14,7 @@
14
14
  import crypto from 'node:crypto'
15
15
  import fs from 'node:fs'
16
16
  import path from 'node:path'
17
- import type { OpenCodeClient } from '@opencode/client'
17
+ import type { OpenCodeClient, V2Event } from '@opencode/client'
18
18
 
19
19
  import type { KimakiDb } from './db.ts'
20
20
  import { ConfigError, DbError, OpenCodeError } from './errors.ts'
@@ -107,12 +107,16 @@ export async function resolveSession({
107
107
  return { threadId: row.thread_id, sessionId: row.session_id }
108
108
  }
109
109
 
110
- type Message = Awaited<ReturnType<OpenCodeClient['message']['list']>>['data'][number]
110
+ export type Message = Awaited<ReturnType<OpenCodeClient['message']['list']>>['data'][number]
111
111
  type Session = Awaited<ReturnType<OpenCodeClient['session']['get']>>
112
112
 
113
+ // Long enough for most edits, commands and subagent prompts; huge writes stay short.
114
+ const TOOL_INPUT_LIMIT = 5000
115
+
113
116
  function renderMessage(message: Message, options: { thinking?: boolean; verbose?: boolean }): string {
114
117
  const time = new Date(message.time.created).toISOString()
115
- if (message.type === 'user') return `## user ${time}\n\n${message.text}`
118
+ if (message.type === 'user' || message.type === 'synthetic' || message.type === 'system') return `## ${message.type} ${time}\n\n${message.text}`
119
+ if (message.type === 'compaction' && message.status !== 'failed') return `## compaction ${time}\n\n${message.summary}`
116
120
  if (message.type !== 'assistant') return `## ${message.type} ${time}\n\n${JSON.stringify(message)}`
117
121
  const header = `## assistant ${time} (${message.agent}, ${message.model.providerID}/${message.model.id})`
118
122
  const parts = message.content.map((part) => {
@@ -120,10 +124,12 @@ function renderMessage(message: Message, options: { thinking?: boolean; verbose?
120
124
  if (part.type === 'reasoning') return options.thinking ? part.text : ''
121
125
  if (part.type === 'tool') {
122
126
  const state = part.state
123
- const input = options.verbose ? JSON.stringify(state.input ?? {}) : JSON.stringify(state.input ?? {}).slice(0, 500)
127
+ const input = options.verbose ? JSON.stringify(state.input ?? {}) : JSON.stringify(state.input ?? {}).slice(0, TOOL_INPUT_LIMIT)
124
128
  const error = state.status === 'error' ? `\nerror: ${JSON.stringify(state).slice(0, 500)}` : ''
125
129
  const output = options.verbose && state.status === 'completed' ? `\n${JSON.stringify(state.content)}` : ''
126
- return `tool ${part.name} [${state.status}] ${input}${error}${output}`
130
+ // Without --verbose tool outputs are hidden, but uploaded media stays findable.
131
+ const media = options.verbose ? '' : mediaBlock(uploadedMedia(part))
132
+ return `tool ${part.name} [${state.status}] ${input}${error}${output}${media && `\n${media}`}`
127
133
  }
128
134
  return ''
129
135
  })
@@ -131,34 +137,139 @@ function renderMessage(message: Message, options: { thinking?: boolean; verbose?
131
137
  return `${header}\n\n${parts.join('\n\n')}${finish}`
132
138
  }
133
139
 
140
+ export function formatDuration(ms: number): string {
141
+ if (ms < 1_000) return '<1s'
142
+ const seconds = Math.floor(ms / 1_000)
143
+ if (seconds < 60) return `${seconds}s`
144
+ const minutes = Math.floor(seconds / 60)
145
+ if (minutes < 60) return `${minutes}m ${seconds % 60}s`
146
+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
147
+ }
148
+
149
+ export function formatTokens(tokens: number): string {
150
+ return tokens < 1_000 ? String(tokens) : `${Math.round(tokens / 1_000)}k`
151
+ }
152
+
153
+ export type SessionUsage = { contextTokens: number; runMs: number | null; endedAt: number }
154
+
155
+ // Context size of the latest step, length of the latest run and when it ended. Oldest-first input.
156
+ // Agents use it to judge if a follow-up would rewrite a big, expired prompt cache.
157
+ export function sessionUsage(messages: readonly Message[]): SessionUsage | null {
158
+ const lastIndex = messages.findLastIndex((message) => message.type === 'assistant')
159
+ const last = messages[lastIndex]
160
+ if (last?.type !== 'assistant') return null
161
+ const step = messages.findLast((message) => message.type === 'assistant' && !!message.tokens)
162
+ const tokens = step?.type === 'assistant' ? step.tokens : undefined
163
+ const contextTokens = tokens ? tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write : 0
164
+ // A run starts at a user prompt. Synthetic items also arrive mid-run (subagent results), so they count only without one.
165
+ const before = messages.slice(0, lastIndex)
166
+ const start = before.findLast((message) => message.type === 'user') ?? before.findLast((message) => message.type === 'synthetic')
167
+ const endedAt = last.time.completed ?? last.time.created
168
+ return { contextTokens, runMs: start ? endedAt - start.time.created : null, endedAt }
169
+ }
170
+
171
+ function usageLine(usage: SessionUsage | null, now: number): string {
172
+ if (!usage) return ''
173
+ const run = usage.runMs === null ? '' : `, last run took ${formatDuration(usage.runMs)}`
174
+ return `\n\ncontext: ${formatTokens(usage.contextTokens)} tokens${run}, last active ${formatDuration(now - usage.endedAt)} ago (${new Date(usage.endedAt).toISOString()})`
175
+ }
176
+
177
+ // The text of the last assistant message that has text, like OpenCode's
178
+ // subagent completion (session/subagent-completion.ts). Oldest-first input.
179
+ export function finalAnswer(messages: readonly Message[]): string | null {
180
+ for (const message of [...messages].reverse()) {
181
+ if (message.type === 'user') return null
182
+ if (message.type !== 'assistant') continue
183
+ const text = message.content.flatMap((part) => (part.type === 'text' && part.text.trim() ? [part.text.trim()] : [])).join('\n\n')
184
+ if (text) return text
185
+ }
186
+ return null
187
+ }
188
+
189
+ type MessagePart = Extract<Message, { type: 'assistant' }>['content'][number]
190
+
191
+ const UPLOAD_RESULT_PREFIX = '{"uploaded":'
192
+ // `"url":"..."` of each file in that JSON line (upload in prompt.ts). A regex, not zod:
193
+ // this module loads on every CLI start. Discord URLs have no quotes or backslashes.
194
+ const UPLOAD_URL = /"url":"([^"\\]+)"/g
195
+
196
+ // Discord URLs of the files a `kimaki upload-to-discord` call posted, from the result
197
+ // line it printed into the shell output (the system prompt says never to pipe it).
198
+ // The session history holds them, so this needs no Discord read and survives restarts.
199
+ function uploadedMedia(part: MessagePart): string[] {
200
+ if (part.type !== 'tool' || part.state.status !== 'completed') return []
201
+ return part.state.content
202
+ .flatMap((content) => (content.type === 'text' ? content.text.split('\n') : []))
203
+ .filter((line) => line.startsWith(UPLOAD_RESULT_PREFIX))
204
+ .flatMap((line) => [...line.matchAll(UPLOAD_URL)].map((match) => match[1]!))
205
+ }
206
+
207
+ function mediaBlock(urls: readonly string[]): string {
208
+ return urls.length === 0 ? '' : `Media uploaded to Discord:\n${urls.map((url) => `- ${url}`).join('\n')}`
209
+ }
210
+
211
+ // Inputs and the final answer of each turn, no tool calls: what agents read most often.
212
+ function renderAnswers({ session, messages, usage = '' }: { session: Pick<Session, 'id' | 'title' | 'location'>; messages: Message[]; usage?: string }): string {
213
+ const title = `# ${session.title} (${session.id})\n\ndirectory: ${session.location.directory}${usage}`
214
+ const blocks: string[] = []
215
+ let turn: Message[] = []
216
+ const flush = () => {
217
+ const answer = finalAnswer(turn)
218
+ const last = turn.findLast((message) => message.type === 'assistant')
219
+ const media = mediaBlock(turn.flatMap((message) => (message.type === 'assistant' ? message.content.flatMap(uploadedMedia) : [])))
220
+ if (answer && last?.type === 'assistant') blocks.push(`## assistant ${new Date(last.time.created).toISOString()} (${last.agent}, ${last.model.providerID}/${last.model.id})\n\n${answer}${media && `\n\n${media}`}`)
221
+ turn = []
222
+ }
223
+ for (const message of messages) {
224
+ if (message.type === 'assistant') {
225
+ turn.push(message)
226
+ continue
227
+ }
228
+ flush()
229
+ if (message.type === 'user' || message.type === 'synthetic') blocks.push(`## ${message.type} ${new Date(message.time.created).toISOString()}\n\n${message.text}`)
230
+ if (message.type === 'compaction' && message.status !== 'failed') blocks.push(`## compaction ${new Date(message.time.created).toISOString()}\n\n${message.summary}`)
231
+ }
232
+ // A turn without an answer yet (running, or waiting on a question): show its tool calls.
233
+ if (turn.length > 0 && !finalAnswer(turn)) blocks.push(...turn.map((message) => renderMessage(message, {})))
234
+ else flush()
235
+ const more = `Only prompts and final answers are shown. Every tool call and the reasoning: kimaki session read ${session.id} --thinking`
236
+ return [title, ...blocks, more].join('\n\n')
237
+ }
238
+
134
239
  // Oldest first, as markdown. OpenCode returns the newest first.
240
+ // Default: prompts and final answers. `full`: every message with tool calls.
135
241
  export async function readSessionMarkdown({
136
242
  client,
137
243
  sessionId,
244
+ full,
138
245
  thinking,
139
246
  verbose,
140
247
  }: {
141
248
  client: OpenCodeClient
142
249
  sessionId: string
250
+ full?: boolean
143
251
  thinking?: boolean
144
252
  verbose?: boolean
145
253
  }): Promise<OpenCodeError | string> {
146
- const session = await client.session
147
- .get({ sessionID: sessionId })
148
- .catch((e) => new OpenCodeError({ operation: 'session.get', cause: e }))
254
+ const [session, messages] = await Promise.all([
255
+ client.session.get({ sessionID: sessionId }).catch((e) => new OpenCodeError({ operation: 'session.get', cause: e })),
256
+ allMessages({ client, sessionId }),
257
+ ])
149
258
  if (session instanceof Error) return session
150
- const messages = await allMessages({ client, sessionId })
151
259
  if (messages instanceof Error) return messages
152
- return renderSession({ session, messages, thinking, verbose })
260
+ const usage = usageLine(sessionUsage(messages), Date.now())
261
+ if (!full && !thinking && !verbose) return renderAnswers({ session, messages, usage })
262
+ return renderSession({ session, messages, thinking, verbose, usage })
153
263
  }
154
264
 
155
- function renderSession({ session, messages, thinking, verbose }: { session: Pick<Session, 'id' | 'title' | 'location'>; messages: Message[]; thinking?: boolean; verbose?: boolean }): string {
156
- const title = `# ${session.title} (${session.id})\n\ndirectory: ${session.location.directory}`
265
+ // Transcripts pass no usage: their "ago" would be stale on disk.
266
+ function renderSession({ session, messages, thinking, verbose, usage = '' }: { session: Pick<Session, 'id' | 'title' | 'location'>; messages: Message[]; thinking?: boolean; verbose?: boolean; usage?: string }): string {
267
+ const title = `# ${session.title} (${session.id})\n\ndirectory: ${session.location.directory}${usage}`
157
268
  return [title, ...messages.map((message) => renderMessage(message, { thinking, verbose }))].join('\n\n')
158
269
  }
159
270
 
160
271
  // Transcripts: <dataDir>/transcripts/<sessionId>.md, the `session read` markdown
161
- // (no thinking, no tool outputs) so agents can search every session with rg.
272
+ // with thinking but no tool outputs, so agents can search every session with rg.
162
273
  // The file mtime is the session's time.updated: a file at least that new is current.
163
274
  export function transcriptsDir({ dataDir }: { dataDir: string }): string {
164
275
  return path.join(dataDir, 'transcripts')
@@ -172,7 +283,7 @@ export async function writeTranscript({ client, dataDir, session }: { client: Op
172
283
  const temp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`
173
284
  const written = await (async () => {
174
285
  await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 })
175
- await fs.promises.writeFile(temp, `${renderSession({ session, messages })}\n`, { mode: 0o600 })
286
+ await fs.promises.writeFile(temp, `${renderSession({ session, messages, thinking: true })}\n`, { mode: 0o600 })
176
287
  await fs.promises.utimes(temp, session.time.updated / 1000, session.time.updated / 1000)
177
288
  await fs.promises.rename(temp, file)
178
289
  })().catch((cause) => new ConfigError({ reason: `Cannot write ${file}`, cause }))
@@ -260,14 +371,34 @@ export function allMessages({ client, sessionId, type }: { client: OpenCodeClien
260
371
  })
261
372
  }
262
373
 
263
- export function allSessions({ client, directory }: { client: OpenCodeClient; directory?: string }) {
374
+ export function allSessions({ client, project }: { client: OpenCodeClient; project?: string }) {
264
375
  return collectPages({
265
376
  operation: 'session.list',
266
- page: (cursor) => client.session.list({ directory, limit: 200, ...(cursor ? { cursor } : { order: 'desc' as const }) }),
377
+ page: (cursor) => client.session.list({ project, limit: 200, ...(cursor ? { cursor } : { order: 'desc' as const }) }),
267
378
  })
268
379
  }
269
380
 
270
- export async function waitForSessionReady({ client, sessionId, signal }: { client: OpenCodeClient; sessionId: string; signal?: AbortSignal }): Promise<OpenCodeError | void> {
381
+ // Why `kimaki session wait` returned for a session. `idle`: no run was in progress
382
+ // when the wait started. `running`: the timeout ended the wait first.
383
+ export type WaitStatus = 'finished' | 'failed' | 'interrupted' | 'idle' | 'question' | 'permission' | 'running'
384
+
385
+ function eventStatus(event: V2Event): { sessionId: string; status: WaitStatus } | null {
386
+ if (event.type === 'form.created') return { sessionId: event.data.form.sessionID, status: 'question' }
387
+ if (event.type === 'permission.asked') return { sessionId: event.data.sessionID, status: 'permission' }
388
+ if (event.type === 'session.execution.succeeded') return { sessionId: event.data.sessionID, status: 'finished' }
389
+ if (event.type === 'session.execution.failed') return { sessionId: event.data.sessionID, status: 'failed' }
390
+ if (event.type === 'session.execution.interrupted') return { sessionId: event.data.sessionID, status: 'interrupted' }
391
+ return null
392
+ }
393
+
394
+ // Waits until every session ended its run or paused for the user, on one event stream.
395
+ // When `signal` ends the wait, the sessions not done yet get `running`.
396
+ export async function waitForSessions({ client, sessionIds, signal }: { client: OpenCodeClient; sessionIds: readonly string[]; signal?: AbortSignal }): Promise<OpenCodeError | Map<string, WaitStatus>> {
397
+ const statuses = new Map<string, WaitStatus>()
398
+ const settle = (sessionId: string, status: WaitStatus) => {
399
+ if (sessionIds.includes(sessionId) && !statuses.has(sessionId)) statuses.set(sessionId, status)
400
+ }
401
+ const done = () => statuses.size === new Set(sessionIds).size
271
402
  const controller = new AbortController()
272
403
  const subscriptionSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal
273
404
  const iterator = client.event.subscribe({ signal: subscriptionSignal })[Symbol.asyncIterator]()
@@ -275,27 +406,39 @@ export async function waitForSessionReady({ client, sessionId, signal }: { clien
275
406
  const first = await iterator.next().catch((cause) => new OpenCodeError({ operation: 'event.subscribe', cause }))
276
407
  if (first instanceof Error) return first
277
408
  if (first.done || first.value.type !== 'server.connected') return new OpenCodeError({ operation: 'connect session wait stream' })
409
+ // Subscribed before hydrating, so no run can end unseen in between.
278
410
  const consume = (async () => {
279
411
  while (!subscriptionSignal.aborted) {
280
412
  const next = await iterator.next().catch((cause) => new OpenCodeError({ operation: 'event.subscribe', cause }))
281
413
  if (next instanceof Error) return next
282
414
  if (next.done) return new OpenCodeError({ operation: 'session wait stream ended' })
283
- const event = next.value
284
- if (event.type === 'form.created' && event.data.form.sessionID === sessionId) return
285
- if (event.type === 'permission.asked' && event.data.sessionID === sessionId) return
286
- if ((event.type === 'session.execution.succeeded' || event.type === 'session.execution.failed' || event.type === 'session.execution.interrupted') && event.data.sessionID === sessionId) return
415
+ const ended = eventStatus(next.value)
416
+ if (ended) settle(ended.sessionId, ended.status)
417
+ if (done()) return
287
418
  }
288
419
  return new OpenCodeError({ operation: 'session wait cancelled' })
289
420
  })()
290
421
  const hydrated = await Promise.all([
291
- client.session.active(), client.session.form.list({ sessionID: sessionId }), client.permission.list({ sessionID: sessionId }),
422
+ client.session.active({ signal: subscriptionSignal }),
423
+ Promise.all(sessionIds.map((sessionID) => Promise.all([
424
+ client.session.form.list({ sessionID }, { signal: subscriptionSignal }),
425
+ client.permission.list({ sessionID }, { signal: subscriptionSignal }),
426
+ ]))),
292
427
  ]).catch((cause) => new OpenCodeError({ operation: 'hydrate session wait', cause }))
293
428
  if (hydrated instanceof Error) return hydrated
294
- const [active, forms, permissions] = hydrated
295
- if (!(sessionId in active) || forms.length > 0 || permissions.length > 0) return
429
+ const [active, pending] = hydrated
430
+ for (const [index, sessionId] of sessionIds.entries()) {
431
+ const [forms, permissions] = pending[index] ?? [[], []]
432
+ if (forms.length > 0) settle(sessionId, 'question')
433
+ if (permissions.length > 0) settle(sessionId, 'permission')
434
+ if (!active[sessionId]) settle(sessionId, 'idle')
435
+ }
436
+ if (done()) return
296
437
  return consume
297
438
  })()
298
439
  controller.abort()
299
440
  await iterator.return?.(undefined).catch(() => undefined)
300
- return result
441
+ if (result instanceof Error && !signal?.aborted) return result
442
+ for (const sessionId of sessionIds) settle(sessionId, 'running')
443
+ return statuses
301
444
  }
@@ -16,6 +16,7 @@ import {
16
16
  startTwin,
17
17
  tempDataDir,
18
18
  textParts,
19
+ waitFor,
19
20
  waitForBotMessageContaining,
20
21
  waitForFooter,
21
22
  warmUp,
@@ -32,6 +33,21 @@ const matchers: DeterministicMatcher[] = [
32
33
  then: { parts: textParts('## Objective\n- Test compaction') },
33
34
  },
34
35
  { id: 'usage', priority: 500, when: { latestUserTextIncludes: 'usage-marker' }, then: { parts: textParts('counted') } },
36
+ // A reasoning block that stays open long enough to run /context-usage inside it.
37
+ {
38
+ id: 'slow-thinking',
39
+ priority: 500,
40
+ when: { latestUserTextIncludes: 'thinking-marker' },
41
+ then: {
42
+ parts: [
43
+ { type: 'reasoning-start', id: 'r1' },
44
+ { type: 'reasoning-delta', id: 'r1', delta: 'pondering' },
45
+ { type: 'reasoning-end', id: 'r1' },
46
+ ...textParts('thought it through'),
47
+ ],
48
+ partDelaysMs: [0, 0, 2_500, 0, 0, 0, 0],
49
+ },
50
+ },
35
51
  ...scriptedTurn({
36
52
  marker: 'write-marker',
37
53
  steps: [{ id: 'call-write-1', tool: 'write', input: { path: 'undo.txt', content: 'written by the agent\n' } }],
@@ -75,40 +91,77 @@ async function command(threadId: string, name: string, text: string) {
75
91
  return waitForBotMessageContaining({ discord: twin.discord, threadId, text })
76
92
  }
77
93
 
94
+ // /context-usage status durations depend on timing.
95
+ function stableDurations(text: string) {
96
+ return text.replace(/<1s|\b\d+m \d+s\b|\b\d+s\b/g, 'Ns')
97
+ }
98
+
78
99
  test('/diff with a clean working tree and /context-usage after a turn', async () => {
79
100
  const thread = await newThread('Hello usage-marker')
80
101
  await command(thread.id, 'diff', 'No changes')
81
102
  await command(thread.id, 'context-usage', 'Context usage')
82
- expect(await twin.discord.thread(thread.id).text()).toMatchInlineSnapshot(`
103
+ expect(stableDurations(await twin.discord.thread(thread.id).text())).toMatchInlineSnapshot(`
83
104
  "--- from: user (tommy)
84
105
  Hello usage-marker
85
106
  --- from: assistant (TestBot)
86
107
  -# *using deterministic-provider/deterministic-v2 ⋅ build*
87
108
  counted
88
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
109
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
89
110
  No changes to show
90
111
  **Context usage:** 0%, 2 / 200,000 tokens
91
- **Model:** deterministic-provider/deterministic-v2"
112
+ **Model:** deterministic-provider/deterministic-v2
113
+ **Status:** idle for Ns"
114
+ `)
115
+ })
116
+
117
+ test('/context-usage while the model thinks shows the run and what it does', async () => {
118
+ const { discord, channelId } = twin
119
+ const before = new Set((await discord.channel(channelId).getThreads()).map((thread) => thread.id))
120
+ await discord.channel(channelId).user(TEST_USER_ID).sendMessage({ content: 'Ponder this thinking-marker' })
121
+ const thread = await discord.channel(channelId).waitForThread({ timeout: 8_000, predicate: (candidate) => !before.has(candidate.id) })
122
+ const client = await server.client()
123
+ await waitFor({
124
+ label: 'open reasoning block',
125
+ check: async () => {
126
+ const sessionId = bot.store.getState().roots[thread.id]
127
+ if (!sessionId) return null
128
+ const messages = await client.message.list({ sessionID: sessionId, type: 'assistant', order: 'desc', limit: 1 })
129
+ return messages.data.some((message) => message.type === 'assistant' && message.content.some((part) => part.type === 'reasoning')) || null
130
+ },
131
+ })
132
+ await command(thread.id, 'context-usage', 'Status:')
133
+ await waitForFooter({ discord, threadId: thread.id })
134
+ expect(stableDurations(await discord.thread(thread.id).text())).toMatchInlineSnapshot(`
135
+ "--- from: user (tommy)
136
+ Ponder this thinking-marker
137
+ --- from: assistant (TestBot)
138
+ -# *using deterministic-provider/deterministic-v2 ⋅ build*
139
+ Token usage not available for this session yet
140
+ **Status:** running for Ns, thinking for Ns
141
+ **Last activity:** Ns ago, thinking
142
+ thought it through
143
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
92
144
  `)
93
145
  })
94
146
 
95
147
  test('/compact summarizes the history without a footer', async () => {
96
148
  const thread = await newThread('Something to compact')
97
149
  await command(thread.id, 'compact', 'Compacting')
98
- await waitForBotMessageContaining({ discord: twin.discord, threadId: thread.id, text: 'context compacted' })
150
+ const sessionId = bot.store.getState().roots[thread.id]!
151
+ const client = await server.client()
152
+ await waitFor({
153
+ label: 'compaction message',
154
+ check: async () => (await client.message.list({ sessionID: sessionId, order: 'asc' })).data.some((message) => message.type === 'compaction') || null,
155
+ })
99
156
  expect(await twin.discord.thread(thread.id).text()).toMatchInlineSnapshot(`
100
157
  "--- from: user (tommy)
101
158
  Something to compact
102
159
  --- from: assistant (TestBot)
103
160
  -# *using deterministic-provider/deterministic-v2 ⋅ build*
104
161
  ok
105
- -# *project ⋅ main ⋅ Ns ⋅ deterministic-v2*
106
- Compacting the session context
107
- -# ⬦ context compacted"
162
+ -# *main ⋅ Ns ⋅ deterministic-v2*
163
+ Compacting the session context"
108
164
  `)
109
- const sessionId = bot.store.getState().roots[thread.id]!
110
- const messages = await (await server.client()).message.list({ sessionID: sessionId, order: 'asc' })
111
- expect(messages.data.map((message) => message.type)).toContain('compaction')
112
165
  })
113
166
 
114
167
  test('/undo hides the last turn but keeps its files, /redo restores the turn', async () => {
@@ -133,7 +186,7 @@ test('/undo hides the last turn but keeps its files, /redo restores the turn', a
133
186
  -# ◼︎ write *undo.txt* (2 lines)
134
187
 
135
188
  wrote undo.txt
136
- -# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
189
+ -# *main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
137
190
  Undone - removed the last turn from the session. File changes were kept
138
191
  Restored - session fully back to its previous state
139
192
  Nothing to redo - no previous undo found"