@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,316 @@
1
+ // E2e test for question tool: user text message during pending question should
2
+ // dismiss the question (abort), then enqueue as a normal user prompt.
3
+ // The user's message must appear as a real user message in the thread, not
4
+ // get consumed as a tool result answer (which lost voice/image content).
5
+
6
+ import { describe, test, expect, afterEach } from 'vitest'
7
+ import { setupQueueAdvancedSuite, TEST_USER_ID } from './queue-advanced-e2e-setup.js'
8
+ import { waitForBotMessageContaining, waitForFooterMessage } from './test-utils.js'
9
+ import { store, type DeterministicTranscriptionConfig } from './store.js'
10
+ import { getOpencodeClient } from './opencode.js'
11
+ import { getThreadSession } from './database.js'
12
+ import type { Message, Part } from '@opencode-ai/sdk/v2'
13
+
14
+ const TEXT_CHANNEL_ID = '200000000000001007'
15
+ const VOICE_CHANNEL_ID = '200000000000001017'
16
+
17
+ function setDeterministicTranscription(config: DeterministicTranscriptionConfig | null) {
18
+ store.setState({
19
+ test: { deterministicTranscription: config },
20
+ })
21
+ }
22
+
23
+ type SessionMessage = { info: Message; parts: Part[] }
24
+
25
+ function getOpencodeClientForTest(projectDirectory: string) {
26
+ const client = getOpencodeClient(projectDirectory)
27
+ if (!client) {
28
+ throw new Error('OpenCode client not found for project directory')
29
+ }
30
+ return client
31
+ }
32
+
33
+ function getTextFromParts(parts: Part[]): string[] {
34
+ return parts.flatMap((part) => {
35
+ if (part.type === 'text') {
36
+ return [part.text]
37
+ }
38
+ return []
39
+ })
40
+ }
41
+
42
+ function normalizeSessionText(text: string): string {
43
+ return text
44
+ .replace(/\[current git branch is [^\]]+\]/g, '')
45
+ .replace(/<discord-user[^>]*\/>/g, '<discord-user />')
46
+ .trim()
47
+ }
48
+
49
+ function getSessionRoleTextTimeline(messages: SessionMessage[]) {
50
+ return messages.flatMap((message) => {
51
+ const text = normalizeSessionText(getTextFromParts(message.parts).join(''))
52
+ if (!text.trim()) {
53
+ return []
54
+ }
55
+ return [{ role: message.info.role, text }]
56
+ })
57
+ }
58
+
59
+ function getSessionMessageSummary(messages: SessionMessage[]) {
60
+ return messages.map((message) => {
61
+ return {
62
+ role: message.info.role,
63
+ parts: message.parts.map((part) => {
64
+ if (part.type === 'text') {
65
+ return {
66
+ type: part.type,
67
+ text: normalizeSessionText(part.text),
68
+ }
69
+ }
70
+ if (part.type === 'tool') {
71
+ return {
72
+ type: part.type,
73
+ tool: part.tool,
74
+ status: part.state.status,
75
+ title: part.state.status === 'completed' ? part.state.title : undefined,
76
+ output: part.state.status === 'completed' ? part.state.output : undefined,
77
+ }
78
+ }
79
+ return { type: part.type }
80
+ }),
81
+ }
82
+ })
83
+ }
84
+
85
+ async function waitForSessionMessages({
86
+ projectDirectory,
87
+ sessionId,
88
+ timeoutMs,
89
+ predicate,
90
+ }: {
91
+ projectDirectory: string
92
+ sessionId: string
93
+ timeoutMs: number
94
+ predicate: (messages: SessionMessage[]) => boolean
95
+ }): Promise<SessionMessage[]> {
96
+ const client = getOpencodeClientForTest(projectDirectory)
97
+ const start = Date.now()
98
+ while (Date.now() - start < timeoutMs) {
99
+ const response = await client.session.messages({
100
+ sessionID: sessionId,
101
+ directory: projectDirectory,
102
+ })
103
+ const messages = response.data ?? []
104
+ if (predicate(messages)) {
105
+ return messages
106
+ }
107
+ await new Promise<void>((resolve) => {
108
+ setTimeout(resolve, 100)
109
+ })
110
+ }
111
+
112
+ const finalResponse = await client.session.messages({
113
+ sessionID: sessionId,
114
+ directory: projectDirectory,
115
+ })
116
+ return finalResponse.data ?? []
117
+ }
118
+
119
+ describe('queue advanced: question tool answer', () => {
120
+ const ctx = setupQueueAdvancedSuite({
121
+ channelId: TEXT_CHANNEL_ID,
122
+ channelName: 'qa-question-e2e',
123
+ dirName: 'qa-question-e2e',
124
+ username: 'queue-question-tester',
125
+ })
126
+
127
+ afterEach(() => {
128
+ setDeterministicTranscription(null)
129
+ })
130
+
131
+ test('user text message dismisses pending question and enqueues as normal prompt', async () => {
132
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
133
+ content: 'QUESTION_TEXT_ANSWER_MARKER',
134
+ })
135
+
136
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
137
+ timeout: 8_000,
138
+ predicate: (t) => {
139
+ return t.name === 'QUESTION_TEXT_ANSWER_MARKER'
140
+ },
141
+ })
142
+
143
+ const th = ctx.discord.thread(thread.id)
144
+
145
+ // Wait for the question dropdown message to appear in Discord.
146
+ // This is the user-visible signal that the question tool fired and
147
+ // kimaki processed the event. Avoids polling internal Maps which
148
+ // have timing sensitivity on slower CI hardware.
149
+ await waitForBotMessageContaining({
150
+ discord: ctx.discord,
151
+ threadId: thread.id,
152
+ text: 'Which option do you prefer?',
153
+ timeout: 12_000,
154
+ })
155
+
156
+ // User sends a text message while question is pending.
157
+ // This should:
158
+ // 1. Dismiss the pending question (cleanup context)
159
+ // 2. Abort the blocked session so OpenCode unblocks
160
+ // 3. Enqueue the message as a normal user prompt (not consumed as answer)
161
+ await th.user(TEST_USER_ID).sendMessage({
162
+ content: 'my text answer',
163
+ })
164
+
165
+ // Give time for question cleanup to propagate
166
+ await new Promise((r) => {
167
+ setTimeout(r, 1_000)
168
+ })
169
+
170
+ const timeline = await th.text({ showInteractions: true })
171
+
172
+ // The user's text answer must appear in Discord
173
+ expect(timeline).toContain('my text answer')
174
+ // The original question must have appeared
175
+ expect(timeline).toContain('Which option do you prefer?')
176
+ // The user's marker message triggered the question
177
+ expect(timeline).toContain('QUESTION_TEXT_ANSWER_MARKER')
178
+ }, 20_000)
179
+ })
180
+
181
+ describe('queue advanced: voice message during pending question', () => {
182
+ const ctx = setupQueueAdvancedSuite({
183
+ channelId: VOICE_CHANNEL_ID,
184
+ channelName: 'qa-question-voice-e2e',
185
+ dirName: 'qa-question-voice-e2e',
186
+ username: 'queue-question-tester',
187
+ })
188
+
189
+ afterEach(() => {
190
+ setDeterministicTranscription(null)
191
+ })
192
+
193
+ test('voice message during pending question dismisses question and transcribes normally', async () => {
194
+ // This is the exact bug scenario: user sends a voice message while a
195
+ // question dropdown is pending. Voice messages have empty message.content
196
+ // (audio is in attachments, transcription happens later). The old code
197
+ // passed "" as the question answer and consumed the message — the voice
198
+ // content was completely lost.
199
+ await ctx.discord.channel(VOICE_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
200
+ content: 'QUESTION_TEXT_ANSWER_MARKER',
201
+ })
202
+
203
+ const thread = await ctx.discord.channel(VOICE_CHANNEL_ID).waitForThread({
204
+ timeout: 8_000,
205
+ predicate: (t) => {
206
+ return t.name === 'QUESTION_TEXT_ANSWER_MARKER'
207
+ },
208
+ })
209
+
210
+ const th = ctx.discord.thread(thread.id)
211
+
212
+ // Wait for the question dropdown message to appear in Discord
213
+ await waitForBotMessageContaining({
214
+ discord: ctx.discord,
215
+ threadId: thread.id,
216
+ text: 'Which option do you prefer?',
217
+ timeout: 12_000,
218
+ })
219
+
220
+ // Send a voice message while the question is pending.
221
+ // Reproduction: Discord voice messages can still carry non-empty
222
+ // message.content. The bug consumed that raw text before transcription,
223
+ // so the session never received the spoken content.
224
+ setDeterministicTranscription({
225
+ transcription: 'I want option Alpha please',
226
+ queueMessage: false,
227
+ })
228
+
229
+ await th.user(TEST_USER_ID).sendVoiceMessage({
230
+ content: 'VOICE_TEXT_CONTENT_SHOULD_NOT_REACH_MODEL',
231
+ })
232
+
233
+ // Give time for question cleanup to propagate
234
+ await new Promise((r) => {
235
+ setTimeout(r, 1_000)
236
+ })
237
+
238
+ // Voice content should be transcribed and appear as the next user message,
239
+ // processed after the model responds to the empty question answer.
240
+ await waitForBotMessageContaining({
241
+ discord: ctx.discord,
242
+ threadId: thread.id,
243
+ text: 'I want option Alpha please',
244
+ timeout: 8_000,
245
+ })
246
+
247
+ await waitForFooterMessage({
248
+ discord: ctx.discord,
249
+ threadId: thread.id,
250
+ timeout: 8_000,
251
+ afterMessageIncludes: 'I want option Alpha please',
252
+ afterAuthorId: ctx.discord.botUserId,
253
+ })
254
+
255
+ const sessionId = await getThreadSession(thread.id)
256
+ expect(sessionId).toBeTruthy()
257
+
258
+ const sessionMessages = await waitForSessionMessages({
259
+ projectDirectory: ctx.directories.projectDirectory,
260
+ sessionId: sessionId!,
261
+ timeoutMs: 8_000,
262
+ predicate: (messages) => {
263
+ const timeline = getSessionRoleTextTimeline(messages)
264
+ return timeline.some((entry) => {
265
+ return entry.text.includes('I want option Alpha please')
266
+ })
267
+ },
268
+ })
269
+
270
+ const sessionTimeline = getSessionRoleTextTimeline(sessionMessages)
271
+ const sessionSummary = getSessionMessageSummary(sessionMessages)
272
+
273
+ const latestUserText = sessionTimeline
274
+ .filter((entry) => {
275
+ return entry.role === 'user'
276
+ })
277
+ .at(-1)?.text
278
+ const assistantTexts = sessionTimeline.flatMap((entry) => {
279
+ if (entry.role === 'assistant') {
280
+ return [entry.text]
281
+ }
282
+ return []
283
+ })
284
+
285
+ expect(latestUserText).toContain('I want option Alpha please')
286
+ expect(latestUserText).not.toContain('VOICE_TEXT_CONTENT_SHOULD_NOT_REACH_MODEL')
287
+ expect(assistantTexts).toContain('ok')
288
+ expect(
289
+ sessionSummary.some((message) => {
290
+ return message.role === 'user'
291
+ && message.parts.some((part) => {
292
+ return part.type === 'text' && part.text.includes('I want option Alpha please')
293
+ })
294
+ }),
295
+ ).toBe(true)
296
+ expect(
297
+ sessionSummary.some((message) => {
298
+ return message.role === 'assistant'
299
+ && message.parts.some((part) => {
300
+ return part.type === 'text' && part.text === 'ok'
301
+ })
302
+ }),
303
+ ).toBe(true)
304
+
305
+ const timeline = await th.text({ showInteractions: true })
306
+ expect(timeline).toContain('QUESTION_TEXT_ANSWER_MARKER')
307
+ expect(timeline).toContain('Which option do you prefer?')
308
+ expect(timeline).toContain('VOICE_TEXT_CONTENT_SHOULD_NOT_REACH_MODEL')
309
+ expect(timeline).toContain('🎤 Transcribing voice message...')
310
+ expect(timeline).toContain('📝 **Transcribed message:** I want option Alpha please')
311
+ expect(timeline).toContain('⬥ ok')
312
+
313
+ // Voice content must be present as a real transcribed message, not lost
314
+ expect(timeline).toContain('I want option Alpha please')
315
+ }, 20_000)
316
+ })
@@ -0,0 +1,146 @@
1
+ // E2e test for typing indicator lifecycle during interruption flow.
2
+ // Split from queue-advanced-typing.e2e.test.ts for parallelization.
3
+
4
+ import { describe, test, expect } from 'vitest'
5
+ import {
6
+ setupQueueAdvancedSuite,
7
+ TEST_USER_ID,
8
+ } from './queue-advanced-e2e-setup.js'
9
+ import {
10
+ waitForBotMessageContaining,
11
+ waitForFooterMessage,
12
+ } from './test-utils.js'
13
+
14
+ const TEXT_CHANNEL_ID = '200000000000001008'
15
+
16
+ const e2eTest = describe
17
+
18
+ e2eTest('queue advanced: typing interrupt', () => {
19
+ const ctx = setupQueueAdvancedSuite({
20
+ channelId: TEXT_CHANNEL_ID,
21
+ channelName: 'qa-typing-interrupt-e2e',
22
+ dirName: 'qa-typing-interrupt-e2e',
23
+ username: 'queue-advanced-tester',
24
+ })
25
+
26
+ test(
27
+ 'interruption flow emits footer for final assistant reply and then stops typing',
28
+ async () => {
29
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
30
+ content: 'Reply with exactly: typing-stop-interrupt-setup',
31
+ })
32
+
33
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
34
+ timeout: 4_000,
35
+ predicate: (t) => {
36
+ return t.name === 'Reply with exactly: typing-stop-interrupt-setup'
37
+ },
38
+ })
39
+
40
+ const th = ctx.discord.thread(thread.id)
41
+
42
+ await waitForBotMessageContaining({
43
+ discord: ctx.discord,
44
+ threadId: thread.id,
45
+ userId: TEST_USER_ID,
46
+ text: '*project',
47
+ timeout: 4_000,
48
+ })
49
+
50
+ th.clearTypingEvents()
51
+
52
+ await th.user(TEST_USER_ID).sendMessage({
53
+ content: 'PLUGIN_TIMEOUT_SLEEP_MARKER',
54
+ })
55
+
56
+ await waitForBotMessageContaining({
57
+ discord: ctx.discord,
58
+ threadId: thread.id,
59
+ userId: TEST_USER_ID,
60
+ text: 'starting sleep 100',
61
+ afterUserMessageIncludes: 'PLUGIN_TIMEOUT_SLEEP_MARKER',
62
+ timeout: 4_000,
63
+ })
64
+
65
+ await th.user(TEST_USER_ID).sendMessage({
66
+ content: 'Reply with exactly: typing-stop-interrupt-final',
67
+ })
68
+
69
+ await waitForBotMessageContaining({
70
+ discord: ctx.discord,
71
+ threadId: thread.id,
72
+ userId: TEST_USER_ID,
73
+ text: 'ok',
74
+ afterUserMessageIncludes: 'typing-stop-interrupt-final',
75
+ timeout: 12_000,
76
+ })
77
+
78
+ const messages = await waitForFooterMessage({
79
+ discord: ctx.discord,
80
+ threadId: thread.id,
81
+ timeout: 12_000,
82
+ afterMessageIncludes: 'typing-stop-interrupt-final',
83
+ afterAuthorId: TEST_USER_ID,
84
+ })
85
+
86
+ const finalUserIndex = messages.findIndex((message) => {
87
+ return message.author.id === TEST_USER_ID
88
+ && message.content.includes('typing-stop-interrupt-final')
89
+ })
90
+ const finalReplyIndex = messages.findIndex((message, index) => {
91
+ if (index <= finalUserIndex) {
92
+ return false
93
+ }
94
+ return message.author.id === ctx.discord.botUserId && message.content.includes('ok')
95
+ })
96
+ const finalFooterIndex = messages.findIndex((message, index) => {
97
+ if (index <= finalReplyIndex) {
98
+ return false
99
+ }
100
+ return message.author.id === ctx.discord.botUserId
101
+ && message.content.startsWith('*')
102
+ && message.content.includes('⋅')
103
+ })
104
+
105
+ expect(await th.text()).toMatchInlineSnapshot(`
106
+ "--- from: user (queue-advanced-tester)
107
+ Reply with exactly: typing-stop-interrupt-setup
108
+ --- from: assistant (TestBot)
109
+ ⬥ ok
110
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
111
+ --- from: user (queue-advanced-tester)
112
+ PLUGIN_TIMEOUT_SLEEP_MARKER
113
+ --- from: assistant (TestBot)
114
+ ⬥ starting sleep 100
115
+ --- from: user (queue-advanced-tester)
116
+ Reply with exactly: typing-stop-interrupt-final
117
+ --- from: assistant (TestBot)
118
+ ⬥ ok
119
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
120
+ `)
121
+
122
+ const timeline = await th.text({ showTyping: true })
123
+ expect(finalUserIndex).toBeGreaterThanOrEqual(0)
124
+ expect(finalReplyIndex).toBeGreaterThan(finalUserIndex)
125
+ expect(finalFooterIndex).toBeGreaterThan(finalReplyIndex)
126
+ expect(messages[finalFooterIndex]).toBeDefined()
127
+
128
+ const finalPromptPosition = timeline.indexOf(
129
+ 'Reply with exactly: typing-stop-interrupt-final',
130
+ )
131
+ const finalReplyPosition = timeline.indexOf('--- from: assistant (TestBot)\n⬥ ok', finalPromptPosition)
132
+ const lastFooterPosition = timeline.lastIndexOf('*project ⋅')
133
+ expect(finalPromptPosition).toBeGreaterThanOrEqual(0)
134
+ expect(finalReplyPosition).toBeGreaterThan(finalPromptPosition)
135
+ expect(lastFooterPosition).toBeGreaterThanOrEqual(0)
136
+ const typingDuringFinalRun = timeline
137
+ .slice(finalPromptPosition, finalReplyPosition)
138
+ .match(/\[bot typing\]/g) || []
139
+ expect(typingDuringFinalRun.length).toBeGreaterThanOrEqual(2)
140
+ expect(timeline.slice(lastFooterPosition)).not.toContain('[bot typing]')
141
+
142
+ },
143
+ 12_000,
144
+ )
145
+
146
+ })
@@ -0,0 +1,199 @@
1
+ // E2e tests for typing indicator lifecycle in advanced queue scenarios.
2
+ // Split from thread-queue-advanced.e2e.test.ts for parallelization.
3
+
4
+ import { describe, test, expect } from 'vitest'
5
+ import {
6
+ setupQueueAdvancedSuite,
7
+ TEST_USER_ID,
8
+ } from './queue-advanced-e2e-setup.js'
9
+ import {
10
+ waitForBotMessageContaining,
11
+ waitForFooterMessage,
12
+ } from './test-utils.js'
13
+
14
+ const TEXT_CHANNEL_ID = '200000000000001002'
15
+
16
+ const e2eTest = describe
17
+
18
+ e2eTest('queue advanced: typing lifecycle', () => {
19
+ const ctx = setupQueueAdvancedSuite({
20
+ channelId: TEXT_CHANNEL_ID,
21
+ channelName: 'qa-typing-e2e',
22
+ dirName: 'qa-typing-e2e',
23
+ username: 'queue-advanced-tester',
24
+ })
25
+
26
+ test(
27
+ 'normal reply stops typing after footer',
28
+ async () => {
29
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
30
+ content: 'Reply with exactly: typing-stop-normal',
31
+ })
32
+
33
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
34
+ timeout: 4_000,
35
+ predicate: (t) => {
36
+ return t.name === 'Reply with exactly: typing-stop-normal'
37
+ },
38
+ })
39
+
40
+ const th = ctx.discord.thread(thread.id)
41
+
42
+ await th.waitForTypingEvent({ timeout: 1_000 }).catch(() => {
43
+ return undefined
44
+ })
45
+
46
+ await waitForBotMessageContaining({
47
+ discord: ctx.discord,
48
+ threadId: thread.id,
49
+ userId: TEST_USER_ID,
50
+ text: 'ok',
51
+ timeout: 4_000,
52
+ })
53
+
54
+ const messages = await waitForFooterMessage({
55
+ discord: ctx.discord,
56
+ threadId: thread.id,
57
+ timeout: 4_000,
58
+ afterMessageIncludes: 'ok',
59
+ afterAuthorId: ctx.discord.botUserId,
60
+ })
61
+
62
+ const replyIndex = messages.findIndex((message) => {
63
+ return message.author.id === ctx.discord.botUserId && message.content.includes('ok')
64
+ })
65
+ const footerIndex = messages.findIndex((message, index) => {
66
+ if (index <= replyIndex) {
67
+ return false
68
+ }
69
+ return message.author.id === ctx.discord.botUserId
70
+ && message.content.startsWith('*')
71
+ && message.content.includes('⋅')
72
+ })
73
+
74
+ const timeline = await th.text({ showTyping: true })
75
+ expect(timeline).toContain('Reply with exactly: typing-stop-normal')
76
+ expect(timeline).toContain('⬥ ok')
77
+ expect(timeline).toContain('*project ⋅ main ⋅')
78
+ const typingCount = (timeline.match(/\[bot typing\]/g) || []).length
79
+ expect(typingCount).toBeGreaterThanOrEqual(1)
80
+ expect(replyIndex).toBeGreaterThanOrEqual(0)
81
+ expect(footerIndex).toBeGreaterThan(replyIndex)
82
+ expect(messages[footerIndex]).toBeDefined()
83
+
84
+ const lastFooterPosition = timeline.lastIndexOf('*project ⋅')
85
+ expect(lastFooterPosition).toBeGreaterThanOrEqual(0)
86
+ expect(timeline.slice(lastFooterPosition)).not.toContain('[bot typing]')
87
+
88
+ },
89
+ 8_000,
90
+ )
91
+
92
+ test(
93
+ 'thread follow-up reply re-pulses typing after a visible assistant message while session stays busy',
94
+ async () => {
95
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
96
+ content: 'Reply with exactly: typing-thread-reply-setup',
97
+ })
98
+
99
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
100
+ timeout: 4_000,
101
+ predicate: (t) => {
102
+ return t.name === 'Reply with exactly: typing-thread-reply-setup'
103
+ },
104
+ })
105
+
106
+ const th = ctx.discord.thread(thread.id)
107
+
108
+ await waitForBotMessageContaining({
109
+ discord: ctx.discord,
110
+ threadId: thread.id,
111
+ userId: TEST_USER_ID,
112
+ text: 'ok',
113
+ timeout: 4_000,
114
+ })
115
+
116
+ await waitForFooterMessage({
117
+ discord: ctx.discord,
118
+ threadId: thread.id,
119
+ timeout: 4_000,
120
+ afterMessageIncludes: 'ok',
121
+ afterAuthorId: ctx.discord.botUserId,
122
+ })
123
+
124
+ th.clearTypingEvents()
125
+
126
+ await th.user(TEST_USER_ID).sendMessage({
127
+ content: 'TYPING_REPULSE_MARKER',
128
+ })
129
+
130
+ const messagesAfterFirstReply = await waitForBotMessageContaining({
131
+ discord: ctx.discord,
132
+ threadId: thread.id,
133
+ userId: TEST_USER_ID,
134
+ text: 'repulse-first',
135
+ afterUserMessageIncludes: 'TYPING_REPULSE_MARKER',
136
+ timeout: 4_000,
137
+ })
138
+
139
+ const markerUserIndex = messagesAfterFirstReply.findIndex((message) => {
140
+ return message.author.id === TEST_USER_ID
141
+ && message.content.includes('TYPING_REPULSE_MARKER')
142
+ })
143
+ const firstReply = messagesAfterFirstReply.find((message, index) => {
144
+ if (index <= markerUserIndex) {
145
+ return false
146
+ }
147
+ return message.author.id === ctx.discord.botUserId
148
+ && message.content.includes('repulse-first')
149
+ })
150
+ if (!firstReply) {
151
+ throw new Error('Expected first bot reply after TYPING_REPULSE_MARKER')
152
+ }
153
+
154
+ const typingAfterVisibleReply = await th.waitForTypingEvent({
155
+ timeout: 700,
156
+ afterTimestamp: new Date(firstReply.timestamp).getTime(),
157
+ }).then(
158
+ () => {
159
+ return true
160
+ },
161
+ () => {
162
+ return false
163
+ },
164
+ )
165
+
166
+ const messages = await waitForFooterMessage({
167
+ discord: ctx.discord,
168
+ threadId: thread.id,
169
+ timeout: 6_000,
170
+ afterMessageIncludes: 'TYPING_REPULSE_MARKER',
171
+ afterAuthorId: TEST_USER_ID,
172
+ })
173
+
174
+ const timeline = await th.text({ showTyping: true })
175
+ expect(timeline).toContain('TYPING_REPULSE_MARKER')
176
+ expect(timeline).toContain('⬥ repulse-first')
177
+ const typingCount = (timeline.match(/\[bot typing\]/g) || []).length
178
+ expect(typingCount).toBeGreaterThanOrEqual(2)
179
+
180
+ const followupUserIndex = messages.findIndex((message) => {
181
+ return message.author.id === TEST_USER_ID
182
+ && message.content.includes('TYPING_REPULSE_MARKER')
183
+ })
184
+ const followupReplyIndex = messages.findIndex((message, index) => {
185
+ if (index <= followupUserIndex) {
186
+ return false
187
+ }
188
+ return message.author.id === ctx.discord.botUserId
189
+ && message.content.includes('repulse-first')
190
+ })
191
+
192
+ expect(followupUserIndex).toBeGreaterThanOrEqual(0)
193
+ expect(followupReplyIndex).toBeGreaterThan(followupUserIndex)
194
+ expect(typingAfterVisibleReply).toBe(true)
195
+ },
196
+ 10_000,
197
+ )
198
+
199
+ })