@otto-assistant/bridge 0.4.92

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 (483) hide show
  1. package/bin.js +2 -0
  2. package/dist/agent-model.e2e.test.js +755 -0
  3. package/dist/ai-tool-to-genai.js +233 -0
  4. package/dist/ai-tool-to-genai.test.js +267 -0
  5. package/dist/ai-tool.js +6 -0
  6. package/dist/anthropic-auth-plugin.js +728 -0
  7. package/dist/anthropic-auth-plugin.test.js +125 -0
  8. package/dist/anthropic-auth-state.js +231 -0
  9. package/dist/bin.js +90 -0
  10. package/dist/channel-management.js +227 -0
  11. package/dist/cli-parsing.test.js +137 -0
  12. package/dist/cli-send-thread.e2e.test.js +356 -0
  13. package/dist/cli.js +3276 -0
  14. package/dist/commands/abort.js +65 -0
  15. package/dist/commands/action-buttons.js +245 -0
  16. package/dist/commands/add-project.js +113 -0
  17. package/dist/commands/agent.js +335 -0
  18. package/dist/commands/ask-question.js +274 -0
  19. package/dist/commands/btw.js +116 -0
  20. package/dist/commands/compact.js +120 -0
  21. package/dist/commands/context-usage.js +140 -0
  22. package/dist/commands/create-new-project.js +130 -0
  23. package/dist/commands/diff.js +63 -0
  24. package/dist/commands/file-upload.js +275 -0
  25. package/dist/commands/fork.js +220 -0
  26. package/dist/commands/gemini-apikey.js +70 -0
  27. package/dist/commands/login.js +885 -0
  28. package/dist/commands/mcp.js +239 -0
  29. package/dist/commands/memory-snapshot.js +24 -0
  30. package/dist/commands/mention-mode.js +44 -0
  31. package/dist/commands/merge-worktree.js +159 -0
  32. package/dist/commands/model-variant.js +364 -0
  33. package/dist/commands/model.js +776 -0
  34. package/dist/commands/new-worktree.js +366 -0
  35. package/dist/commands/paginated-select.js +57 -0
  36. package/dist/commands/permissions.js +274 -0
  37. package/dist/commands/queue.js +206 -0
  38. package/dist/commands/remove-project.js +115 -0
  39. package/dist/commands/restart-opencode-server.js +127 -0
  40. package/dist/commands/resume.js +149 -0
  41. package/dist/commands/run-command.js +79 -0
  42. package/dist/commands/screenshare.js +303 -0
  43. package/dist/commands/screenshare.test.js +20 -0
  44. package/dist/commands/session-id.js +78 -0
  45. package/dist/commands/session.js +176 -0
  46. package/dist/commands/share.js +80 -0
  47. package/dist/commands/tasks.js +205 -0
  48. package/dist/commands/types.js +2 -0
  49. package/dist/commands/undo-redo.js +305 -0
  50. package/dist/commands/unset-model.js +138 -0
  51. package/dist/commands/upgrade.js +42 -0
  52. package/dist/commands/user-command.js +155 -0
  53. package/dist/commands/verbosity.js +125 -0
  54. package/dist/commands/worktree-settings.js +43 -0
  55. package/dist/commands/worktrees.js +410 -0
  56. package/dist/condense-memory.js +33 -0
  57. package/dist/config.js +94 -0
  58. package/dist/context-awareness-plugin.js +363 -0
  59. package/dist/context-awareness-plugin.test.js +124 -0
  60. package/dist/critique-utils.js +95 -0
  61. package/dist/database.js +1310 -0
  62. package/dist/db.js +251 -0
  63. package/dist/db.test.js +138 -0
  64. package/dist/debounce-timeout.js +28 -0
  65. package/dist/debounced-process-flush.js +77 -0
  66. package/dist/discord-bot.js +1008 -0
  67. package/dist/discord-command-registration.js +524 -0
  68. package/dist/discord-urls.js +81 -0
  69. package/dist/discord-utils.js +591 -0
  70. package/dist/discord-utils.test.js +134 -0
  71. package/dist/errors.js +157 -0
  72. package/dist/escape-backticks.test.js +429 -0
  73. package/dist/event-stream-real-capture.e2e.test.js +533 -0
  74. package/dist/eventsource-parser.test.js +327 -0
  75. package/dist/exec-async.js +26 -0
  76. package/dist/external-opencode-sync.js +480 -0
  77. package/dist/format-tables.js +302 -0
  78. package/dist/format-tables.test.js +308 -0
  79. package/dist/forum-sync/config.js +79 -0
  80. package/dist/forum-sync/discord-operations.js +154 -0
  81. package/dist/forum-sync/index.js +5 -0
  82. package/dist/forum-sync/markdown.js +113 -0
  83. package/dist/forum-sync/sync-to-discord.js +417 -0
  84. package/dist/forum-sync/sync-to-files.js +190 -0
  85. package/dist/forum-sync/types.js +53 -0
  86. package/dist/forum-sync/watchers.js +307 -0
  87. package/dist/gateway-proxy-reconnect.e2e.test.js +394 -0
  88. package/dist/gateway-proxy.e2e.test.js +483 -0
  89. package/dist/genai-worker-wrapper.js +111 -0
  90. package/dist/genai-worker.js +311 -0
  91. package/dist/genai.js +232 -0
  92. package/dist/generated/browser.js +17 -0
  93. package/dist/generated/client.js +37 -0
  94. package/dist/generated/commonInputTypes.js +10 -0
  95. package/dist/generated/enums.js +52 -0
  96. package/dist/generated/internal/class.js +49 -0
  97. package/dist/generated/internal/prismaNamespace.js +253 -0
  98. package/dist/generated/internal/prismaNamespaceBrowser.js +223 -0
  99. package/dist/generated/models/bot_api_keys.js +1 -0
  100. package/dist/generated/models/bot_tokens.js +1 -0
  101. package/dist/generated/models/channel_agents.js +1 -0
  102. package/dist/generated/models/channel_directories.js +1 -0
  103. package/dist/generated/models/channel_mention_mode.js +1 -0
  104. package/dist/generated/models/channel_models.js +1 -0
  105. package/dist/generated/models/channel_verbosity.js +1 -0
  106. package/dist/generated/models/channel_worktrees.js +1 -0
  107. package/dist/generated/models/forum_sync_configs.js +1 -0
  108. package/dist/generated/models/global_models.js +1 -0
  109. package/dist/generated/models/ipc_requests.js +1 -0
  110. package/dist/generated/models/part_messages.js +1 -0
  111. package/dist/generated/models/scheduled_tasks.js +1 -0
  112. package/dist/generated/models/session_agents.js +1 -0
  113. package/dist/generated/models/session_events.js +1 -0
  114. package/dist/generated/models/session_models.js +1 -0
  115. package/dist/generated/models/session_start_sources.js +1 -0
  116. package/dist/generated/models/thread_sessions.js +1 -0
  117. package/dist/generated/models/thread_worktrees.js +1 -0
  118. package/dist/generated/models.js +1 -0
  119. package/dist/heap-monitor.js +122 -0
  120. package/dist/hrana-server.js +263 -0
  121. package/dist/hrana-server.test.js +370 -0
  122. package/dist/html-actions.js +123 -0
  123. package/dist/html-actions.test.js +70 -0
  124. package/dist/html-components.js +117 -0
  125. package/dist/html-components.test.js +34 -0
  126. package/dist/image-optimizer-plugin.js +153 -0
  127. package/dist/image-utils.js +112 -0
  128. package/dist/interaction-handler.js +397 -0
  129. package/dist/ipc-polling.js +252 -0
  130. package/dist/ipc-tools-plugin.js +193 -0
  131. package/dist/kimaki-digital-twin.e2e.test.js +161 -0
  132. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +87 -0
  133. package/dist/kimaki-opencode-plugin.js +17 -0
  134. package/dist/kimaki-opencode-plugin.test.js +98 -0
  135. package/dist/limit-heading-depth.js +25 -0
  136. package/dist/limit-heading-depth.test.js +105 -0
  137. package/dist/logger.js +165 -0
  138. package/dist/markdown.js +342 -0
  139. package/dist/markdown.test.js +257 -0
  140. package/dist/message-finish-field.e2e.test.js +165 -0
  141. package/dist/message-formatting.js +413 -0
  142. package/dist/message-formatting.test.js +73 -0
  143. package/dist/message-preprocessing.js +330 -0
  144. package/dist/onboarding-tutorial.js +172 -0
  145. package/dist/onboarding-welcome.js +37 -0
  146. package/dist/openai-realtime.js +224 -0
  147. package/dist/opencode-command-detection.js +65 -0
  148. package/dist/opencode-command-detection.test.js +240 -0
  149. package/dist/opencode-command.js +129 -0
  150. package/dist/opencode-command.test.js +48 -0
  151. package/dist/opencode-interrupt-plugin.js +361 -0
  152. package/dist/opencode-interrupt-plugin.test.js +458 -0
  153. package/dist/opencode.js +861 -0
  154. package/dist/otto/branding.js +22 -0
  155. package/dist/otto/index.js +21 -0
  156. package/dist/parse-permission-rules.test.js +117 -0
  157. package/dist/patch-text-parser.js +97 -0
  158. package/dist/plugin-logger.js +59 -0
  159. package/dist/privacy-sanitizer.js +105 -0
  160. package/dist/queue-advanced-abort.e2e.test.js +293 -0
  161. package/dist/queue-advanced-action-buttons.e2e.test.js +206 -0
  162. package/dist/queue-advanced-e2e-setup.js +786 -0
  163. package/dist/queue-advanced-footer.e2e.test.js +472 -0
  164. package/dist/queue-advanced-model-switch.e2e.test.js +299 -0
  165. package/dist/queue-advanced-permissions-typing.e2e.test.js +180 -0
  166. package/dist/queue-advanced-question.e2e.test.js +261 -0
  167. package/dist/queue-advanced-typing-interrupt.e2e.test.js +114 -0
  168. package/dist/queue-advanced-typing.e2e.test.js +153 -0
  169. package/dist/queue-drain-after-interactive-ui.e2e.test.js +119 -0
  170. package/dist/queue-interrupt-drain.e2e.test.js +135 -0
  171. package/dist/queue-question-select-drain.e2e.test.js +120 -0
  172. package/dist/runtime-idle-sweeper.js +52 -0
  173. package/dist/runtime-lifecycle.e2e.test.js +508 -0
  174. package/dist/sentry.js +23 -0
  175. package/dist/session-handler/agent-utils.js +67 -0
  176. package/dist/session-handler/event-stream-state.js +420 -0
  177. package/dist/session-handler/event-stream-state.test.js +563 -0
  178. package/dist/session-handler/model-utils.js +124 -0
  179. package/dist/session-handler/opencode-session-event-log.js +94 -0
  180. package/dist/session-handler/thread-runtime-state.js +104 -0
  181. package/dist/session-handler/thread-session-runtime.js +3258 -0
  182. package/dist/session-handler.js +9 -0
  183. package/dist/session-search.js +100 -0
  184. package/dist/session-search.test.js +40 -0
  185. package/dist/session-title-rename.test.js +80 -0
  186. package/dist/startup-service.js +153 -0
  187. package/dist/startup-time.e2e.test.js +296 -0
  188. package/dist/store.js +17 -0
  189. package/dist/system-message.js +613 -0
  190. package/dist/system-message.test.js +602 -0
  191. package/dist/task-runner.js +295 -0
  192. package/dist/task-schedule.js +209 -0
  193. package/dist/task-schedule.test.js +71 -0
  194. package/dist/test-utils.js +299 -0
  195. package/dist/thinking-utils.js +35 -0
  196. package/dist/thread-message-queue.e2e.test.js +999 -0
  197. package/dist/tools.js +357 -0
  198. package/dist/undo-redo.e2e.test.js +161 -0
  199. package/dist/unnest-code-blocks.js +146 -0
  200. package/dist/unnest-code-blocks.test.js +673 -0
  201. package/dist/upgrade.js +114 -0
  202. package/dist/utils.js +144 -0
  203. package/dist/voice-attachment.js +34 -0
  204. package/dist/voice-handler.js +646 -0
  205. package/dist/voice-message.e2e.test.js +1021 -0
  206. package/dist/voice.js +447 -0
  207. package/dist/voice.test.js +235 -0
  208. package/dist/wait-session.js +94 -0
  209. package/dist/websockify.js +69 -0
  210. package/dist/worker-types.js +4 -0
  211. package/dist/worktree-lifecycle.e2e.test.js +308 -0
  212. package/dist/worktree-utils.js +3 -0
  213. package/dist/worktrees.js +929 -0
  214. package/dist/worktrees.test.js +189 -0
  215. package/dist/xml.js +92 -0
  216. package/dist/xml.test.js +32 -0
  217. package/package.json +98 -0
  218. package/schema.prisma +295 -0
  219. package/skills/batch/SKILL.md +87 -0
  220. package/skills/critique/SKILL.md +112 -0
  221. package/skills/egaki/SKILL.md +100 -0
  222. package/skills/errore/SKILL.md +647 -0
  223. package/skills/event-sourcing-state/SKILL.md +252 -0
  224. package/skills/gitchamber/SKILL.md +93 -0
  225. package/skills/goke/SKILL.md +644 -0
  226. package/skills/jitter/EDITOR.md +219 -0
  227. package/skills/jitter/EXPORT-INTERNALS.md +309 -0
  228. package/skills/jitter/SKILL.md +158 -0
  229. package/skills/jitter/jitter-clipboard.json +1042 -0
  230. package/skills/jitter/package.json +14 -0
  231. package/skills/jitter/tsconfig.json +15 -0
  232. package/skills/jitter/utils/actions.ts +212 -0
  233. package/skills/jitter/utils/export.ts +114 -0
  234. package/skills/jitter/utils/index.ts +141 -0
  235. package/skills/jitter/utils/snapshot.ts +154 -0
  236. package/skills/jitter/utils/traverse.ts +246 -0
  237. package/skills/jitter/utils/types.ts +279 -0
  238. package/skills/jitter/utils/wait.ts +133 -0
  239. package/skills/lintcn/SKILL.md +873 -0
  240. package/skills/new-skill/SKILL.md +211 -0
  241. package/skills/npm-package/SKILL.md +239 -0
  242. package/skills/playwriter/SKILL.md +35 -0
  243. package/skills/proxyman/SKILL.md +215 -0
  244. package/skills/security-review/SKILL.md +208 -0
  245. package/skills/simplify/SKILL.md +58 -0
  246. package/skills/spiceflow/SKILL.md +14 -0
  247. package/skills/termcast/SKILL.md +945 -0
  248. package/skills/tuistory/SKILL.md +250 -0
  249. package/skills/usecomputer/SKILL.md +264 -0
  250. package/skills/x-articles/SKILL.md +554 -0
  251. package/skills/zele/SKILL.md +112 -0
  252. package/skills/zustand-centralized-state/SKILL.md +1004 -0
  253. package/src/agent-model.e2e.test.ts +976 -0
  254. package/src/ai-tool-to-genai.test.ts +296 -0
  255. package/src/ai-tool-to-genai.ts +283 -0
  256. package/src/ai-tool.ts +39 -0
  257. package/src/anthropic-auth-plugin.test.ts +159 -0
  258. package/src/anthropic-auth-plugin.ts +861 -0
  259. package/src/anthropic-auth-state.ts +282 -0
  260. package/src/bin.ts +111 -0
  261. package/src/channel-management.ts +334 -0
  262. package/src/cli-parsing.test.ts +195 -0
  263. package/src/cli-send-thread.e2e.test.ts +464 -0
  264. package/src/cli.ts +4581 -0
  265. package/src/commands/abort.ts +89 -0
  266. package/src/commands/action-buttons.ts +364 -0
  267. package/src/commands/add-project.ts +149 -0
  268. package/src/commands/agent.ts +473 -0
  269. package/src/commands/ask-question.ts +390 -0
  270. package/src/commands/btw.ts +164 -0
  271. package/src/commands/compact.ts +157 -0
  272. package/src/commands/context-usage.ts +199 -0
  273. package/src/commands/create-new-project.ts +190 -0
  274. package/src/commands/diff.ts +91 -0
  275. package/src/commands/file-upload.ts +389 -0
  276. package/src/commands/fork.ts +321 -0
  277. package/src/commands/gemini-apikey.ts +104 -0
  278. package/src/commands/login.ts +1173 -0
  279. package/src/commands/mcp.ts +307 -0
  280. package/src/commands/memory-snapshot.ts +30 -0
  281. package/src/commands/mention-mode.ts +68 -0
  282. package/src/commands/merge-worktree.ts +223 -0
  283. package/src/commands/model-variant.ts +483 -0
  284. package/src/commands/model.ts +1053 -0
  285. package/src/commands/new-worktree.ts +510 -0
  286. package/src/commands/paginated-select.ts +81 -0
  287. package/src/commands/permissions.ts +397 -0
  288. package/src/commands/queue.ts +271 -0
  289. package/src/commands/remove-project.ts +155 -0
  290. package/src/commands/restart-opencode-server.ts +162 -0
  291. package/src/commands/resume.ts +230 -0
  292. package/src/commands/run-command.ts +123 -0
  293. package/src/commands/screenshare.test.ts +30 -0
  294. package/src/commands/screenshare.ts +366 -0
  295. package/src/commands/session-id.ts +109 -0
  296. package/src/commands/session.ts +227 -0
  297. package/src/commands/share.ts +106 -0
  298. package/src/commands/tasks.ts +293 -0
  299. package/src/commands/types.ts +25 -0
  300. package/src/commands/undo-redo.ts +386 -0
  301. package/src/commands/unset-model.ts +173 -0
  302. package/src/commands/upgrade.ts +52 -0
  303. package/src/commands/user-command.ts +198 -0
  304. package/src/commands/verbosity.ts +173 -0
  305. package/src/commands/worktree-settings.ts +70 -0
  306. package/src/commands/worktrees.ts +552 -0
  307. package/src/condense-memory.ts +36 -0
  308. package/src/config.ts +111 -0
  309. package/src/context-awareness-plugin.test.ts +142 -0
  310. package/src/context-awareness-plugin.ts +510 -0
  311. package/src/critique-utils.ts +139 -0
  312. package/src/database.ts +1876 -0
  313. package/src/db.test.ts +162 -0
  314. package/src/db.ts +286 -0
  315. package/src/debounce-timeout.ts +43 -0
  316. package/src/debounced-process-flush.ts +104 -0
  317. package/src/discord-bot.ts +1330 -0
  318. package/src/discord-command-registration.ts +693 -0
  319. package/src/discord-urls.ts +88 -0
  320. package/src/discord-utils.test.ts +153 -0
  321. package/src/discord-utils.ts +800 -0
  322. package/src/errors.ts +201 -0
  323. package/src/escape-backticks.test.ts +469 -0
  324. package/src/event-stream-real-capture.e2e.test.ts +692 -0
  325. package/src/eventsource-parser.test.ts +351 -0
  326. package/src/exec-async.ts +35 -0
  327. package/src/external-opencode-sync.ts +685 -0
  328. package/src/format-tables.test.ts +335 -0
  329. package/src/format-tables.ts +445 -0
  330. package/src/forum-sync/config.ts +92 -0
  331. package/src/forum-sync/discord-operations.ts +241 -0
  332. package/src/forum-sync/index.ts +9 -0
  333. package/src/forum-sync/markdown.ts +172 -0
  334. package/src/forum-sync/sync-to-discord.ts +595 -0
  335. package/src/forum-sync/sync-to-files.ts +294 -0
  336. package/src/forum-sync/types.ts +175 -0
  337. package/src/forum-sync/watchers.ts +454 -0
  338. package/src/gateway-proxy-reconnect.e2e.test.ts +523 -0
  339. package/src/gateway-proxy.e2e.test.ts +640 -0
  340. package/src/genai-worker-wrapper.ts +164 -0
  341. package/src/genai-worker.ts +386 -0
  342. package/src/genai.ts +321 -0
  343. package/src/generated/browser.ts +114 -0
  344. package/src/generated/client.ts +138 -0
  345. package/src/generated/commonInputTypes.ts +736 -0
  346. package/src/generated/enums.ts +88 -0
  347. package/src/generated/internal/class.ts +384 -0
  348. package/src/generated/internal/prismaNamespace.ts +2386 -0
  349. package/src/generated/internal/prismaNamespaceBrowser.ts +326 -0
  350. package/src/generated/models/bot_api_keys.ts +1288 -0
  351. package/src/generated/models/bot_tokens.ts +1656 -0
  352. package/src/generated/models/channel_agents.ts +1256 -0
  353. package/src/generated/models/channel_directories.ts +1859 -0
  354. package/src/generated/models/channel_mention_mode.ts +1300 -0
  355. package/src/generated/models/channel_models.ts +1288 -0
  356. package/src/generated/models/channel_verbosity.ts +1228 -0
  357. package/src/generated/models/channel_worktrees.ts +1300 -0
  358. package/src/generated/models/forum_sync_configs.ts +1452 -0
  359. package/src/generated/models/global_models.ts +1288 -0
  360. package/src/generated/models/ipc_requests.ts +1485 -0
  361. package/src/generated/models/part_messages.ts +1302 -0
  362. package/src/generated/models/scheduled_tasks.ts +2320 -0
  363. package/src/generated/models/session_agents.ts +1086 -0
  364. package/src/generated/models/session_events.ts +1439 -0
  365. package/src/generated/models/session_models.ts +1114 -0
  366. package/src/generated/models/session_start_sources.ts +1408 -0
  367. package/src/generated/models/thread_sessions.ts +1781 -0
  368. package/src/generated/models/thread_worktrees.ts +1356 -0
  369. package/src/generated/models.ts +30 -0
  370. package/src/heap-monitor.ts +152 -0
  371. package/src/hrana-server.test.ts +434 -0
  372. package/src/hrana-server.ts +314 -0
  373. package/src/html-actions.test.ts +87 -0
  374. package/src/html-actions.ts +174 -0
  375. package/src/html-components.test.ts +38 -0
  376. package/src/html-components.ts +181 -0
  377. package/src/image-optimizer-plugin.ts +194 -0
  378. package/src/image-utils.ts +149 -0
  379. package/src/interaction-handler.ts +576 -0
  380. package/src/ipc-polling.ts +326 -0
  381. package/src/ipc-tools-plugin.ts +236 -0
  382. package/src/kimaki-digital-twin.e2e.test.ts +199 -0
  383. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +109 -0
  384. package/src/kimaki-opencode-plugin.test.ts +108 -0
  385. package/src/kimaki-opencode-plugin.ts +18 -0
  386. package/src/limit-heading-depth.test.ts +116 -0
  387. package/src/limit-heading-depth.ts +26 -0
  388. package/src/logger.ts +208 -0
  389. package/src/markdown.test.ts +308 -0
  390. package/src/markdown.ts +410 -0
  391. package/src/message-finish-field.e2e.test.ts +192 -0
  392. package/src/message-formatting.test.ts +81 -0
  393. package/src/message-formatting.ts +533 -0
  394. package/src/message-preprocessing.ts +455 -0
  395. package/src/onboarding-tutorial.ts +176 -0
  396. package/src/onboarding-welcome.ts +49 -0
  397. package/src/openai-realtime.ts +358 -0
  398. package/src/opencode-command-detection.test.ts +307 -0
  399. package/src/opencode-command-detection.ts +76 -0
  400. package/src/opencode-command.test.ts +70 -0
  401. package/src/opencode-command.ts +188 -0
  402. package/src/opencode-interrupt-plugin.test.ts +677 -0
  403. package/src/opencode-interrupt-plugin.ts +477 -0
  404. package/src/opencode.ts +1110 -0
  405. package/src/otto/branding.ts +23 -0
  406. package/src/otto/index.ts +22 -0
  407. package/src/parse-permission-rules.test.ts +127 -0
  408. package/src/patch-text-parser.ts +107 -0
  409. package/src/plugin-logger.ts +68 -0
  410. package/src/privacy-sanitizer.ts +142 -0
  411. package/src/queue-advanced-abort.e2e.test.ts +382 -0
  412. package/src/queue-advanced-action-buttons.e2e.test.ts +268 -0
  413. package/src/queue-advanced-e2e-setup.ts +873 -0
  414. package/src/queue-advanced-footer.e2e.test.ts +576 -0
  415. package/src/queue-advanced-model-switch.e2e.test.ts +383 -0
  416. package/src/queue-advanced-permissions-typing.e2e.test.ts +245 -0
  417. package/src/queue-advanced-question.e2e.test.ts +316 -0
  418. package/src/queue-advanced-typing-interrupt.e2e.test.ts +146 -0
  419. package/src/queue-advanced-typing.e2e.test.ts +199 -0
  420. package/src/queue-drain-after-interactive-ui.e2e.test.ts +151 -0
  421. package/src/queue-interrupt-drain.e2e.test.ts +166 -0
  422. package/src/queue-question-select-drain.e2e.test.ts +152 -0
  423. package/src/runtime-idle-sweeper.ts +76 -0
  424. package/src/runtime-lifecycle.e2e.test.ts +641 -0
  425. package/src/schema.sql +173 -0
  426. package/src/sentry.ts +26 -0
  427. package/src/session-handler/agent-utils.ts +97 -0
  428. package/src/session-handler/event-stream-fixtures/real-session-action-buttons.jsonl +45 -0
  429. package/src/session-handler/event-stream-fixtures/real-session-footer-suppressed-on-pre-idle-interrupt.jsonl +40 -0
  430. package/src/session-handler/event-stream-fixtures/real-session-permission-external-file.jsonl +23 -0
  431. package/src/session-handler/event-stream-fixtures/real-session-task-normal.jsonl +22 -0
  432. package/src/session-handler/event-stream-fixtures/real-session-task-three-parallel-sleeps.jsonl +277 -0
  433. package/src/session-handler/event-stream-fixtures/real-session-task-user-interruption.jsonl +46 -0
  434. package/src/session-handler/event-stream-fixtures/session-abort-after-idle-race.jsonl +21 -0
  435. package/src/session-handler/event-stream-fixtures/session-concurrent-messages-serialized.jsonl +56 -0
  436. package/src/session-handler/event-stream-fixtures/session-explicit-abort.jsonl +44 -0
  437. package/src/session-handler/event-stream-fixtures/session-normal-completion.jsonl +29 -0
  438. package/src/session-handler/event-stream-fixtures/session-tool-call-noisy-stream.jsonl +29 -0
  439. package/src/session-handler/event-stream-fixtures/session-two-completions-same-session.jsonl +50 -0
  440. package/src/session-handler/event-stream-fixtures/session-user-interruption.jsonl +59 -0
  441. package/src/session-handler/event-stream-fixtures/session-voice-queued-followup.jsonl +52 -0
  442. package/src/session-handler/event-stream-state.test.ts +645 -0
  443. package/src/session-handler/event-stream-state.ts +608 -0
  444. package/src/session-handler/model-utils.ts +183 -0
  445. package/src/session-handler/opencode-session-event-log.ts +130 -0
  446. package/src/session-handler/thread-runtime-state.ts +212 -0
  447. package/src/session-handler/thread-session-runtime.ts +4281 -0
  448. package/src/session-handler.ts +15 -0
  449. package/src/session-search.test.ts +50 -0
  450. package/src/session-search.ts +148 -0
  451. package/src/session-title-rename.test.ts +112 -0
  452. package/src/startup-service.ts +200 -0
  453. package/src/startup-time.e2e.test.ts +373 -0
  454. package/src/store.ts +122 -0
  455. package/src/system-message.test.ts +612 -0
  456. package/src/system-message.ts +723 -0
  457. package/src/task-runner.ts +421 -0
  458. package/src/task-schedule.test.ts +84 -0
  459. package/src/task-schedule.ts +311 -0
  460. package/src/test-utils.ts +435 -0
  461. package/src/thinking-utils.ts +61 -0
  462. package/src/thread-message-queue.e2e.test.ts +1219 -0
  463. package/src/tools.ts +430 -0
  464. package/src/undici.d.ts +12 -0
  465. package/src/undo-redo.e2e.test.ts +209 -0
  466. package/src/unnest-code-blocks.test.ts +713 -0
  467. package/src/unnest-code-blocks.ts +185 -0
  468. package/src/upgrade.ts +127 -0
  469. package/src/utils.ts +212 -0
  470. package/src/voice-attachment.ts +51 -0
  471. package/src/voice-handler.ts +908 -0
  472. package/src/voice-message.e2e.test.ts +1255 -0
  473. package/src/voice.test.ts +281 -0
  474. package/src/voice.ts +627 -0
  475. package/src/wait-session.ts +147 -0
  476. package/src/websockify.ts +101 -0
  477. package/src/worker-types.ts +64 -0
  478. package/src/worktree-lifecycle.e2e.test.ts +391 -0
  479. package/src/worktree-utils.ts +4 -0
  480. package/src/worktrees.test.ts +223 -0
  481. package/src/worktrees.ts +1294 -0
  482. package/src/xml.test.ts +38 -0
  483. package/src/xml.ts +121 -0
