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