@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,1021 @@
1
+ // E2e tests for voice message handling (audio attachment transcription).
2
+ // Uses deterministic transcription (store.test.deterministicTranscription) to
3
+ // bypass real AI model calls and control transcription output, timing, and
4
+ // queueMessage flag. Combined with opencode-deterministic-provider for session
5
+ // responses. Tests validate the full flow: attachment detection โ†’ transcription
6
+ // โ†’ session dispatch, including interrupt, queue, and race condition scenarios.
7
+ //
8
+ // Tests assert on both Discord messages (via digital twin) and session state
9
+ // transitions (via getThreadState from the zustand store).
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import url from 'node:url';
13
+ import { describe, beforeAll, afterAll, beforeEach, test, expect } from 'vitest';
14
+ import { ChannelType, Client, GatewayIntentBits, Partials } from 'discord.js';
15
+ import { DigitalDiscord } from 'discord-digital-twin/src';
16
+ import { buildDeterministicOpencodeConfig, } from 'opencode-deterministic-provider';
17
+ import { setDataDir } from './config.js';
18
+ import { store } from './store.js';
19
+ import { startDiscordBot } from './discord-bot.js';
20
+ import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, setChannelVerbosity, } from './database.js';
21
+ import { startHranaServer, stopHranaServer } from './hrana-server.js';
22
+ import { initializeOpencodeForDirectory, getOpencodeClient, stopOpencodeServer } from './opencode.js';
23
+ import { chooseLockPort, cleanupTestSessions, initTestGitRepo, waitForFooterMessage, waitForBotMessageContaining, waitForThreadState, } from './test-utils.js';
24
+ import { getThreadState } from './session-handler/thread-runtime-state.js';
25
+ const e2eTest = describe;
26
+ // โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
27
+ function createRunDirectories() {
28
+ const root = path.resolve(process.cwd(), 'tmp', 'voice-msg-e2e');
29
+ fs.mkdirSync(root, { recursive: true });
30
+ const dataDir = fs.mkdtempSync(path.join(root, 'data-'));
31
+ const projectDirectory = path.join(root, 'project');
32
+ fs.mkdirSync(projectDirectory, { recursive: true });
33
+ initTestGitRepo(projectDirectory);
34
+ return { root, dataDir, projectDirectory };
35
+ }
36
+ function createDiscordJsClient({ restUrl }) {
37
+ return new Client({
38
+ intents: [
39
+ GatewayIntentBits.Guilds,
40
+ GatewayIntentBits.GuildMessages,
41
+ GatewayIntentBits.MessageContent,
42
+ GatewayIntentBits.GuildVoiceStates,
43
+ ],
44
+ partials: [
45
+ Partials.Channel,
46
+ Partials.Message,
47
+ Partials.User,
48
+ Partials.ThreadMember,
49
+ ],
50
+ rest: {
51
+ api: restUrl,
52
+ version: '10',
53
+ },
54
+ });
55
+ }
56
+ /** Set the deterministic transcription config in the store for the next voice message. */
57
+ function setDeterministicTranscription(config) {
58
+ store.setState({
59
+ test: { deterministicTranscription: config },
60
+ });
61
+ }
62
+ function getOpencodeClientForTest(projectDirectory) {
63
+ const client = getOpencodeClient(projectDirectory);
64
+ if (!client) {
65
+ throw new Error('OpenCode client not found for project directory');
66
+ }
67
+ return client;
68
+ }
69
+ /** Extract text content from an array of parts (filters to TextPart only). */
70
+ function getTextFromParts(parts) {
71
+ return parts.flatMap((part) => {
72
+ if (part.type === 'text') {
73
+ return [part.text];
74
+ }
75
+ return [];
76
+ });
77
+ }
78
+ /** Get all user-role messages' text parts joined. */
79
+ function getUserTexts(messages) {
80
+ return messages
81
+ .filter((m) => m.info.role === 'user')
82
+ .flatMap((m) => getTextFromParts(m.parts));
83
+ }
84
+ /** Get all assistant-role messages' text parts joined. */
85
+ function getAssistantTexts(messages) {
86
+ return messages
87
+ .filter((m) => m.info.role === 'assistant')
88
+ .flatMap((m) => getTextFromParts(m.parts));
89
+ }
90
+ /**
91
+ * Poll session.messages() until predicate returns true.
92
+ * Used to wait for async session updates (prompts dispatched, responses completed).
93
+ */
94
+ async function waitForSessionMessages({ projectDirectory, sessionID, timeout, predicate, description, }) {
95
+ const client = getOpencodeClientForTest(projectDirectory);
96
+ const start = Date.now();
97
+ while (Date.now() - start < timeout) {
98
+ const result = await client.session.messages({
99
+ sessionID,
100
+ directory: projectDirectory,
101
+ });
102
+ const messages = result.data ?? [];
103
+ if (predicate(messages)) {
104
+ return messages;
105
+ }
106
+ await new Promise((resolve) => {
107
+ setTimeout(resolve, 100);
108
+ });
109
+ }
110
+ // Final attempt for error reporting
111
+ const finalResult = await client.session.messages({
112
+ sessionID,
113
+ directory: projectDirectory,
114
+ });
115
+ const finalMessages = finalResult.data ?? [];
116
+ const userTexts = getUserTexts(finalMessages);
117
+ const assistantTexts = getAssistantTexts(finalMessages);
118
+ throw new Error(`Timed out waiting for session messages (${description}). ` +
119
+ `User texts: ${JSON.stringify(userTexts.map((t) => t.slice(0, 80)))}. ` +
120
+ `Assistant texts: ${JSON.stringify(assistantTexts.map((t) => t.slice(0, 80)))}`);
121
+ }
122
+ // โ”€โ”€ Deterministic provider matchers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
123
+ // The opencode session uses these to produce canned responses.
124
+ function createDeterministicMatchers() {
125
+ // Slow response: emits text-delta after 2s delay, giving voice messages
126
+ // time to arrive while the session is still "running".
127
+ // Uses latestUserTextIncludes (not rawPromptIncludes) so it only matches
128
+ // the current user message, not previous messages in session history.
129
+ const slowResponse = {
130
+ id: 'slow-response',
131
+ priority: 100,
132
+ when: {
133
+ latestUserTextIncludes: 'SLOW_RESPONSE_MARKER',
134
+ },
135
+ then: {
136
+ parts: [
137
+ { type: 'stream-start', warnings: [] },
138
+ { type: 'text-start', id: 'slow' },
139
+ { type: 'text-delta', id: 'slow', delta: 'slow-response-done' },
140
+ { type: 'text-end', id: 'slow' },
141
+ {
142
+ type: 'finish',
143
+ finishReason: 'stop',
144
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
145
+ },
146
+ ],
147
+ // 2s delay on the first text delta โ€” keeps the session in "running" state
148
+ partDelaysMs: [0, 0, 2000, 0, 0],
149
+ },
150
+ };
151
+ // Fast response: completes almost immediately (~100ms)
152
+ const fastResponse = {
153
+ id: 'fast-response',
154
+ priority: 90,
155
+ when: {
156
+ latestUserTextIncludes: 'FAST_RESPONSE_MARKER',
157
+ },
158
+ then: {
159
+ parts: [
160
+ { type: 'stream-start', warnings: [] },
161
+ { type: 'text-start', id: 'fast' },
162
+ { type: 'text-delta', id: 'fast', delta: 'fast-response-done' },
163
+ { type: 'text-end', id: 'fast' },
164
+ {
165
+ type: 'finish',
166
+ finishReason: 'stop',
167
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
168
+ },
169
+ ],
170
+ partDelaysMs: [0, 100, 0, 0, 0],
171
+ },
172
+ };
173
+ // Default: matches any user message (fallback)
174
+ const defaultReply = {
175
+ id: 'default-reply',
176
+ priority: 1,
177
+ when: {
178
+ lastMessageRole: 'user',
179
+ },
180
+ then: {
181
+ parts: [
182
+ { type: 'stream-start', warnings: [] },
183
+ { type: 'text-start', id: 'default' },
184
+ { type: 'text-delta', id: 'default', delta: 'session-reply' },
185
+ { type: 'text-end', id: 'default' },
186
+ {
187
+ type: 'finish',
188
+ finishReason: 'stop',
189
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
190
+ },
191
+ ],
192
+ partDelaysMs: [0, 100, 0, 0, 0],
193
+ },
194
+ };
195
+ // Tool followup: when the last message is a tool result
196
+ const toolFollowup = {
197
+ id: 'tool-followup',
198
+ priority: 50,
199
+ when: {
200
+ lastMessageRole: 'tool',
201
+ },
202
+ then: {
203
+ parts: [
204
+ { type: 'stream-start', warnings: [] },
205
+ { type: 'text-start', id: 'tool-followup' },
206
+ { type: 'text-delta', id: 'tool-followup', delta: 'tool done' },
207
+ { type: 'text-end', id: 'tool-followup' },
208
+ {
209
+ type: 'finish',
210
+ finishReason: 'stop',
211
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
212
+ },
213
+ ],
214
+ },
215
+ };
216
+ return [slowResponse, fastResponse, toolFollowup, defaultReply];
217
+ }
218
+ // โ”€โ”€ Test constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
219
+ const TEST_USER_ID = '300000000000000777';
220
+ const TEXT_CHANNEL_ID = '300000000000000778';
221
+ // โ”€โ”€ Test suite โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
222
+ e2eTest('voice message handling', () => {
223
+ let directories;
224
+ let discord;
225
+ let botClient;
226
+ let previousDefaultVerbosity = store.getState().defaultVerbosity;
227
+ let testStartTime = Date.now();
228
+ beforeAll(async () => {
229
+ testStartTime = Date.now();
230
+ directories = createRunDirectories();
231
+ const lockPort = chooseLockPort({ key: TEXT_CHANNEL_ID });
232
+ process.env['KIMAKI_LOCK_PORT'] = String(lockPort);
233
+ setDataDir(directories.dataDir);
234
+ previousDefaultVerbosity = store.getState().defaultVerbosity;
235
+ store.setState({ defaultVerbosity: 'tools_and_text' });
236
+ const digitalDiscordDbPath = path.join(directories.dataDir, 'digital-discord.db');
237
+ discord = new DigitalDiscord({
238
+ guild: {
239
+ name: 'Voice E2E Guild',
240
+ ownerId: TEST_USER_ID,
241
+ },
242
+ channels: [
243
+ {
244
+ id: TEXT_CHANNEL_ID,
245
+ name: 'voice-e2e',
246
+ type: ChannelType.GuildText,
247
+ },
248
+ ],
249
+ users: [
250
+ {
251
+ id: TEST_USER_ID,
252
+ username: 'voice-tester',
253
+ },
254
+ ],
255
+ dbUrl: `file:${digitalDiscordDbPath}`,
256
+ });
257
+ await discord.start();
258
+ const providerNpm = url
259
+ .pathToFileURL(path.resolve(process.cwd(), '..', 'opencode-deterministic-provider', 'src', 'index.ts'))
260
+ .toString();
261
+ const opencodeConfig = buildDeterministicOpencodeConfig({
262
+ providerName: 'deterministic-provider',
263
+ providerNpm,
264
+ model: 'deterministic-v2',
265
+ smallModel: 'deterministic-v2',
266
+ settings: {
267
+ strict: false,
268
+ matchers: createDeterministicMatchers(),
269
+ },
270
+ });
271
+ fs.writeFileSync(path.join(directories.projectDirectory, 'opencode.json'), JSON.stringify(opencodeConfig, null, 2));
272
+ const dbPath = path.join(directories.dataDir, 'discord-sessions.db');
273
+ const hranaResult = await startHranaServer({ dbPath });
274
+ if (hranaResult instanceof Error) {
275
+ throw hranaResult;
276
+ }
277
+ process.env['KIMAKI_DB_URL'] = hranaResult;
278
+ await initDatabase();
279
+ await setBotToken(discord.botUserId, discord.botToken);
280
+ await setChannelDirectory({
281
+ channelId: TEXT_CHANNEL_ID,
282
+ directory: directories.projectDirectory,
283
+ channelType: 'text',
284
+ });
285
+ await setChannelVerbosity(TEXT_CHANNEL_ID, 'tools_and_text');
286
+ botClient = createDiscordJsClient({ restUrl: discord.restUrl });
287
+ await startDiscordBot({
288
+ token: discord.botToken,
289
+ appId: discord.botUserId,
290
+ discordClient: botClient,
291
+ });
292
+ // Pre-warm the opencode server
293
+ const warmup = await initializeOpencodeForDirectory(directories.projectDirectory);
294
+ if (warmup instanceof Error) {
295
+ throw warmup;
296
+ }
297
+ }, 60_000);
298
+ afterAll(async () => {
299
+ // Reset deterministic transcription
300
+ setDeterministicTranscription(null);
301
+ if (directories) {
302
+ await cleanupTestSessions({
303
+ projectDirectory: directories.projectDirectory,
304
+ testStartTime,
305
+ });
306
+ }
307
+ if (botClient) {
308
+ botClient.destroy();
309
+ }
310
+ await stopOpencodeServer();
311
+ await Promise.all([
312
+ closeDatabase().catch(() => {
313
+ return;
314
+ }),
315
+ stopHranaServer().catch(() => {
316
+ return;
317
+ }),
318
+ discord?.stop().catch(() => {
319
+ return;
320
+ }),
321
+ ]);
322
+ delete process.env['KIMAKI_LOCK_PORT'];
323
+ delete process.env['KIMAKI_DB_URL'];
324
+ store.setState({ defaultVerbosity: previousDefaultVerbosity });
325
+ if (directories) {
326
+ fs.rmSync(directories.dataDir, { recursive: true, force: true });
327
+ }
328
+ }, 10_000);
329
+ beforeEach(() => {
330
+ // Reset deterministic transcription before each test to prevent leakage
331
+ // from a failed test that set it but didn't clean up
332
+ setDeterministicTranscription(null);
333
+ });
334
+ // โ”€โ”€ Test 1: Voice message in a channel creates thread + session โ”€โ”€
335
+ test('voice message in channel creates thread and starts session', async () => {
336
+ setDeterministicTranscription({
337
+ transcription: 'Fix the login bug in auth.ts',
338
+ queueMessage: false,
339
+ });
340
+ // Send voice message in the text channel
341
+ await discord
342
+ .channel(TEXT_CHANNEL_ID)
343
+ .user(TEST_USER_ID)
344
+ .sendVoiceMessage();
345
+ // Thread should be created and renamed to the transcription text
346
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
347
+ timeout: 4_000,
348
+ predicate: (t) => {
349
+ return t.name?.includes('Fix the login bug') ?? false;
350
+ },
351
+ });
352
+ expect(thread).toBeDefined();
353
+ const th = discord.thread(thread.id);
354
+ // Bot should post "Transcribing..." then "Transcribed message: ..."
355
+ await waitForBotMessageContaining({
356
+ discord,
357
+ threadId: thread.id,
358
+ userId: TEST_USER_ID,
359
+ text: 'Transcribing voice message',
360
+ timeout: 4_000,
361
+ });
362
+ await waitForBotMessageContaining({
363
+ discord,
364
+ threadId: thread.id,
365
+ userId: TEST_USER_ID,
366
+ text: 'Fix the login bug in auth.ts',
367
+ timeout: 4_000,
368
+ });
369
+ // Session should get the transcribed prompt and respond
370
+ const sessionReply = await th.waitForBotReply({ timeout: 4_000 });
371
+ expect(sessionReply).toBeDefined();
372
+ await waitForFooterMessage({
373
+ discord,
374
+ threadId: thread.id,
375
+ timeout: 4_000,
376
+ });
377
+ // Assert thread state has a session and no queued messages after footer.
378
+ const finalState = await waitForThreadState({
379
+ threadId: thread.id,
380
+ predicate: (state) => {
381
+ return Boolean(state.sessionId) && state.queueItems.length === 0;
382
+ },
383
+ timeout: 4_000,
384
+ description: 'voice turn settled with empty queue',
385
+ });
386
+ await waitForFooterMessage({
387
+ discord,
388
+ threadId: thread.id,
389
+ timeout: 4_000,
390
+ });
391
+ expect(await th.text()).toMatchInlineSnapshot(`
392
+ "--- from: user (voice-tester)
393
+ [attachment: voice-message.ogg]
394
+ --- from: assistant (TestBot)
395
+ ๐ŸŽค Transcribing voice message...
396
+ ๐Ÿ“ **Transcribed message:** Fix the login bug in auth.ts
397
+ โฌฅ session-reply
398
+ *project โ‹… main โ‹… Ns โ‹… N% โ‹… deterministic-v2*"
399
+ `);
400
+ expect(finalState.sessionId).toBeDefined();
401
+ // Verify OpenCode session received the transcribed voice message as a prompt
402
+ const messages = await waitForSessionMessages({
403
+ projectDirectory: directories.projectDirectory,
404
+ sessionID: finalState.sessionId,
405
+ timeout: 4_000,
406
+ description: 'voice transcription prompt sent to session',
407
+ predicate: (all) => {
408
+ const userTexts = getUserTexts(all);
409
+ return userTexts.some((text) => text.includes('Fix the login bug in auth.ts'));
410
+ },
411
+ });
412
+ const userTexts = getUserTexts(messages);
413
+ expect(userTexts.some((t) => t.includes('Fix the login bug in auth.ts'))).toBe(true);
414
+ // Session should have at least one assistant response
415
+ const assistantTexts = getAssistantTexts(messages);
416
+ expect(assistantTexts.length).toBeGreaterThan(0);
417
+ }, 8_000);
418
+ test('voice attachment without content type still transcribes and avoids empty prompt dispatch', async () => {
419
+ setDeterministicTranscription({
420
+ transcription: 'Investigate the missing content type path',
421
+ queueMessage: false,
422
+ });
423
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
424
+ content: '',
425
+ attachments: [
426
+ {
427
+ id: 'voice-no-content-type',
428
+ filename: 'voice-message.ogg',
429
+ size: 1024,
430
+ url: 'https://fake-cdn.discord.test/voice-no-content-type.ogg',
431
+ proxy_url: 'https://fake-cdn.discord.test/voice-no-content-type.ogg',
432
+ },
433
+ ],
434
+ });
435
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
436
+ timeout: 4_000,
437
+ predicate: (t) => {
438
+ return t.name?.includes('Investigate the missing content type path') ?? false;
439
+ },
440
+ });
441
+ const th = discord.thread(thread.id);
442
+ await waitForBotMessageContaining({
443
+ discord,
444
+ threadId: thread.id,
445
+ userId: TEST_USER_ID,
446
+ text: 'Transcribing voice message',
447
+ timeout: 4_000,
448
+ });
449
+ await waitForBotMessageContaining({
450
+ discord,
451
+ threadId: thread.id,
452
+ userId: TEST_USER_ID,
453
+ text: 'Investigate the missing content type path',
454
+ timeout: 4_000,
455
+ });
456
+ await waitForFooterMessage({
457
+ discord,
458
+ threadId: thread.id,
459
+ timeout: 4_000,
460
+ });
461
+ const finalState = await waitForThreadState({
462
+ threadId: thread.id,
463
+ predicate: (state) => {
464
+ return Boolean(state.sessionId) && state.queueItems.length === 0;
465
+ },
466
+ timeout: 4_000,
467
+ description: 'voice attachment without content type settled',
468
+ });
469
+ expect(await th.text()).toMatchInlineSnapshot(`
470
+ "--- from: user (voice-tester)
471
+ [attachment: voice-message.ogg]
472
+ --- from: assistant (TestBot)
473
+ ๐ŸŽค Transcribing voice message...
474
+ ๐Ÿ“ **Transcribed message:** Investigate the missing content type path
475
+ โฌฅ session-reply
476
+ *project โ‹… main โ‹… Ns โ‹… N% โ‹… deterministic-v2*"
477
+ `);
478
+ const messages = await waitForSessionMessages({
479
+ projectDirectory: directories.projectDirectory,
480
+ sessionID: finalState.sessionId,
481
+ timeout: 4_000,
482
+ description: 'voice attachment without content type dispatched once',
483
+ predicate: (all) => {
484
+ const userTexts = getUserTexts(all);
485
+ return userTexts.some((text) => {
486
+ return text.includes('Investigate the missing content type path');
487
+ });
488
+ },
489
+ });
490
+ const userTexts = getUserTexts(messages);
491
+ expect(userTexts).not.toContain('');
492
+ expect(userTexts.some((text) => {
493
+ return text.includes('Investigate the missing content type path');
494
+ })).toBe(true);
495
+ }, 8_000);
496
+ // โ”€โ”€ Test 2: Voice message in thread with idle session โ”€โ”€
497
+ test('voice message in thread with idle session starts new request', async () => {
498
+ // 1. Create a session with a text message first
499
+ setDeterministicTranscription(null); // text message, no transcription
500
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
501
+ content: 'FAST_RESPONSE_MARKER initial setup',
502
+ });
503
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
504
+ timeout: 4_000,
505
+ predicate: (t) => {
506
+ return t.name?.includes('FAST_RESPONSE_MARKER') ?? false;
507
+ },
508
+ });
509
+ const th = discord.thread(thread.id);
510
+ // Wait for the initial setup turn to fully complete before sending voice.
511
+ await waitForBotMessageContaining({
512
+ discord,
513
+ threadId: thread.id,
514
+ userId: TEST_USER_ID,
515
+ text: 'fast-response-done',
516
+ timeout: 4_000,
517
+ });
518
+ await waitForFooterMessage({
519
+ discord,
520
+ threadId: thread.id,
521
+ timeout: 4_000,
522
+ });
523
+ // 2. Now send a voice message to the idle session
524
+ setDeterministicTranscription({
525
+ transcription: 'Add error handling to the parser',
526
+ queueMessage: false,
527
+ });
528
+ await th.user(TEST_USER_ID).sendVoiceMessage();
529
+ // Bot should post transcription messages
530
+ await waitForBotMessageContaining({
531
+ discord,
532
+ threadId: thread.id,
533
+ userId: TEST_USER_ID,
534
+ text: 'Transcribing voice message',
535
+ timeout: 4_000,
536
+ });
537
+ await waitForBotMessageContaining({
538
+ discord,
539
+ threadId: thread.id,
540
+ userId: TEST_USER_ID,
541
+ text: 'Add error handling to the parser',
542
+ timeout: 4_000,
543
+ });
544
+ await waitForBotMessageContaining({
545
+ discord,
546
+ threadId: thread.id,
547
+ userId: TEST_USER_ID,
548
+ text: 'session-reply',
549
+ timeout: 4_000,
550
+ });
551
+ await waitForFooterMessage({
552
+ discord,
553
+ threadId: thread.id,
554
+ timeout: 4_000,
555
+ afterMessageIncludes: 'session-reply',
556
+ afterAuthorId: discord.botUserId,
557
+ });
558
+ const finalState = getThreadState(thread.id);
559
+ expect(await th.text()).toMatchInlineSnapshot(`
560
+ "--- from: user (voice-tester)
561
+ FAST_RESPONSE_MARKER initial setup
562
+ --- from: assistant (TestBot)
563
+ โฌฅ fast-response-done
564
+ *project โ‹… main โ‹… Ns โ‹… N% โ‹… deterministic-v2*
565
+ --- from: user (voice-tester)
566
+ [attachment: voice-message.ogg]
567
+ --- from: assistant (TestBot)
568
+ ๐ŸŽค Transcribing voice message...
569
+ ๐Ÿ“ **Transcribed message:** Add error handling to the parser
570
+ โฌฅ session-reply
571
+ *project โ‹… main โ‹… Ns โ‹… N% โ‹… deterministic-v2*"
572
+ `);
573
+ expect(finalState?.sessionId).toBeDefined();
574
+ if (!finalState?.sessionId) {
575
+ throw new Error('Expected final state with sessionId');
576
+ }
577
+ expect(finalState.queueItems.length).toBe(0);
578
+ // Verify the same OpenCode session received both prompts:
579
+ // the initial text message AND the voice transcription
580
+ const messages = await waitForSessionMessages({
581
+ projectDirectory: directories.projectDirectory,
582
+ sessionID: finalState.sessionId,
583
+ timeout: 4_000,
584
+ description: 'idle session receives voice transcription prompt',
585
+ predicate: (all) => {
586
+ const userTexts = getUserTexts(all);
587
+ return (userTexts.some((t) => t.includes('FAST_RESPONSE_MARKER initial setup')) &&
588
+ userTexts.some((t) => t.includes('Add error handling to the parser')));
589
+ },
590
+ });
591
+ const userTexts = getUserTexts(messages);
592
+ expect(userTexts.some((t) => t.includes('FAST_RESPONSE_MARKER initial setup'))).toBe(true);
593
+ expect(userTexts.some((t) => t.includes('Add error handling to the parser'))).toBe(true);
594
+ // Both prompts should have gotten assistant responses
595
+ const assistantTexts = getAssistantTexts(messages);
596
+ expect(assistantTexts.length).toBeGreaterThanOrEqual(2);
597
+ }, 8_000);
598
+ // โ”€โ”€ Test 3: Voice message queues behind running session (default) โ”€โ”€
599
+ test.skip('voice message with queueMessage=false queues behind running session', async () => {
600
+ // 1. Start a session with a slow response
601
+ setDeterministicTranscription(null);
602
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
603
+ content: 'SLOW_RESPONSE_MARKER start slow task',
604
+ });
605
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
606
+ timeout: 4_000,
607
+ predicate: (t) => {
608
+ return t.name?.includes('SLOW_RESPONSE_MARKER') ?? false;
609
+ },
610
+ });
611
+ const th = discord.thread(thread.id);
612
+ // 2. Send voice message while session is running (default: queue)
613
+ setDeterministicTranscription({
614
+ transcription: 'Stop and do this instead',
615
+ queueMessage: false,
616
+ });
617
+ await th.user(TEST_USER_ID).sendVoiceMessage();
618
+ // 3. Wait for transcription to appear first
619
+ await waitForBotMessageContaining({
620
+ discord,
621
+ threadId: thread.id,
622
+ userId: TEST_USER_ID,
623
+ text: 'Stop and do this instead',
624
+ timeout: 4_000,
625
+ });
626
+ // queueMessage=false no longer interrupts by default, so we should NOT
627
+ // receive the queued-position ack that queueMessage=true sends.
628
+ const afterTranscription = await th.getMessages();
629
+ const hasQueuedAck = afterTranscription.some((m) => {
630
+ return (m.author.id === discord.botUserId &&
631
+ m.content.includes('Queued at position'));
632
+ });
633
+ expect(hasQueuedAck).toBe(false);
634
+ const midState = getThreadState(thread.id);
635
+ expect(midState).toBeDefined();
636
+ // 4. Wait for both runs to finish (slow prompt + queued transcription)
637
+ const finalState = await waitForThreadState({
638
+ threadId: thread.id,
639
+ predicate: (s) => {
640
+ return s.queueItems.length === 0;
641
+ },
642
+ timeout: 8_000,
643
+ description: 'queue empty (default queued voice behavior)',
644
+ });
645
+ expect(finalState.sessionId).toBeDefined();
646
+ expect(finalState.queueItems.length).toBe(0);
647
+ // Verify the OpenCode session processed both prompts sequentially.
648
+ const messages = await waitForSessionMessages({
649
+ projectDirectory: directories.projectDirectory,
650
+ sessionID: finalState.sessionId,
651
+ timeout: 4_000,
652
+ description: 'default queue: original prompt + voice prompt',
653
+ predicate: (all) => {
654
+ const userTexts = getUserTexts(all);
655
+ const assistantTexts = getAssistantTexts(all);
656
+ return (userTexts.some((t) => t.includes('SLOW_RESPONSE_MARKER start slow task')) &&
657
+ userTexts.some((t) => t.includes('Stop and do this instead')) &&
658
+ assistantTexts.some((t) => t.includes('slow-response-done')) &&
659
+ assistantTexts.some((t) => t.includes('session-reply')));
660
+ },
661
+ });
662
+ const userTexts = getUserTexts(messages);
663
+ // Both prompts were sent to the same session
664
+ expect(userTexts.some((t) => t.includes('SLOW_RESPONSE_MARKER start slow task'))).toBe(true);
665
+ expect(userTexts.some((t) => t.includes('Stop and do this instead'))).toBe(true);
666
+ const assistantTexts = getAssistantTexts(messages);
667
+ expect(assistantTexts.some((t) => t.includes('slow-response-done'))).toBe(true);
668
+ expect(assistantTexts.some((t) => t.includes('session-reply'))).toBe(true);
669
+ }, 10_000);
670
+ // โ”€โ”€ Test 4: Voice message with queueMessage=true queues instead of interrupting โ”€โ”€
671
+ test('voice message with queueMessage=true queues behind running session', async () => {
672
+ // 1. Start a session with a slow response
673
+ setDeterministicTranscription(null);
674
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
675
+ content: 'SLOW_RESPONSE_MARKER start queued task',
676
+ });
677
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
678
+ timeout: 4_000,
679
+ predicate: (t) => {
680
+ return t.name?.includes('start queued task') ?? false;
681
+ },
682
+ });
683
+ const th = discord.thread(thread.id);
684
+ // 2. Send voice message with queueMessage=true (should NOT interrupt)
685
+ setDeterministicTranscription({
686
+ transcription: 'Queue this task for later',
687
+ queueMessage: true,
688
+ });
689
+ await th.user(TEST_USER_ID).sendVoiceMessage();
690
+ // 3. Transcription should appear, followed by queue notification
691
+ await waitForBotMessageContaining({
692
+ discord,
693
+ threadId: thread.id,
694
+ userId: TEST_USER_ID,
695
+ text: 'Queue this task for later',
696
+ timeout: 4_000,
697
+ });
698
+ const messagesWithQueueAck = await waitForBotMessageContaining({
699
+ discord,
700
+ threadId: thread.id,
701
+ userId: TEST_USER_ID,
702
+ text: 'Queued at position',
703
+ timeout: 4_000,
704
+ });
705
+ const queueAckMessage = messagesWithQueueAck.find((message) => {
706
+ return (message.author.id === discord.botUserId
707
+ && message.content.includes('Queued at position'));
708
+ });
709
+ expect(queueAckMessage).toBeDefined();
710
+ // 4. queueMessage=true should not interrupt the in-flight response.
711
+ await waitForBotMessageContaining({
712
+ discord,
713
+ threadId: thread.id,
714
+ userId: TEST_USER_ID,
715
+ text: 'slow-response-done',
716
+ timeout: 4_000,
717
+ });
718
+ if (!queueAckMessage) {
719
+ throw new Error('Expected queue ack message');
720
+ }
721
+ const dispatchPrefix = 'ยป **voice-tester:** Voice message transcription from Discord user:';
722
+ const messagesWithDispatch = await waitForBotMessageContaining({
723
+ discord,
724
+ threadId: thread.id,
725
+ text: dispatchPrefix,
726
+ afterMessageId: queueAckMessage.id,
727
+ timeout: 8_000,
728
+ });
729
+ const dispatchMessage = messagesWithDispatch.find((message) => {
730
+ return (message.author.id === discord.botUserId
731
+ && message.content.includes(dispatchPrefix));
732
+ });
733
+ expect(dispatchMessage).toBeDefined();
734
+ if (!dispatchMessage) {
735
+ throw new Error('Expected queued dispatch indicator message');
736
+ }
737
+ await waitForBotMessageContaining({
738
+ discord,
739
+ threadId: thread.id,
740
+ text: 'session-reply',
741
+ afterMessageId: dispatchMessage.id,
742
+ timeout: 8_000,
743
+ });
744
+ // 5. Wait for the slow session to finish AND the queue to drain.
745
+ // Using waitForThreadState with a compound predicate avoids matching
746
+ // the transient 'idle' state from run A before run B starts.
747
+ const finalState = await waitForThreadState({
748
+ threadId: thread.id,
749
+ predicate: (s) => {
750
+ return s.queueItems.length === 0;
751
+ },
752
+ timeout: 8_000,
753
+ description: 'queue empty (both runs completed)',
754
+ });
755
+ await waitForFooterMessage({
756
+ discord,
757
+ threadId: thread.id,
758
+ timeout: 4_000,
759
+ afterMessageIncludes: 'session-reply',
760
+ afterAuthorId: discord.botUserId,
761
+ });
762
+ expect(await th.text()).toMatchInlineSnapshot(`
763
+ "--- from: user (voice-tester)
764
+ SLOW_RESPONSE_MARKER start queued task
765
+ [attachment: voice-message.ogg]
766
+ --- from: assistant (TestBot)
767
+ ๐ŸŽค Transcribing voice message...
768
+ ๐Ÿ“ **Transcribed message:** Queue this task for later
769
+ Queued at position 1
770
+ โฌฅ slow-response-done
771
+ *project โ‹… main โ‹… Ns โ‹… N% โ‹… deterministic-v2*
772
+ ยป **voice-tester:** Voice message transcription from Discord user:
773
+ Queue this task for later
774
+ โฌฅ session-reply
775
+ *project โ‹… main โ‹… Ns โ‹… N% โ‹… deterministic-v2*"
776
+ `);
777
+ expect(finalState.queueItems.length).toBe(0);
778
+ // Verify the OpenCode session processed BOTH prompts sequentially:
779
+ // the slow initial prompt completed, then the queued voice prompt ran
780
+ const messages = await waitForSessionMessages({
781
+ projectDirectory: directories.projectDirectory,
782
+ sessionID: finalState.sessionId,
783
+ timeout: 4_000,
784
+ description: 'queue: both prompts processed with responses',
785
+ predicate: (all) => {
786
+ const userTexts = getUserTexts(all);
787
+ const assistantTexts = getAssistantTexts(all);
788
+ return (userTexts.some((t) => t.includes('SLOW_RESPONSE_MARKER start queued task')) &&
789
+ userTexts.some((t) => t.includes('Queue this task for later')) &&
790
+ assistantTexts.some((t) => t.includes('slow-response-done')) &&
791
+ assistantTexts.some((t) => t.includes('session-reply')));
792
+ },
793
+ });
794
+ const userTexts = getUserTexts(messages);
795
+ const assistantTexts = getAssistantTexts(messages);
796
+ // Both prompts sent to the session
797
+ expect(userTexts.some((t) => t.includes('SLOW_RESPONSE_MARKER start queued task'))).toBe(true);
798
+ expect(userTexts.some((t) => t.includes('Queue this task for later'))).toBe(true);
799
+ // Both got responses (slow response + default reply for queued message)
800
+ expect(assistantTexts.some((t) => t.includes('slow-response-done'))).toBe(true);
801
+ expect(assistantTexts.some((t) => t.includes('session-reply'))).toBe(true);
802
+ // No abort errors โ€” the queue preserved the first run
803
+ const abortedAssistant = messages.find((m) => {
804
+ return m.info.role === 'assistant' && m.info.error?.name === 'MessageAbortedError';
805
+ });
806
+ expect(abortedAssistant).toBeUndefined();
807
+ }, 12_000);
808
+ // โ”€โ”€ Test 5: Slow transcription finishes after session becomes idle (race condition) โ”€โ”€
809
+ test('slow transcription completing after session finishes is handled correctly', async () => {
810
+ // This tests the race condition where:
811
+ // 1. Session starts with a fast response (~100ms)
812
+ // 2. Voice message is sent simultaneously with slow transcription (500ms)
813
+ // 3. The fast session finishes BEFORE transcription completes
814
+ // 4. When transcription completes, the session is idle โ†’ should start new request
815
+ // 1. Start a session with a fast response
816
+ setDeterministicTranscription(null);
817
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
818
+ content: 'FAST_RESPONSE_MARKER quick task',
819
+ });
820
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
821
+ timeout: 4_000,
822
+ predicate: (t) => {
823
+ return t.name?.includes('quick task') ?? false;
824
+ },
825
+ });
826
+ const th = discord.thread(thread.id);
827
+ // Wait for the first run to complete before sending voice.
828
+ await th.waitForBotReply({ timeout: 4_000 });
829
+ await waitForFooterMessage({
830
+ discord,
831
+ threadId: thread.id,
832
+ timeout: 4_000,
833
+ });
834
+ // 2. Now send voice message with slow transcription
835
+ // The fast response completes in ~100ms, but transcription takes 500ms.
836
+ // By the time transcription returns, the session is already idle.
837
+ setDeterministicTranscription({
838
+ transcription: 'Delayed transcription result',
839
+ queueMessage: false,
840
+ delayMs: 500,
841
+ });
842
+ await th.user(TEST_USER_ID).sendVoiceMessage();
843
+ // 3. The transcription should complete after the session finishes
844
+ // and the transcribed message should be processed as a new request
845
+ await waitForBotMessageContaining({
846
+ discord,
847
+ threadId: thread.id,
848
+ userId: TEST_USER_ID,
849
+ text: 'Delayed transcription result',
850
+ timeout: 4_000,
851
+ });
852
+ await waitForFooterMessage({
853
+ discord,
854
+ threadId: thread.id,
855
+ timeout: 4_000,
856
+ afterMessageIncludes: 'Delayed transcription result',
857
+ afterAuthorId: discord.botUserId,
858
+ });
859
+ // 4. Session should process the delayed transcription and settle.
860
+ const finalState = await waitForThreadState({
861
+ threadId: thread.id,
862
+ predicate: (state) => {
863
+ return Boolean(state.sessionId) && state.queueItems.length === 0;
864
+ },
865
+ timeout: 4_000,
866
+ description: 'delayed transcription settled with empty queue',
867
+ });
868
+ expect(await th.text()).toMatchInlineSnapshot(`
869
+ "--- from: user (voice-tester)
870
+ FAST_RESPONSE_MARKER quick task
871
+ --- from: assistant (TestBot)
872
+ โฌฅ fast-response-done
873
+ *project โ‹… main โ‹… Ns โ‹… N% โ‹… deterministic-v2*
874
+ --- from: user (voice-tester)
875
+ [attachment: voice-message.ogg]
876
+ --- from: assistant (TestBot)
877
+ ๐ŸŽค Transcribing voice message...
878
+ ๐Ÿ“ **Transcribed message:** Delayed transcription result
879
+ โฌฅ session-reply
880
+ *project โ‹… main โ‹… Ns โ‹… N% โ‹… deterministic-v2*"
881
+ `);
882
+ expect(finalState.sessionId).toBeDefined();
883
+ expect(finalState.queueItems.length).toBe(0);
884
+ // 5. Verify the OpenCode session processed both prompts on the same session:
885
+ // the fast text message completed first, then the delayed voice transcription
886
+ const sessionMessages = await waitForSessionMessages({
887
+ projectDirectory: directories.projectDirectory,
888
+ sessionID: finalState.sessionId,
889
+ timeout: 4_000,
890
+ description: 'race: both prompts processed with responses on same session',
891
+ predicate: (all) => {
892
+ const userTexts = getUserTexts(all);
893
+ const aTexts = getAssistantTexts(all);
894
+ return (userTexts.some((t) => t.includes('FAST_RESPONSE_MARKER quick task')) &&
895
+ userTexts.some((t) => t.includes('Delayed transcription result')) &&
896
+ aTexts.length >= 2);
897
+ },
898
+ });
899
+ const userTexts = getUserTexts(sessionMessages);
900
+ expect(userTexts.some((t) => t.includes('FAST_RESPONSE_MARKER quick task'))).toBe(true);
901
+ expect(userTexts.some((t) => t.includes('Delayed transcription result'))).toBe(true);
902
+ // Both prompts got assistant responses (no aborts โ€” second arrived after first finished)
903
+ const assistantTexts = getAssistantTexts(sessionMessages);
904
+ expect(assistantTexts.length).toBeGreaterThanOrEqual(2);
905
+ const abortedAssistant = sessionMessages.find((m) => {
906
+ return m.info.role === 'assistant' && m.info.error?.name === 'MessageAbortedError';
907
+ });
908
+ expect(abortedAssistant).toBeUndefined();
909
+ }, 8_000);
910
+ // โ”€โ”€ Test 6: Slow transcription with queueMessage=true arriving after idle queue drain โ”€โ”€
911
+ test('slow queued transcription completing after session idle is dispatched', async () => {
912
+ // Reproduces the bug where a voice message with queueMessage=true has
913
+ // slow transcription that completes after the session is already idle.
914
+ // The message gets inserted into the local queue but was never drained
915
+ // because handleSessionIdle() didn't call tryDrainQueue().
916
+ //
917
+ // 1. Send a fast text message โ†’ session starts and finishes quickly
918
+ // 2. Send a voice message with queueMessage=true and slow transcription
919
+ // 3. Fast session finishes โ†’ handleSessionIdle fires
920
+ // 4. Transcription completes โ†’ enqueueViaLocalQueue adds item
921
+ // 5. tryDrainQueue in handleSessionIdle should pick it up
922
+ // 1. Start a session with a fast response
923
+ setDeterministicTranscription(null);
924
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
925
+ content: 'FAST_RESPONSE_MARKER fast before queued voice',
926
+ });
927
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
928
+ timeout: 4_000,
929
+ predicate: (t) => {
930
+ return t.name?.includes('fast before queued voice') ?? false;
931
+ },
932
+ });
933
+ const th = discord.thread(thread.id);
934
+ // Wait for the first run to fully complete before sending the queued voice message.
935
+ await th.waitForBotReply({ timeout: 4_000 });
936
+ await waitForFooterMessage({
937
+ discord,
938
+ threadId: thread.id,
939
+ timeout: 4_000,
940
+ });
941
+ // 2. Send voice message with queueMessage=true AND slow transcription.
942
+ // Session is already idle when this arrives. The transcription delay
943
+ // means the message enters the local queue after idle has fired.
944
+ setDeterministicTranscription({
945
+ transcription: 'Queued voice after idle',
946
+ queueMessage: true,
947
+ delayMs: 500,
948
+ });
949
+ await th.user(TEST_USER_ID).sendVoiceMessage();
950
+ // 3. The transcription should complete, and even though queueMessage=true
951
+ // routes through the local queue, the item should be drained immediately
952
+ // because the session is idle. No dispatch indicator (ยป prefix) appears
953
+ // because the message is dispatched immediately by enqueueViaLocalQueue's
954
+ // tryDrainQueue (showIndicator=false for first drain).
955
+ await waitForBotMessageContaining({
956
+ discord,
957
+ threadId: thread.id,
958
+ userId: TEST_USER_ID,
959
+ text: 'Queued voice after idle',
960
+ timeout: 4_000,
961
+ });
962
+ // Wait for the queued message response and footer
963
+ await waitForBotMessageContaining({
964
+ discord,
965
+ threadId: thread.id,
966
+ text: 'session-reply',
967
+ timeout: 4_000,
968
+ });
969
+ await waitForFooterMessage({
970
+ discord,
971
+ threadId: thread.id,
972
+ timeout: 4_000,
973
+ afterMessageIncludes: 'session-reply',
974
+ afterAuthorId: discord.botUserId,
975
+ });
976
+ // 4. Final state: queue should be empty
977
+ const finalState = await waitForThreadState({
978
+ threadId: thread.id,
979
+ predicate: (state) => {
980
+ return Boolean(state.sessionId) && state.queueItems.length === 0;
981
+ },
982
+ timeout: 4_000,
983
+ description: 'queued voice after idle settled with empty queue',
984
+ });
985
+ expect(await th.text()).toMatchInlineSnapshot(`
986
+ "--- from: user (voice-tester)
987
+ FAST_RESPONSE_MARKER fast before queued voice
988
+ --- from: assistant (TestBot)
989
+ โฌฅ fast-response-done
990
+ *project โ‹… main โ‹… Ns โ‹… N% โ‹… deterministic-v2*
991
+ --- from: user (voice-tester)
992
+ [attachment: voice-message.ogg]
993
+ --- from: assistant (TestBot)
994
+ ๐ŸŽค Transcribing voice message...
995
+ ๐Ÿ“ **Transcribed message:** Queued voice after idle
996
+ โฌฅ session-reply
997
+ *project โ‹… main โ‹… Ns โ‹… N% โ‹… deterministic-v2*"
998
+ `);
999
+ expect(finalState.sessionId).toBeDefined();
1000
+ expect(finalState.queueItems.length).toBe(0);
1001
+ // 5. Verify the OpenCode session processed both prompts
1002
+ const sessionMessages = await waitForSessionMessages({
1003
+ projectDirectory: directories.projectDirectory,
1004
+ sessionID: finalState.sessionId,
1005
+ timeout: 4_000,
1006
+ description: 'queued-voice-idle: both prompts processed',
1007
+ predicate: (all) => {
1008
+ const userTexts = getUserTexts(all);
1009
+ const aTexts = getAssistantTexts(all);
1010
+ return (userTexts.some((t) => t.includes('FAST_RESPONSE_MARKER fast before queued voice')) &&
1011
+ userTexts.some((t) => t.includes('Queued voice after idle')) &&
1012
+ aTexts.length >= 2);
1013
+ },
1014
+ });
1015
+ const userTexts = getUserTexts(sessionMessages);
1016
+ expect(userTexts.some((t) => t.includes('FAST_RESPONSE_MARKER fast before queued voice'))).toBe(true);
1017
+ expect(userTexts.some((t) => t.includes('Queued voice after idle'))).toBe(true);
1018
+ const assistantTexts = getAssistantTexts(sessionMessages);
1019
+ expect(assistantTexts.length).toBeGreaterThanOrEqual(2);
1020
+ }, 10_000);
1021
+ });