@@ -0,0 +1,1219 @@
1
+ // E2e tests for basic per-thread message queue ordering.
2
+ // Advanced interrupt/abort/retry tests are in thread-queue-advanced.e2e.test.ts.
3
+ //
4
+ // Uses opencode-deterministic-provider which returns canned responses instantly
5
+ // (no real LLM calls), so poll timeouts can be aggressive (4s). The only real
6
+ // latency is OpenCode server startup (beforeAll) and intentional partDelaysMs
7
+ // in matchers (100ms for user-reply).
8
+ //
9
+ // If total duration of a file exceeds ~10s, split into a new test file
10
+ // so vitest can parallelize across files.
11
+
12
+ import fs from 'node:fs'
13
+ import path from 'node:path'
14
+ import url from 'node:url'
15
+ import { describe, beforeAll, afterAll, test, expect } from 'vitest'
16
+ import { ChannelType, Client, GatewayIntentBits, Partials } from 'discord.js'
17
+ import { DigitalDiscord } from 'discord-digital-twin/src'
18
+ import {
19
+ buildDeterministicOpencodeConfig,
20
+ type DeterministicMatcher,
21
+ } from 'opencode-deterministic-provider'
22
+ import {
23
+ setDataDir,
24
+ } from './config.js'
25
+ import { store } from './store.js'
26
+ import { startDiscordBot } from './discord-bot.js'
27
+ import {
28
+ setBotToken,
29
+ initDatabase,
30
+ closeDatabase,
31
+ setChannelDirectory,
32
+ setChannelVerbosity,
33
+ getChannelVerbosity,
34
+ type VerbosityLevel,
35
+ } from './database.js'
36
+ import { startHranaServer, stopHranaServer } from './hrana-server.js'
37
+ import { initializeOpencodeForDirectory, stopOpencodeServer } from './opencode.js'
38
+ import {
39
+ chooseLockPort,
40
+ cleanupTestSessions,
41
+ initTestGitRepo,
42
+ waitForFooterMessage,
43
+ waitForBotMessageContaining,
44
+ waitForMessageById,
45
+ waitForBotMessageCount,
46
+ waitForBotReplyAfterUserMessage,
47
+ waitForThreadState,
48
+ } from './test-utils.js'
49
+
50
+
51
+ const e2eTest = describe
52
+
53
+ function createRunDirectories() {
54
+ const root = path.resolve(process.cwd(), 'tmp', 'thread-queue-e2e')
55
+ fs.mkdirSync(root, { recursive: true })
56
+
57
+ const dataDir = fs.mkdtempSync(path.join(root, 'data-'))
58
+ const projectDirectory = path.join(root, 'project')
59
+ fs.mkdirSync(projectDirectory, { recursive: true })
60
+ initTestGitRepo(projectDirectory)
61
+
62
+ return { root, dataDir, projectDirectory }
63
+ }
64
+
65
+ function createDiscordJsClient({ restUrl }: { restUrl: string }) {
66
+ return new Client({
67
+ intents: [
68
+ GatewayIntentBits.Guilds,
69
+ GatewayIntentBits.GuildMessages,
70
+ GatewayIntentBits.MessageContent,
71
+ GatewayIntentBits.GuildVoiceStates,
72
+ ],
73
+ partials: [
74
+ Partials.Channel,
75
+ Partials.Message,
76
+ Partials.User,
77
+ Partials.ThreadMember,
78
+ ],
79
+ rest: {
80
+ api: restUrl,
81
+ version: '10',
82
+ },
83
+ })
84
+ }
85
+
86
+ function createDeterministicMatchers() {
87
+ const bashCreateFileMatcher: DeterministicMatcher = {
88
+ id: 'bash-create-file',
89
+ priority: 130,
90
+ when: {
91
+ lastMessageRole: 'user',
92
+ rawPromptIncludes: 'BASH_TOOL_FILE_MARKER',
93
+ },
94
+ then: {
95
+ parts: [
96
+ { type: 'stream-start', warnings: [] },
97
+ { type: 'text-start', id: 'bash-create-file' },
98
+ {
99
+ type: 'text-delta',
100
+ id: 'bash-create-file',
101
+ delta: 'running create file',
102
+ },
103
+ { type: 'text-end', id: 'bash-create-file' },
104
+ {
105
+ type: 'tool-call',
106
+ toolCallId: 'bash-create-file-call',
107
+ toolName: 'bash',
108
+ input: JSON.stringify({
109
+ command: 'mkdir -p tmp && printf "created" > tmp/bash-tool-executed.txt',
110
+ description: 'Create marker file for e2e test',
111
+ hasSideEffect: true,
112
+ }),
113
+ },
114
+ {
115
+ type: 'finish',
116
+ finishReason: 'tool-calls',
117
+ usage: {
118
+ inputTokens: 1,
119
+ outputTokens: 1,
120
+ totalTokens: 2,
121
+ },
122
+ },
123
+ ],
124
+ },
125
+ }
126
+
127
+ const bashCreateFileFollowupMatcher: DeterministicMatcher = {
128
+ id: 'bash-create-file-followup',
129
+ priority: 120,
130
+ when: {
131
+ lastMessageRole: 'tool',
132
+ rawPromptIncludes: 'BASH_TOOL_FILE_MARKER',
133
+ },
134
+ then: {
135
+ parts: [
136
+ { type: 'stream-start', warnings: [] },
137
+ { type: 'text-start', id: 'bash-create-file-followup' },
138
+ {
139
+ type: 'text-delta',
140
+ id: 'bash-create-file-followup',
141
+ delta: 'file created',
142
+ },
143
+ { type: 'text-end', id: 'bash-create-file-followup' },
144
+ {
145
+ type: 'finish',
146
+ finishReason: 'stop',
147
+ usage: {
148
+ inputTokens: 1,
149
+ outputTokens: 1,
150
+ totalTokens: 2,
151
+ },
152
+ },
153
+ ],
154
+ },
155
+ }
156
+
157
+ const raceFinalReplyMatcher: DeterministicMatcher = {
158
+ id: 'race-final-reply',
159
+ priority: 110,
160
+ when: {
161
+ latestUserTextIncludes: 'Reply with exactly: race-final',
162
+ },
163
+ then: {
164
+ parts: [
165
+ { type: 'stream-start', warnings: [] },
166
+ { type: 'text-start', id: 'race-final' },
167
+ { type: 'text-delta', id: 'race-final', delta: 'race-final' },
168
+ { type: 'text-end', id: 'race-final' },
169
+ {
170
+ type: 'finish',
171
+ finishReason: 'stop',
172
+ usage: {
173
+ inputTokens: 1,
174
+ outputTokens: 1,
175
+ totalTokens: 2,
176
+ },
177
+ },
178
+ ],
179
+ // Delay first output to widen the stale-idle window. The race happens
180
+ // in <1ms; 500ms is plenty to keep the window reliably open.
181
+ partDelaysMs: [0, 500, 0, 0, 0],
182
+ },
183
+ }
184
+
185
+ // Slow matcher for "hotel" so the 200ms sleep in the queueing test
186
+ // guarantees "india" arrives while hotel is still streaming.
187
+ const hotelSlowMatcher: DeterministicMatcher = {
188
+ id: 'hotel-slow-reply',
189
+ priority: 20,
190
+ when: {
191
+ latestUserTextIncludes: 'Reply with exactly: hotel',
192
+ },
193
+ then: {
194
+ parts: [
195
+ { type: 'stream-start', warnings: [] },
196
+ { type: 'text-start', id: 'hotel-reply' },
197
+ { type: 'text-delta', id: 'hotel-reply', delta: 'ok' },
198
+ { type: 'text-end', id: 'hotel-reply' },
199
+ {
200
+ type: 'finish',
201
+ finishReason: 'stop',
202
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
203
+ },
204
+ ],
205
+ partDelaysMs: [0, 100, 300, 0, 0],
206
+ },
207
+ }
208
+
209
+ const userReplyMatcher: DeterministicMatcher = {
210
+ id: 'user-reply',
211
+ priority: 10,
212
+ when: {
213
+ lastMessageRole: 'user',
214
+ rawPromptIncludes: 'Reply with exactly:',
215
+ },
216
+ then: {
217
+ parts: [
218
+ { type: 'stream-start', warnings: [] },
219
+ { type: 'text-start', id: 'default-reply' },
220
+ { type: 'text-delta', id: 'default-reply', delta: 'ok' },
221
+ { type: 'text-end', id: 'default-reply' },
222
+ {
223
+ type: 'finish',
224
+ finishReason: 'stop',
225
+ usage: {
226
+ inputTokens: 1,
227
+ outputTokens: 1,
228
+ totalTokens: 2,
229
+ },
230
+ },
231
+ ],
232
+ partDelaysMs: [0, 100, 0, 0, 0],
233
+ },
234
+ }
235
+
236
+ return [
237
+ bashCreateFileMatcher,
238
+ bashCreateFileFollowupMatcher,
239
+ raceFinalReplyMatcher,
240
+ hotelSlowMatcher,
241
+ userReplyMatcher,
242
+ ]
243
+ }
244
+
245
+ const TEST_USER_ID = '200000000000000777'
246
+ const TEXT_CHANNEL_ID = '200000000000000778'
247
+
248
+ e2eTest('thread message queue ordering', () => {
249
+ let directories: ReturnType<typeof createRunDirectories>
250
+ let discord: DigitalDiscord
251
+ let botClient: Client
252
+ let previousDefaultVerbosity: VerbosityLevel | null =
253
+ null
254
+ let testStartTime = Date.now()
255
+
256
+ beforeAll(async () => {
257
+ testStartTime = Date.now()
258
+ directories = createRunDirectories()
259
+ const lockPort = chooseLockPort({ key: TEXT_CHANNEL_ID })
260
+
261
+ process.env['KIMAKI_LOCK_PORT'] = String(lockPort)
262
+ setDataDir(directories.dataDir)
263
+ previousDefaultVerbosity = store.getState().defaultVerbosity
264
+ store.setState({ defaultVerbosity: 'tools_and_text' })
265
+
266
+ const digitalDiscordDbPath = path.join(
267
+ directories.dataDir,
268
+ 'digital-discord.db',
269
+ )
270
+
271
+ discord = new DigitalDiscord({
272
+ guild: {
273
+ name: 'Queue E2E Guild',
274
+ ownerId: TEST_USER_ID,
275
+ },
276
+ channels: [
277
+ {
278
+ id: TEXT_CHANNEL_ID,
279
+ name: 'queue-e2e',
280
+ type: ChannelType.GuildText,
281
+ },
282
+ ],
283
+ users: [
284
+ {
285
+ id: TEST_USER_ID,
286
+ username: 'queue-tester',
287
+ },
288
+ ],
289
+ dbUrl: `file:${digitalDiscordDbPath}`,
290
+ })
291
+
292
+ await discord.start()
293
+
294
+ const providerNpm = url
295
+ .pathToFileURL(
296
+ path.resolve(
297
+ process.cwd(),
298
+ '..',
299
+ 'opencode-deterministic-provider',
300
+ 'src',
301
+ 'index.ts',
302
+ ),
303
+ )
304
+ .toString()
305
+
306
+ const opencodeConfig = buildDeterministicOpencodeConfig({
307
+ providerName: 'deterministic-provider',
308
+ providerNpm,
309
+ model: 'deterministic-v2',
310
+ smallModel: 'deterministic-v2',
311
+ settings: {
312
+ strict: false,
313
+ matchers: createDeterministicMatchers(),
314
+ },
315
+ })
316
+ fs.writeFileSync(
317
+ path.join(directories.projectDirectory, 'opencode.json'),
318
+ JSON.stringify(opencodeConfig, null, 2),
319
+ )
320
+
321
+ const dbPath = path.join(directories.dataDir, 'discord-sessions.db')
322
+ const hranaResult = await startHranaServer({ dbPath })
323
+ if (hranaResult instanceof Error) {
324
+ throw hranaResult
325
+ }
326
+ process.env['KIMAKI_DB_URL'] = hranaResult
327
+ await initDatabase()
328
+ await setBotToken(discord.botUserId, discord.botToken)
329
+
330
+ await setChannelDirectory({
331
+ channelId: TEXT_CHANNEL_ID,
332
+ directory: directories.projectDirectory,
333
+ channelType: 'text',
334
+ })
335
+ await setChannelVerbosity(TEXT_CHANNEL_ID, 'tools_and_text')
336
+ const channelVerbosity = await getChannelVerbosity(TEXT_CHANNEL_ID)
337
+ expect(channelVerbosity).toBe('tools_and_text')
338
+
339
+ botClient = createDiscordJsClient({ restUrl: discord.restUrl })
340
+ await startDiscordBot({
341
+ token: discord.botToken,
342
+ appId: discord.botUserId,
343
+ discordClient: botClient,
344
+ })
345
+
346
+ // Pre-warm the opencode server so the first test doesn't include
347
+ // server startup time (~3-4s) inside its 4s poll timeouts.
348
+ const warmup = await initializeOpencodeForDirectory(
349
+ directories.projectDirectory,
350
+ )
351
+ if (warmup instanceof Error) {
352
+ throw warmup
353
+ }
354
+ }, 60_000)
355
+
356
+ afterAll(async () => {
357
+ if (directories) {
358
+ await cleanupTestSessions({
359
+ projectDirectory: directories.projectDirectory,
360
+ testStartTime,
361
+ })
362
+ }
363
+
364
+ if (botClient) {
365
+ botClient.destroy()
366
+ }
367
+
368
+ await stopOpencodeServer()
369
+ await Promise.all([
370
+ closeDatabase().catch(() => {
371
+ return
372
+ }),
373
+ stopHranaServer().catch(() => {
374
+ return
375
+ }),
376
+ discord?.stop().catch(() => {
377
+ return
378
+ }),
379
+ ])
380
+
381
+ delete process.env['KIMAKI_LOCK_PORT']
382
+ delete process.env['KIMAKI_DB_URL']
383
+ if (previousDefaultVerbosity) {
384
+ store.setState({ defaultVerbosity: previousDefaultVerbosity })
385
+ }
386
+ if (directories) {
387
+ fs.rmSync(directories.dataDir, { recursive: true, force: true })
388
+ }
389
+ }, 10_000)
390
+
391
+ test(
392
+ 'first prompt after cold opencode server start still streams text parts',
393
+ async () => {
394
+ // Reproduce cold-start path: clear in-memory server/client registry so
395
+ // runtime startEventListener() runs once before initialize and exits with
396
+ // "No OpenCode client". The first prompt must still show text parts.
397
+ await stopOpencodeServer()
398
+
399
+ const prompt = 'Reply with exactly: cold-start-stream'
400
+
401
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
402
+ content: prompt,
403
+ })
404
+
405
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
406
+ timeout: 4_000,
407
+ predicate: (t) => {
408
+ return t.name === prompt
409
+ },
410
+ })
411
+
412
+ await waitForBotMessageContaining({
413
+ discord,
414
+ threadId: thread.id,
415
+ userId: TEST_USER_ID,
416
+ text: '⬥ ok',
417
+ timeout: 10_000,
418
+ })
419
+
420
+ await waitForFooterMessage({
421
+ discord,
422
+ threadId: thread.id,
423
+ timeout: 4_000,
424
+ })
425
+
426
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
427
+ "--- from: user (queue-tester)
428
+ Reply with exactly: cold-start-stream
429
+ --- from: assistant (TestBot)
430
+ ⬥ ok
431
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
432
+ `)
433
+ },
434
+ 12_000,
435
+ )
436
+
437
+ test(
438
+ 'text message during active session gets processed',
439
+ async () => {
440
+ // 1. Send initial message to text channel → thread created + session established
441
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
442
+ content: 'Reply with exactly: alpha',
443
+ })
444
+
445
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
446
+ timeout: 4_000,
447
+ predicate: (t) => {
448
+ return t.name === 'Reply with exactly: alpha'
449
+ },
450
+ })
451
+
452
+ const th = discord.thread(thread.id)
453
+
454
+ // Wait for the first bot reply so session is fully established in DB
455
+ const firstReply = await th.waitForBotReply({
456
+ timeout: 4_000,
457
+ })
458
+ expect(firstReply.content.trim().length).toBeGreaterThan(0)
459
+
460
+ // Snapshot bot message count before sending follow-up
461
+ const before = await th.getMessages()
462
+ const beforeBotCount = before.filter((m) => {
463
+ return m.author.id === discord.botUserId
464
+ }).length
465
+
466
+ // 2. Send follow-up message B into the thread — serialized by runtime's enqueueIncoming
467
+ await th.user(TEST_USER_ID).sendMessage({
468
+ content: 'Reply with exactly: beta',
469
+ })
470
+
471
+ // 3. Wait for exactly 1 new bot message (the response to B)
472
+ const after = await waitForBotMessageCount({
473
+ discord,
474
+ threadId: thread.id,
475
+ count: beforeBotCount + 1,
476
+ timeout: 4_000,
477
+ })
478
+
479
+ // 4. Verify at least 1 new bot message appeared for the follow-up.
480
+ // The bot may send additional messages per session (error reactions,
481
+ // session notifications) so we check >= not exact equality.
482
+ const afterBotMessages = after.filter((m) => {
483
+ return m.author.id === discord.botUserId
484
+ })
485
+ expect(afterBotMessages.length).toBeGreaterThanOrEqual(beforeBotCount + 1)
486
+
487
+ await waitForFooterMessage({
488
+ discord,
489
+ threadId: thread.id,
490
+ timeout: 8_000,
491
+ afterMessageIncludes: 'beta',
492
+ afterAuthorId: TEST_USER_ID,
493
+ })
494
+
495
+ const timeline = await th.text()
496
+ expect(timeline).toContain('Reply with exactly: alpha')
497
+ expect(timeline).toContain('Reply with exactly: beta')
498
+ expect(timeline).toContain('⬥ ok')
499
+ expect(timeline).toContain('*project ⋅ main ⋅')
500
+ // User B's message must appear before the new bot response
501
+ const userBIndex = after.findIndex((m) => {
502
+ return (
503
+ m.author.id === TEST_USER_ID &&
504
+ m.content.includes('beta')
505
+ )
506
+ })
507
+ const lastBotIndex = after.findLastIndex((m) => {
508
+ return m.author.id === discord.botUserId
509
+ })
510
+
511
+ expect(userBIndex).toBeGreaterThan(-1)
512
+ expect(lastBotIndex).toBeGreaterThan(-1)
513
+ expect(userBIndex).toBeLessThan(lastBotIndex)
514
+
515
+ // New bot response has non-empty content
516
+ const newBotReply = afterBotMessages[afterBotMessages.length - 1]!
517
+ expect(newBotReply.content.trim().length).toBeGreaterThan(0)
518
+ },
519
+ 12_000,
520
+ )
521
+
522
+ test(
523
+ 'two rapid text messages in thread — both processed in order',
524
+ async () => {
525
+ // 1. Send initial message to text channel → thread + session established
526
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
527
+ content: 'Reply with exactly: one',
528
+ })
529
+
530
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
531
+ timeout: 4_000,
532
+ predicate: (t) => {
533
+ return t.name === 'Reply with exactly: one'
534
+ },
535
+ })
536
+
537
+ const th = discord.thread(thread.id)
538
+
539
+ // Wait for the first bot reply AND its footer so the first response
540
+ // cycle is fully complete before sending follow-ups. Without this,
541
+ // the footer for "one" can still be in-flight when the snapshot runs.
542
+ const firstReply = await th.waitForBotReply({
543
+ timeout: 4_000,
544
+ })
545
+ expect(firstReply.content.trim().length).toBeGreaterThan(0)
546
+
547
+ await waitForFooterMessage({
548
+ discord,
549
+ threadId: thread.id,
550
+ timeout: 4_000,
551
+ afterMessageIncludes: 'one',
552
+ afterAuthorId: TEST_USER_ID,
553
+ })
554
+
555
+ // Snapshot bot message count before sending follow-ups
556
+ const before = await th.getMessages()
557
+ const beforeBotCount = before.filter((m) => {
558
+ return m.author.id === discord.botUserId
559
+ }).length
560
+
561
+ // 2. Rapidly send messages B and C. With opencode queue mode,
562
+ // both messages are serialized by opencode's per-session loop.
563
+ await th.user(TEST_USER_ID).sendMessage({
564
+ content: 'Reply with exactly: two',
565
+ })
566
+ await th.user(TEST_USER_ID).sendMessage({
567
+ content: 'Reply with exactly: three',
568
+ })
569
+
570
+ // 3. Wait for a bot reply after message C.
571
+ const after = await waitForBotReplyAfterUserMessage({
572
+ discord,
573
+ threadId: thread.id,
574
+ userId: TEST_USER_ID,
575
+ userMessageIncludes: 'three',
576
+ timeout: 4_000,
577
+ })
578
+
579
+ // 4. Verify the latest user message got a bot reply.
580
+ const afterBotMessages = after.filter((m) => {
581
+ return m.author.id === discord.botUserId
582
+ })
583
+ expect(afterBotMessages.length).toBeGreaterThanOrEqual(beforeBotCount + 1)
584
+
585
+ await waitForFooterMessage({
586
+ discord,
587
+ threadId: thread.id,
588
+ timeout: 4_000,
589
+ afterMessageIncludes: 'three',
590
+ afterAuthorId: TEST_USER_ID,
591
+ })
592
+
593
+ expect(await th.text()).toMatchInlineSnapshot(`
594
+ "--- from: user (queue-tester)
595
+ Reply with exactly: one
596
+ --- from: assistant (TestBot)
597
+ ⬥ ok
598
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
599
+ --- from: user (queue-tester)
600
+ Reply with exactly: two
601
+ Reply with exactly: three
602
+ --- from: assistant (TestBot)
603
+ ⬥ ok
604
+ ⬥ ok
605
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
606
+ `)
607
+ const userThreeIndex = after.findIndex((message) => {
608
+ return (
609
+ message.author.id === TEST_USER_ID &&
610
+ message.content.includes('three')
611
+ )
612
+ })
613
+ expect(userThreeIndex).toBeGreaterThan(-1)
614
+
615
+ const botAfterThreeIndex = after.findIndex((message, index) => {
616
+ return index > userThreeIndex && message.author.id === discord.botUserId
617
+ })
618
+ expect(botAfterThreeIndex).toBeGreaterThan(userThreeIndex)
619
+
620
+ const newBotReplies = afterBotMessages.slice(beforeBotCount)
621
+ expect(newBotReplies.some((reply) => {
622
+ return reply.content.trim().length > 0
623
+ })).toBe(true)
624
+
625
+ const finalState = await waitForThreadState({
626
+ threadId: thread.id,
627
+ predicate: (state) => {
628
+ return state.queueItems.length === 0
629
+ },
630
+ timeout: 4_000,
631
+ description: 'queue empty after rapid interrupts',
632
+ })
633
+ expect(finalState.queueItems.length).toBe(0)
634
+ },
635
+ 8_000,
636
+ )
637
+
638
+ test(
639
+ 'normal messages bypass local queue and still show assistant text parts',
640
+ async () => {
641
+ const setupPrompt = 'Reply with exactly: opencode-queue-setup'
642
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
643
+ content: setupPrompt,
644
+ })
645
+
646
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
647
+ timeout: 4_000,
648
+ predicate: (t) => {
649
+ return t.name === 'Reply with exactly: opencode-queue-setup'
650
+ },
651
+ })
652
+
653
+ const th = discord.thread(thread.id)
654
+ const firstReply = await th.waitForBotReply({ timeout: 4_000 })
655
+ expect(firstReply.content.trim().length).toBeGreaterThan(0)
656
+
657
+ // Anchor follow-up on an already-completed first run so footer ordering
658
+ // is deterministic before we assert on the second prompt.
659
+ await waitForFooterMessage({
660
+ discord,
661
+ threadId: thread.id,
662
+ timeout: 4_000,
663
+ })
664
+
665
+ const followupPrompt =
666
+ 'Prompt from test: respond with short text for opencode queue mode.'
667
+
668
+ const followupUserMessage = await th.user(TEST_USER_ID).sendMessage({
669
+ content: followupPrompt,
670
+ })
671
+
672
+ // Assert assistant text parts are visible in Discord.
673
+ await waitForBotMessageContaining({
674
+ discord,
675
+ threadId: thread.id,
676
+ userId: TEST_USER_ID,
677
+ text: '⬥ ok',
678
+ afterMessageId: followupUserMessage.id,
679
+ timeout: 4_000,
680
+ })
681
+
682
+ const messagesWithFollowupFooter = await waitForFooterMessage({
683
+ discord,
684
+ threadId: thread.id,
685
+ timeout: 4_000,
686
+ afterMessageIncludes: followupPrompt,
687
+ afterAuthorId: TEST_USER_ID,
688
+ })
689
+
690
+ expect(await th.text()).toMatchInlineSnapshot(`
691
+ "--- from: user (queue-tester)
692
+ Reply with exactly: opencode-queue-setup
693
+ --- from: assistant (TestBot)
694
+ ⬥ ok
695
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
696
+ --- from: user (queue-tester)
697
+ Prompt from test: respond with short text for opencode queue mode.
698
+ --- from: assistant (TestBot)
699
+ ⬥ ok
700
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
701
+ `)
702
+ const followupUserIndex = messagesWithFollowupFooter.findIndex((message) => {
703
+ return message.id === followupUserMessage.id
704
+ })
705
+ const textPartAfterFollowupIndex = messagesWithFollowupFooter.findIndex((message, index) => {
706
+ return (
707
+ index > followupUserIndex &&
708
+ message.author.id === discord.botUserId &&
709
+ message.content.includes('⬥ ok')
710
+ )
711
+ })
712
+ const footerAfterFollowupIndex = messagesWithFollowupFooter.findIndex((message, index) => {
713
+ return (
714
+ index > textPartAfterFollowupIndex &&
715
+ message.author.id === discord.botUserId &&
716
+ message.content.startsWith('*') &&
717
+ message.content.includes('⋅')
718
+ )
719
+ })
720
+ expect(followupUserIndex).toBeGreaterThan(-1)
721
+ expect(textPartAfterFollowupIndex).toBeGreaterThan(followupUserIndex)
722
+ expect(footerAfterFollowupIndex).toBeGreaterThan(textPartAfterFollowupIndex)
723
+ // Normal messages should not populate kimaki local queue.
724
+ const noLocalQueueState = await waitForThreadState({
725
+ threadId: thread.id,
726
+ predicate: (state) => {
727
+ return state.queueItems.length === 0
728
+ },
729
+ timeout: 4_000,
730
+ description: 'local queue remains empty in opencode mode',
731
+ })
732
+ expect(noLocalQueueState.queueItems.length).toBe(0)
733
+ },
734
+ 8_000,
735
+ )
736
+
737
+ test(
738
+ 'bash tool-call actually executes and creates file in project directory',
739
+ async () => {
740
+ const markerRelativePath = path.join('tmp', 'bash-tool-executed.txt')
741
+ const markerPath = path.join(directories.projectDirectory, markerRelativePath)
742
+ fs.rmSync(markerPath, { force: true })
743
+
744
+ const prompt = 'Reply with exactly: BASH_TOOL_FILE_MARKER'
745
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
746
+ content: prompt,
747
+ })
748
+
749
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
750
+ timeout: 4_000,
751
+ predicate: (t) => {
752
+ return t.name === prompt
753
+ },
754
+ })
755
+
756
+ await waitForBotMessageContaining({
757
+ discord,
758
+ threadId: thread.id,
759
+ userId: TEST_USER_ID,
760
+ text: 'running create file',
761
+ timeout: 4_000,
762
+ })
763
+
764
+ await waitForFooterMessage({
765
+ discord,
766
+ threadId: thread.id,
767
+ timeout: 4_000,
768
+ })
769
+
770
+ const deadline = Date.now() + 4_000
771
+ while (!fs.existsSync(markerPath) && Date.now() < deadline) {
772
+ await new Promise((resolve) => {
773
+ setTimeout(resolve, 100)
774
+ })
775
+ }
776
+
777
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
778
+ "--- from: user (queue-tester)
779
+ Reply with exactly: BASH_TOOL_FILE_MARKER
780
+ --- from: assistant (TestBot)
781
+ ⬥ running create file
782
+ ⬥ ok
783
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
784
+ `)
785
+ expect(fs.existsSync(markerPath)).toBe(true)
786
+ const markerContents = fs.readFileSync(markerPath, 'utf8')
787
+ expect(markerContents).toBe('created')
788
+ },
789
+ 8_000,
790
+ )
791
+
792
+ test(
793
+ '/queue shows queued status first, then dispatch indicator when dequeued',
794
+ async () => {
795
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
796
+ content: 'Reply with exactly: queue-slash-setup',
797
+ })
798
+
799
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
800
+ timeout: 4_000,
801
+ predicate: (t) => {
802
+ return t.name === 'Reply with exactly: queue-slash-setup'
803
+ },
804
+ })
805
+
806
+ const th = discord.thread(thread.id)
807
+ const firstReply = await th.waitForBotReply({ timeout: 4_000 })
808
+ expect(firstReply.content.trim().length).toBeGreaterThan(0)
809
+
810
+ // Ensure the setup run is fully settled before slash-queue checks.
811
+ // Otherwise the first /queue call can race with a still-busy run window.
812
+ await waitForFooterMessage({
813
+ discord,
814
+ threadId: thread.id,
815
+ timeout: 4_000,
816
+ })
817
+
818
+ // Start a non-interrupting queued slash message while idle so it
819
+ // dispatches immediately and keeps the runtime active.
820
+ const { id: firstQueueInteractionId } = await th.user(TEST_USER_ID)
821
+ .runSlashCommand({
822
+ name: 'queue',
823
+ options: [{ name: 'message', type: 3, value: 'Reply with exactly: race-final' }],
824
+ })
825
+
826
+ const firstQueueAck = await th.waitForInteractionAck({
827
+ interactionId: firstQueueInteractionId,
828
+ timeout: 4_000,
829
+ })
830
+ if (!firstQueueAck.messageId) {
831
+ throw new Error('Expected first /queue response message id')
832
+ }
833
+
834
+ const firstQueueAckMessage = await waitForMessageById({
835
+ discord,
836
+ threadId: thread.id,
837
+ messageId: firstQueueAck.messageId,
838
+ timeout: 4_000,
839
+ })
840
+ expect(firstQueueAckMessage.content).toContain('» **queue-tester:** Reply with exactly: race-final')
841
+
842
+ const queuedPrompt = 'Reply with exactly: queued-from-slash'
843
+ const { id: interactionId } = await th.user(TEST_USER_ID).runSlashCommand({
844
+ name: 'queue',
845
+ options: [{ name: 'message', type: 3, value: queuedPrompt }],
846
+ })
847
+
848
+ const queuedAck = await th.waitForInteractionAck({ interactionId, timeout: 4_000 })
849
+ if (!queuedAck.messageId) {
850
+ throw new Error('Expected queued /queue response message id')
851
+ }
852
+
853
+ const queuedStatusMessage = await waitForMessageById({
854
+ discord,
855
+ threadId: thread.id,
856
+ messageId: queuedAck.messageId,
857
+ timeout: 4_000,
858
+ })
859
+ expect(queuedStatusMessage.content.startsWith('Queued message')).toBe(true)
860
+
861
+ const expectedDispatchIndicator = `» **queue-tester:** ${queuedPrompt}`
862
+ const messagesWithDispatch = await waitForBotMessageContaining({
863
+ discord,
864
+ threadId: thread.id,
865
+ userId: TEST_USER_ID,
866
+ text: expectedDispatchIndicator,
867
+ afterMessageId: queuedStatusMessage.id,
868
+ timeout: 8_000,
869
+ })
870
+
871
+ const queuedStatusIndex = messagesWithDispatch.findIndex((message) => {
872
+ return message.id === queuedStatusMessage.id
873
+ })
874
+ const dispatchIndicatorIndex = messagesWithDispatch.findIndex((message) => {
875
+ return (
876
+ message.author.id === discord.botUserId &&
877
+ message.content.includes(expectedDispatchIndicator)
878
+ )
879
+ })
880
+ expect(queuedStatusIndex).toBeGreaterThan(-1)
881
+ expect(dispatchIndicatorIndex).toBeGreaterThan(queuedStatusIndex)
882
+
883
+ const dispatchIndicatorMessage = messagesWithDispatch[dispatchIndicatorIndex]
884
+ if (!dispatchIndicatorMessage) {
885
+ throw new Error('Expected dispatch indicator message')
886
+ }
887
+
888
+ await waitForBotMessageContaining({
889
+ discord,
890
+ threadId: thread.id,
891
+ text: '⬥ ok',
892
+ afterMessageId: dispatchIndicatorMessage.id,
893
+ timeout: 8_000,
894
+ })
895
+
896
+ await waitForFooterMessage({
897
+ discord,
898
+ threadId: thread.id,
899
+ timeout: 8_000,
900
+ afterMessageIncludes: '⬥ ok',
901
+ afterAuthorId: discord.botUserId,
902
+ })
903
+
904
+ expect(await th.text()).toMatchInlineSnapshot(`
905
+ "--- from: user (queue-tester)
906
+ Reply with exactly: queue-slash-setup
907
+ --- from: assistant (TestBot)
908
+ ⬥ ok
909
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
910
+ » **queue-tester:** Reply with exactly: race-final
911
+ Queued message (position 1)
912
+ ⬥ race-final
913
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
914
+ » **queue-tester:** Reply with exactly: queued-from-slash
915
+ ⬥ ok
916
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
917
+ `)
918
+ },
919
+ 12_000,
920
+ )
921
+
922
+ test(
923
+ 'queued message waits for running session and then processes next',
924
+ async () => {
925
+ // When a new message arrives while a session is running, it queues and
926
+ // runs after the in-flight request completes.
927
+ //
928
+ // 1. Fast setup: establish session
929
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
930
+ content: 'Reply with exactly: delta',
931
+ })
932
+
933
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
934
+ timeout: 4_000,
935
+ predicate: (t) => {
936
+ return t.name === 'Reply with exactly: delta'
937
+ },
938
+ })
939
+
940
+ const th = discord.thread(thread.id)
941
+ const firstReply = await th.waitForBotReply({ timeout: 4_000 })
942
+ expect(firstReply.content.trim().length).toBeGreaterThan(0)
943
+
944
+ const before = await th.getMessages()
945
+ const beforeBotCount = before.filter((m) => {
946
+ return m.author.id === discord.botUserId
947
+ }).length
948
+
949
+ // 2. Send B, then quickly send C to enqueue behind B.
950
+ await th.user(TEST_USER_ID).sendMessage({
951
+ content: 'Reply with exactly: echo',
952
+ })
953
+ await new Promise((r) => {
954
+ setTimeout(r, 500)
955
+ })
956
+ await th.user(TEST_USER_ID).sendMessage({
957
+ content: 'Reply with exactly: foxtrot',
958
+ })
959
+
960
+ // 3. Poll until foxtrot's user message has a bot reply after it.
961
+ // waitForBotMessageCount alone isn't enough — error messages from the
962
+ // interrupted session can satisfy the count before foxtrot gets its reply.
963
+ const after = await waitForBotReplyAfterUserMessage({
964
+ discord,
965
+ threadId: thread.id,
966
+ userId: TEST_USER_ID,
967
+ userMessageIncludes: 'foxtrot',
968
+ timeout: 4_000,
969
+ })
970
+
971
+ // 4. Foxtrot got a bot response after B/C were processed.
972
+ const afterBotMessages = after.filter((m) => {
973
+ return m.author.id === discord.botUserId
974
+ })
975
+ expect(afterBotMessages.length).toBeGreaterThanOrEqual(beforeBotCount + 1)
976
+
977
+ await waitForFooterMessage({
978
+ discord,
979
+ threadId: thread.id,
980
+ timeout: 4_000,
981
+ afterMessageIncludes: 'foxtrot',
982
+ afterAuthorId: TEST_USER_ID,
983
+ })
984
+
985
+ // Assert ordering invariants instead of exact snapshot — the echo reply
986
+ // and footer can interleave non-deterministically on slower CI hardware.
987
+ const finalMessages = await th.getMessages()
988
+ const userEchoIndex = finalMessages.findIndex((m) => {
989
+ return m.author.id === TEST_USER_ID && m.content.includes('echo')
990
+ })
991
+ const userFoxtrotIndex = finalMessages.findIndex((m) => {
992
+ return m.author.id === TEST_USER_ID && m.content.includes('foxtrot')
993
+ })
994
+ expect(userEchoIndex).toBeGreaterThan(-1)
995
+ expect(userFoxtrotIndex).toBeGreaterThan(-1)
996
+ // User messages appear in send order
997
+ expect(userEchoIndex).toBeLessThan(userFoxtrotIndex)
998
+
999
+ // Foxtrot's bot reply appears after the foxtrot user message
1000
+ const botAfterFoxtrot = finalMessages.findIndex((m, i) => {
1001
+ return i > userFoxtrotIndex && m.author.id === discord.botUserId
1002
+ })
1003
+ expect(botAfterFoxtrot).toBeGreaterThan(userFoxtrotIndex)
1004
+
1005
+ // A footer appears after foxtrot (session completed)
1006
+ const timeline = await th.text()
1007
+ expect(timeline).toContain('Reply with exactly: echo')
1008
+ expect(timeline).toContain('Reply with exactly: foxtrot')
1009
+ expect(timeline).toContain('*project ⋅ main ⋅')
1010
+ },
1011
+ 8_000,
1012
+ )
1013
+
1014
+ test(
1015
+ 'slow stream still processes queued next message after completion',
1016
+ async () => {
1017
+ // A message sent mid-stream queues and runs after the in-flight request
1018
+ // completes (no auto-interrupt).
1019
+
1020
+ // 1. Fast setup: establish session
1021
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
1022
+ content: 'Reply with exactly: golf',
1023
+ })
1024
+
1025
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
1026
+ timeout: 4_000,
1027
+ predicate: (t) => {
1028
+ return t.name === 'Reply with exactly: golf'
1029
+ },
1030
+ })
1031
+
1032
+ const th = discord.thread(thread.id)
1033
+ const firstReply = await th.waitForBotReply({ timeout: 4_000 })
1034
+ expect(firstReply.content.trim().length).toBeGreaterThan(0)
1035
+
1036
+ // Wait for golf's footer so the golf→hotel transition is deterministic
1037
+ await waitForFooterMessage({
1038
+ discord,
1039
+ threadId: thread.id,
1040
+ timeout: 4_000,
1041
+ afterMessageIncludes: 'ok',
1042
+ afterAuthorId: discord.botUserId,
1043
+ })
1044
+
1045
+ const before = await th.getMessages()
1046
+ const beforeBotCount = before.filter((m) => {
1047
+ return m.author.id === discord.botUserId
1048
+ }).length
1049
+
1050
+ // 2. Start request B (hotel, slow matcher ~400ms), then send C while B
1051
+ // is still in progress.
1052
+ await th.user(TEST_USER_ID).sendMessage({
1053
+ content: 'Reply with exactly: hotel',
1054
+ })
1055
+
1056
+ // 3. Wait briefly for B to start, then send C to queue behind it
1057
+ await new Promise((r) => {
1058
+ setTimeout(r, 200)
1059
+ })
1060
+ await th.user(TEST_USER_ID).sendMessage({
1061
+ content: 'Reply with exactly: india',
1062
+ })
1063
+
1064
+ // 4. B completes, then C gets processed.
1065
+ // Poll until india's user message has a bot reply after it.
1066
+ const after = await waitForBotReplyAfterUserMessage({
1067
+ discord,
1068
+ threadId: thread.id,
1069
+ userId: TEST_USER_ID,
1070
+ userMessageIncludes: 'india',
1071
+ timeout: 4_000,
1072
+ })
1073
+
1074
+ await waitForFooterMessage({
1075
+ discord,
1076
+ threadId: thread.id,
1077
+ timeout: 4_000,
1078
+ afterMessageIncludes: 'india',
1079
+ afterAuthorId: TEST_USER_ID,
1080
+ })
1081
+
1082
+ // C's user message appears before its bot response.
1083
+ // We assert on india's reply existence.
1084
+ expect(await th.text()).toMatchInlineSnapshot(`
1085
+ "--- from: user (queue-tester)
1086
+ Reply with exactly: golf
1087
+ --- from: assistant (TestBot)
1088
+ ⬥ ok
1089
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
1090
+ --- from: user (queue-tester)
1091
+ Reply with exactly: hotel
1092
+ Reply with exactly: india
1093
+ --- from: assistant (TestBot)
1094
+ ⬥ ok
1095
+ ⬥ ok
1096
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
1097
+ `)
1098
+ const userIndiaIndex = after.findIndex((m) => {
1099
+ return m.author.id === TEST_USER_ID && m.content.includes('india')
1100
+ })
1101
+ expect(userIndiaIndex).toBeGreaterThan(-1)
1102
+ const botAfterIndia = after.findIndex((m, i) => {
1103
+ return i > userIndiaIndex && m.author.id === discord.botUserId
1104
+ })
1105
+ expect(botAfterIndia).toBeGreaterThan(userIndiaIndex)
1106
+ },
1107
+ 8_000,
1108
+ )
1109
+
1110
+ test(
1111
+ 'queue drains correctly after bursty queued messages',
1112
+ async () => {
1113
+ // Verifies the queue doesn't get stuck after multiple rapid messages.
1114
+
1115
+ // 1. Fast setup: establish session
1116
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
1117
+ content: 'Reply with exactly: juliet',
1118
+ })
1119
+
1120
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
1121
+ timeout: 4_000,
1122
+ predicate: (t) => {
1123
+ return t.name === 'Reply with exactly: juliet'
1124
+ },
1125
+ })
1126
+
1127
+ const th = discord.thread(thread.id)
1128
+ const firstReply = await th.waitForBotReply({ timeout: 4_000 })
1129
+ expect(firstReply.content.trim().length).toBeGreaterThan(0)
1130
+
1131
+ const before = await th.getMessages()
1132
+ const beforeBotCount = before.filter((m) => {
1133
+ return m.author.id === discord.botUserId
1134
+ }).length
1135
+
1136
+ // 2. Rapidly send B, C, D back-to-back to avoid timing windows where
1137
+ // one run can finish between sends and reorder transcript lines.
1138
+ await th.user(TEST_USER_ID).sendMessage({
1139
+ content: 'Reply with exactly: kilo',
1140
+ })
1141
+ await th.user(TEST_USER_ID).sendMessage({
1142
+ content: 'Reply with exactly: lima',
1143
+ })
1144
+ await th.user(TEST_USER_ID).sendMessage({
1145
+ content: 'Reply with exactly: mike',
1146
+ })
1147
+
1148
+ // 3. Wait until the last burst message (mike) has a bot reply after it.
1149
+ const afterBurst = await waitForBotReplyAfterUserMessage({
1150
+ discord,
1151
+ threadId: thread.id,
1152
+ userId: TEST_USER_ID,
1153
+ userMessageIncludes: 'mike',
1154
+ timeout: 4_000,
1155
+ })
1156
+
1157
+ // 4. Queue should be clean — send E and verify it also gets processed
1158
+ await th.user(TEST_USER_ID).sendMessage({
1159
+ content: 'Reply with exactly: november',
1160
+ })
1161
+
1162
+ const afterE = await waitForBotReplyAfterUserMessage({
1163
+ discord,
1164
+ threadId: thread.id,
1165
+ userId: TEST_USER_ID,
1166
+ userMessageIncludes: 'november',
1167
+ timeout: 4_000,
1168
+ })
1169
+
1170
+ const textWithoutFooters = (await th.text())
1171
+ .split('\n')
1172
+ .filter((line) => {
1173
+ return !line.startsWith('*project ⋅')
1174
+ })
1175
+ .join('\n')
1176
+
1177
+ const normalizedTextWithoutFooters = textWithoutFooters.replace(
1178
+ [
1179
+ '--- from: assistant (TestBot)',
1180
+ '⬥ ok',
1181
+ '--- from: user (queue-tester)',
1182
+ 'Reply with exactly: november',
1183
+ ].join('\n'),
1184
+ [
1185
+ '--- from: assistant (TestBot)',
1186
+ '--- from: user (queue-tester)',
1187
+ 'Reply with exactly: november',
1188
+ ].join('\n'),
1189
+ )
1190
+
1191
+ expect(normalizedTextWithoutFooters).toMatchInlineSnapshot(`
1192
+ "--- from: user (queue-tester)
1193
+ Reply with exactly: juliet
1194
+ --- from: assistant (TestBot)
1195
+ ⬥ ok
1196
+ --- from: user (queue-tester)
1197
+ Reply with exactly: kilo
1198
+ Reply with exactly: lima
1199
+ Reply with exactly: mike
1200
+ --- from: assistant (TestBot)
1201
+ --- from: user (queue-tester)
1202
+ Reply with exactly: november
1203
+ --- from: assistant (TestBot)
1204
+ ⬥ ok"
1205
+ `)
1206
+ // E's user message appears before the final bot response
1207
+ const userNovemberIndex = afterE.findIndex((m) => {
1208
+ return m.author.id === TEST_USER_ID && m.content.includes('november')
1209
+ })
1210
+ expect(userNovemberIndex).toBeGreaterThan(-1)
1211
+ const lastBotIndex = afterE.findLastIndex((m) => {
1212
+ return m.author.id === discord.botUserId
1213
+ })
1214
+ expect(userNovemberIndex).toBeLessThan(lastBotIndex)
1215
+ },
1216
+ 8_000,
1217
+ )
1218
+
1219
+ })