@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,976 @@
1
+ // E2e test for agent model resolution in new threads.
2
+ // Reproduces a bug where /agent channel preference is ignored by the
3
+ // promptAsync path: submitViaOpencodeQueue only passes input.agent/input.model
4
+ // (undefined for normal Discord messages) instead of resolving channel agent
5
+ // preferences from DB like dispatchPrompt does.
6
+ //
7
+ // The test sets a channel agent with a custom model, sends a message,
8
+ // and verifies the footer contains the agent's model — not the default.
9
+ //
10
+ // Uses opencode-deterministic-provider (no real LLM calls).
11
+ // Poll timeouts: 4s max, 100ms interval.
12
+
13
+ import fs from 'node:fs'
14
+
15
+ import path from 'node:path'
16
+ import url from 'node:url'
17
+ import {
18
+ describe,
19
+ beforeAll,
20
+ afterAll,
21
+ test,
22
+ expect,
23
+ } from 'vitest'
24
+ import { ChannelType, Client, GatewayIntentBits, Partials, REST, Routes, SlashCommandBuilder } from 'discord.js'
25
+ import { DigitalDiscord } from 'discord-digital-twin/src'
26
+ import {
27
+ buildDeterministicOpencodeConfig,
28
+ type DeterministicMatcher,
29
+ } from 'opencode-deterministic-provider'
30
+ import { setDataDir } from './config.js'
31
+ import { store } from './store.js'
32
+ import { startDiscordBot } from './discord-bot.js'
33
+ import {
34
+ setBotToken,
35
+ initDatabase,
36
+ closeDatabase,
37
+ setChannelDirectory,
38
+ setChannelVerbosity,
39
+ setChannelAgent,
40
+ setChannelModel,
41
+ type VerbosityLevel,
42
+ } from './database.js'
43
+ import { getPrisma } from './db.js'
44
+ import { startHranaServer, stopHranaServer } from './hrana-server.js'
45
+ import { initializeOpencodeForDirectory, stopOpencodeServer } from './opencode.js'
46
+ import {
47
+ chooseLockPort,
48
+ cleanupTestSessions,
49
+ initTestGitRepo,
50
+ waitForBotMessageContaining,
51
+ waitForFooterMessage,
52
+ } from './test-utils.js'
53
+ import { buildQuickAgentCommandDescription } from './commands/agent.js'
54
+
55
+
56
+ const TEST_USER_ID = '200000000000000920'
57
+ const TEXT_CHANNEL_ID = '200000000000000921'
58
+ const AGENT_MODEL = 'agent-model-v2'
59
+ const PLAN_AGENT_MODEL = 'plan-model-v2'
60
+ const CHANNEL_MODEL = 'channel-model-v2'
61
+ const DEFAULT_MODEL = 'deterministic-v2'
62
+ const PROVIDER_NAME = 'deterministic-provider'
63
+
64
+ function createRunDirectories() {
65
+ const root = path.resolve(process.cwd(), 'tmp', 'agent-model-e2e')
66
+ fs.mkdirSync(root, { recursive: true })
67
+ const dataDir = fs.mkdtempSync(path.join(root, 'data-'))
68
+ const projectDirectory = path.join(root, 'project')
69
+ fs.mkdirSync(projectDirectory, { recursive: true })
70
+ initTestGitRepo(projectDirectory)
71
+ return { root, dataDir, projectDirectory }
72
+ }
73
+
74
+
75
+
76
+ function createDiscordJsClient({ restUrl }: { restUrl: string }) {
77
+ return new Client({
78
+ intents: [
79
+ GatewayIntentBits.Guilds,
80
+ GatewayIntentBits.GuildMessages,
81
+ GatewayIntentBits.MessageContent,
82
+ GatewayIntentBits.GuildVoiceStates,
83
+ ],
84
+ partials: [
85
+ Partials.Channel,
86
+ Partials.Message,
87
+ Partials.User,
88
+ Partials.ThreadMember,
89
+ ],
90
+ rest: {
91
+ api: restUrl,
92
+ version: '10',
93
+ },
94
+ })
95
+ }
96
+
97
+ function createDeterministicMatchers(): DeterministicMatcher[] {
98
+ const systemContextMatcher: DeterministicMatcher = {
99
+ id: 'system-context-check',
100
+ priority: 20,
101
+ when: {
102
+ lastMessageRole: 'user',
103
+ latestUserTextIncludes: 'Reply with exactly: system-context-check',
104
+ promptTextIncludes: `<discord-user name="agent-model-tester" user-id="${TEST_USER_ID}"`,
105
+ },
106
+ then: {
107
+ parts: [
108
+ { type: 'stream-start', warnings: [] },
109
+ { type: 'text-start', id: 'system-context-reply' },
110
+ {
111
+ type: 'text-delta',
112
+ id: 'system-context-reply',
113
+ delta: 'system-context-ok',
114
+ },
115
+ { type: 'text-end', id: 'system-context-reply' },
116
+ {
117
+ type: 'finish',
118
+ finishReason: 'stop',
119
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
120
+ },
121
+ ],
122
+ partDelaysMs: [0, 100, 0, 0, 0],
123
+ },
124
+ }
125
+
126
+ const replyContextMatcher: DeterministicMatcher = {
127
+ id: 'reply-context-check',
128
+ priority: 15,
129
+ when: {
130
+ lastMessageRole: 'user',
131
+ latestUserTextIncludes: 'Reply with exactly: reply-context-check',
132
+ promptTextIncludes:
133
+ 'This message was a reply to message\n\n<replied-message author="agent-model-tester">\nfirst message in thread\n</replied-message>',
134
+ },
135
+ then: {
136
+ parts: [
137
+ { type: 'stream-start', warnings: [] },
138
+ { type: 'text-start', id: 'reply-context-reply' },
139
+ {
140
+ type: 'text-delta',
141
+ id: 'reply-context-reply',
142
+ delta: 'reply-context-ok',
143
+ },
144
+ { type: 'text-end', id: 'reply-context-reply' },
145
+ {
146
+ type: 'finish',
147
+ finishReason: 'stop',
148
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
149
+ },
150
+ ],
151
+ partDelaysMs: [0, 100, 0, 0, 0],
152
+ },
153
+ }
154
+
155
+ const userReplyMatcher: DeterministicMatcher = {
156
+ id: 'user-reply',
157
+ priority: 10,
158
+ when: {
159
+ lastMessageRole: 'user',
160
+ latestUserTextIncludes: 'Reply with exactly:',
161
+ },
162
+ then: {
163
+ parts: [
164
+ { type: 'stream-start', warnings: [] },
165
+ { type: 'text-start', id: 'default-reply' },
166
+ { type: 'text-delta', id: 'default-reply', delta: 'ok' },
167
+ { type: 'text-end', id: 'default-reply' },
168
+ {
169
+ type: 'finish',
170
+ finishReason: 'stop',
171
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
172
+ },
173
+ ],
174
+ partDelaysMs: [0, 100, 0, 0, 0],
175
+ },
176
+ }
177
+
178
+ return [systemContextMatcher, replyContextMatcher, userReplyMatcher]
179
+ }
180
+
181
+ /**
182
+ * Create an opencode agent .md file that uses a specific model.
183
+ * OpenCode discovers agents from .opencode/agent/*.md files.
184
+ */
185
+ function createAgentFile({
186
+ projectDirectory,
187
+ agentName,
188
+ model,
189
+ }: {
190
+ projectDirectory: string
191
+ agentName: string
192
+ model: string
193
+ }) {
194
+ const agentDir = path.join(projectDirectory, '.opencode', 'agent')
195
+ fs.mkdirSync(agentDir, { recursive: true })
196
+ const content = [
197
+ '---',
198
+ `model: ${model}`,
199
+ 'mode: primary',
200
+ `description: Test agent with custom model`,
201
+ '---',
202
+ '',
203
+ 'You are a test agent. Reply concisely.',
204
+ '',
205
+ ].join('\n')
206
+ fs.writeFileSync(path.join(agentDir, `${agentName}.md`), content)
207
+ }
208
+
209
+ describe('agent model resolution', () => {
210
+ let directories: ReturnType<typeof createRunDirectories>
211
+ let discord: DigitalDiscord
212
+ let botClient: Client
213
+ let previousDefaultVerbosity: VerbosityLevel | null = null
214
+ let testStartTime = Date.now()
215
+
216
+ beforeAll(async () => {
217
+ testStartTime = Date.now()
218
+ directories = createRunDirectories()
219
+ const lockPort = chooseLockPort({ key: TEXT_CHANNEL_ID })
220
+
221
+ process.env['KIMAKI_LOCK_PORT'] = String(lockPort)
222
+ setDataDir(directories.dataDir)
223
+ previousDefaultVerbosity = store.getState().defaultVerbosity
224
+ store.setState({ defaultVerbosity: 'tools_and_text' })
225
+
226
+ const digitalDiscordDbPath = path.join(
227
+ directories.dataDir,
228
+ 'digital-discord.db',
229
+ )
230
+
231
+ discord = new DigitalDiscord({
232
+ guild: {
233
+ name: 'Agent Model E2E Guild',
234
+ ownerId: TEST_USER_ID,
235
+ },
236
+ channels: [
237
+ {
238
+ id: TEXT_CHANNEL_ID,
239
+ name: 'agent-model-e2e',
240
+ type: ChannelType.GuildText,
241
+ },
242
+ ],
243
+ users: [
244
+ {
245
+ id: TEST_USER_ID,
246
+ username: 'agent-model-tester',
247
+ },
248
+ ],
249
+ dbUrl: `file:${digitalDiscordDbPath}`,
250
+ })
251
+
252
+ await discord.start()
253
+
254
+ const providerNpm = url
255
+ .pathToFileURL(
256
+ path.resolve(
257
+ process.cwd(),
258
+ '..',
259
+ 'opencode-deterministic-provider',
260
+ 'src',
261
+ 'index.ts',
262
+ ),
263
+ )
264
+ .toString()
265
+
266
+ // Build base config with default model
267
+ const opencodeConfig = buildDeterministicOpencodeConfig({
268
+ providerName: PROVIDER_NAME,
269
+ providerNpm,
270
+ model: DEFAULT_MODEL,
271
+ smallModel: DEFAULT_MODEL,
272
+ settings: {
273
+ strict: false,
274
+ matchers: createDeterministicMatchers(),
275
+ },
276
+ })
277
+
278
+ // Add extra models to the provider so opencode accepts them
279
+ const providerConfig = opencodeConfig.provider[PROVIDER_NAME] as {
280
+ models: Record<string, { name: string }>
281
+ }
282
+ providerConfig.models[AGENT_MODEL] = { name: AGENT_MODEL }
283
+ providerConfig.models[PLAN_AGENT_MODEL] = { name: PLAN_AGENT_MODEL }
284
+ providerConfig.models[CHANNEL_MODEL] = { name: CHANNEL_MODEL }
285
+
286
+ fs.writeFileSync(
287
+ path.join(directories.projectDirectory, 'opencode.json'),
288
+ JSON.stringify(opencodeConfig, null, 2),
289
+ )
290
+
291
+ // Create agent .md files with custom models
292
+ createAgentFile({
293
+ projectDirectory: directories.projectDirectory,
294
+ agentName: 'test-agent',
295
+ model: `${PROVIDER_NAME}/${AGENT_MODEL}`,
296
+ })
297
+ createAgentFile({
298
+ projectDirectory: directories.projectDirectory,
299
+ agentName: 'plan',
300
+ model: `${PROVIDER_NAME}/${PLAN_AGENT_MODEL}`,
301
+ })
302
+
303
+ const dbPath = path.join(directories.dataDir, 'discord-sessions.db')
304
+ const hranaResult = await startHranaServer({ dbPath })
305
+ if (hranaResult instanceof Error) {
306
+ throw hranaResult
307
+ }
308
+ process.env['KIMAKI_DB_URL'] = hranaResult
309
+ await initDatabase()
310
+ await setBotToken(discord.botUserId, discord.botToken)
311
+
312
+ await setChannelDirectory({
313
+ channelId: TEXT_CHANNEL_ID,
314
+ directory: directories.projectDirectory,
315
+ channelType: 'text',
316
+ })
317
+ await setChannelVerbosity(TEXT_CHANNEL_ID, 'tools_and_text')
318
+
319
+ botClient = createDiscordJsClient({ restUrl: discord.restUrl })
320
+ await startDiscordBot({
321
+ token: discord.botToken,
322
+ appId: discord.botUserId,
323
+ discordClient: botClient,
324
+ })
325
+
326
+ // Register quick agent slash commands so /plan-agent and /test-agent-agent
327
+ // are resolvable by handleQuickAgentCommand via guild.commands.fetch().
328
+ const agentCommands = ['test-agent', 'plan'].map((agentName) => {
329
+ return new SlashCommandBuilder()
330
+ .setName(`${agentName}-agent`)
331
+ .setDescription(
332
+ buildQuickAgentCommandDescription({
333
+ agentName,
334
+ description: `Switch to ${agentName} agent`,
335
+ }),
336
+ )
337
+ .setDMPermission(false)
338
+ .toJSON()
339
+ })
340
+ const rest = new REST({ version: '10', api: discord.restUrl }).setToken(
341
+ discord.botToken,
342
+ )
343
+ await rest.put(
344
+ Routes.applicationGuildCommands(discord.botUserId, discord.guildId),
345
+ { body: agentCommands },
346
+ )
347
+
348
+ // Pre-warm the opencode server so agent discovery happens
349
+ const warmup = await initializeOpencodeForDirectory(
350
+ directories.projectDirectory,
351
+ )
352
+ if (warmup instanceof Error) {
353
+ throw warmup
354
+ }
355
+ }, 60_000)
356
+
357
+ afterAll(async () => {
358
+ if (directories) {
359
+ await cleanupTestSessions({
360
+ projectDirectory: directories.projectDirectory,
361
+ testStartTime,
362
+ })
363
+ }
364
+ if (botClient) {
365
+ botClient.destroy()
366
+ }
367
+ await stopOpencodeServer()
368
+ await Promise.all([
369
+ closeDatabase().catch(() => {
370
+ return
371
+ }),
372
+ stopHranaServer().catch(() => {
373
+ return
374
+ }),
375
+ discord?.stop().catch(() => {
376
+ return
377
+ }),
378
+ ])
379
+ delete process.env['KIMAKI_LOCK_PORT']
380
+ delete process.env['KIMAKI_DB_URL']
381
+ if (previousDefaultVerbosity) {
382
+ store.setState({ defaultVerbosity: previousDefaultVerbosity })
383
+ }
384
+ if (directories) {
385
+ fs.rmSync(directories.dataDir, { recursive: true, force: true })
386
+ }
387
+ }, 10_000)
388
+
389
+ test(
390
+ 'new thread uses agent model when channel agent is set',
391
+ async () => {
392
+ // Set channel agent preference — this simulates /agent selecting test-agent
393
+ await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent')
394
+
395
+ // Send a message to create a new thread
396
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
397
+ content: 'Reply with exactly: agent-model-check',
398
+ })
399
+
400
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
401
+ timeout: 4_000,
402
+ predicate: (t) => {
403
+ return t.name === 'Reply with exactly: agent-model-check'
404
+ },
405
+ })
406
+
407
+ // Wait for the footer (starts with *project) — proves run completed.
408
+ // Then assert which model ID appears in it.
409
+ await waitForBotMessageContaining({
410
+ discord,
411
+ threadId: thread.id,
412
+ userId: TEST_USER_ID,
413
+ text: '*project',
414
+ timeout: 4_000,
415
+ })
416
+
417
+ const messages = await discord.thread(thread.id).getMessages()
418
+
419
+ // Find the footer message (starts with * italic)
420
+ const footerMessage = messages.find((message) => {
421
+ return (
422
+ message.author.id === discord.botUserId &&
423
+ message.content.startsWith('*')
424
+ )
425
+ })
426
+
427
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
428
+ "--- from: user (agent-model-tester)
429
+ Reply with exactly: agent-model-check
430
+ --- from: assistant (TestBot)
431
+ ⬥ ok
432
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
433
+ `)
434
+ expect(footerMessage).toBeDefined()
435
+ if (!footerMessage) {
436
+ throw new Error(
437
+ `Expected footer message but none found. Bot messages: ${messages
438
+ .filter((m) => m.author.id === discord.botUserId)
439
+ .map((m) => m.content.slice(0, 150))
440
+ .join(' | ')}`,
441
+ )
442
+ }
443
+
444
+ // The footer should contain the agent's model, not the default
445
+ expect(footerMessage.content).toContain(AGENT_MODEL)
446
+ expect(footerMessage.content).not.toContain(DEFAULT_MODEL)
447
+ },
448
+ 15_000,
449
+ )
450
+
451
+ test(
452
+ 'promptAsync path includes rich system context',
453
+ async () => {
454
+ await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent')
455
+
456
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
457
+ content: 'Reply with exactly: system-context-check',
458
+ })
459
+
460
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
461
+ timeout: 4_000,
462
+ predicate: (t) => {
463
+ return t.name === 'Reply with exactly: system-context-check'
464
+ },
465
+ })
466
+
467
+ await waitForBotMessageContaining({
468
+ discord,
469
+ threadId: thread.id,
470
+ userId: TEST_USER_ID,
471
+ text: 'system-context-ok',
472
+ timeout: 4_000,
473
+ })
474
+
475
+ await waitForFooterMessage({
476
+ discord,
477
+ threadId: thread.id,
478
+ timeout: 4_000,
479
+ afterMessageIncludes: 'system-context-ok',
480
+ afterAuthorId: discord.botUserId,
481
+ })
482
+
483
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
484
+ "--- from: user (agent-model-tester)
485
+ Reply with exactly: system-context-check
486
+ --- from: assistant (TestBot)
487
+ ⬥ system-context-ok
488
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
489
+ `)
490
+ },
491
+ 15_000,
492
+ )
493
+
494
+ test(
495
+ 'reply message injects replied-message context',
496
+ async () => {
497
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
498
+ content: 'first message in thread',
499
+ })
500
+
501
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
502
+ timeout: 4_000,
503
+ predicate: (t) => {
504
+ return t.name === 'first message in thread'
505
+ },
506
+ })
507
+
508
+ const threadMessagesBeforeReply = await discord.thread(thread.id).getMessages()
509
+ const firstUserMessage = threadMessagesBeforeReply.find((message) => {
510
+ return (
511
+ message.author.id === TEST_USER_ID
512
+ && message.content === 'first message in thread'
513
+ )
514
+ })
515
+ expect(firstUserMessage).toBeDefined()
516
+ if (!firstUserMessage) {
517
+ throw new Error('Expected first user message in thread')
518
+ }
519
+
520
+ await discord.thread(thread.id).user(TEST_USER_ID).sendMessage({
521
+ content: 'Reply with exactly: reply-context-check',
522
+ messageReference: {
523
+ message_id: firstUserMessage.id,
524
+ channel_id: thread.id,
525
+ guild_id: discord.guildId,
526
+ },
527
+ })
528
+
529
+ await waitForBotMessageContaining({
530
+ discord,
531
+ threadId: thread.id,
532
+ userId: TEST_USER_ID,
533
+ text: 'reply-context-ok',
534
+ timeout: 4_000,
535
+ })
536
+
537
+ await waitForFooterMessage({
538
+ discord,
539
+ threadId: thread.id,
540
+ timeout: 4_000,
541
+ afterMessageIncludes: 'reply-context-ok',
542
+ afterAuthorId: discord.botUserId,
543
+ })
544
+
545
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
546
+ "--- from: user (agent-model-tester)
547
+ first message in thread
548
+ Reply with exactly: reply-context-check
549
+ --- from: assistant (TestBot)
550
+ ⬥ ok
551
+ ⬥ reply-context-ok
552
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
553
+ `)
554
+ },
555
+ 15_000,
556
+ )
557
+
558
+ test(
559
+ 'new thread uses channel model when channel model preference is set',
560
+ async () => {
561
+ // Clear channel agent so model resolution falls through to channel model
562
+ const prisma = await getPrisma()
563
+ await prisma.channel_agents.deleteMany({
564
+ where: { channel_id: TEXT_CHANNEL_ID },
565
+ })
566
+
567
+ // Set channel model preference — simulates /model selecting a model at channel scope
568
+ await setChannelModel({
569
+ channelId: TEXT_CHANNEL_ID,
570
+ modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
571
+ })
572
+
573
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
574
+ content: 'Reply with exactly: channel-model-check',
575
+ })
576
+
577
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
578
+ timeout: 4_000,
579
+ predicate: (t) => {
580
+ return t.name === 'Reply with exactly: channel-model-check'
581
+ },
582
+ })
583
+
584
+ await waitForBotMessageContaining({
585
+ discord,
586
+ threadId: thread.id,
587
+ userId: TEST_USER_ID,
588
+ text: '*project',
589
+ timeout: 4_000,
590
+ })
591
+
592
+ const messages = await discord.thread(thread.id).getMessages()
593
+ const footerMessage = messages.find((message) => {
594
+ return (
595
+ message.author.id === discord.botUserId &&
596
+ message.content.startsWith('*')
597
+ )
598
+ })
599
+
600
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
601
+ "--- from: user (agent-model-tester)
602
+ Reply with exactly: channel-model-check
603
+ --- from: assistant (TestBot)
604
+ ⬥ ok
605
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2*"
606
+ `)
607
+ expect(footerMessage).toBeDefined()
608
+ if (!footerMessage) {
609
+ throw new Error(
610
+ `Expected footer message but none found. Bot messages: ${messages
611
+ .filter((m) => m.author.id === discord.botUserId)
612
+ .map((m) => m.content.slice(0, 150))
613
+ .join(' | ')}`,
614
+ )
615
+ }
616
+
617
+ // Footer should contain the channel model, not the default
618
+ expect(footerMessage.content).toContain(CHANNEL_MODEL)
619
+ expect(footerMessage.content).not.toContain(DEFAULT_MODEL)
620
+ },
621
+ 15_000,
622
+ )
623
+
624
+ test(
625
+ 'channel model with variant preference completes without error',
626
+ async () => {
627
+ // Clear channel agent so model resolution falls through to channel model
628
+ const prisma = await getPrisma()
629
+ await prisma.channel_agents.deleteMany({
630
+ where: { channel_id: TEXT_CHANNEL_ID },
631
+ })
632
+
633
+ // Set channel model with a variant (thinking level)
634
+ // The deterministic provider doesn't support thinking, so the variant
635
+ // is resolved but silently dropped (no matching thinking values).
636
+ // This test verifies the variant cascade code path runs without crashing
637
+ // and the correct model still appears in the footer.
638
+ await setChannelModel({
639
+ channelId: TEXT_CHANNEL_ID,
640
+ modelId: `${PROVIDER_NAME}/${CHANNEL_MODEL}`,
641
+ variant: 'high',
642
+ })
643
+
644
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
645
+ content: 'Reply with exactly: variant-check',
646
+ })
647
+
648
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
649
+ timeout: 4_000,
650
+ predicate: (t) => {
651
+ return t.name === 'Reply with exactly: variant-check'
652
+ },
653
+ })
654
+
655
+ await waitForBotMessageContaining({
656
+ discord,
657
+ threadId: thread.id,
658
+ userId: TEST_USER_ID,
659
+ text: '*project',
660
+ timeout: 4_000,
661
+ })
662
+
663
+ const messages = await discord.thread(thread.id).getMessages()
664
+ const footerMessage = messages.find((message) => {
665
+ return (
666
+ message.author.id === discord.botUserId &&
667
+ message.content.startsWith('*')
668
+ )
669
+ })
670
+
671
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
672
+ "--- from: user (agent-model-tester)
673
+ Reply with exactly: variant-check
674
+ --- from: assistant (TestBot)
675
+ ⬥ ok
676
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ channel-model-v2*"
677
+ `)
678
+ expect(footerMessage).toBeDefined()
679
+ if (!footerMessage) {
680
+ throw new Error(
681
+ `Expected footer message but none found. Bot messages: ${messages
682
+ .filter((m) => m.author.id === discord.botUserId)
683
+ .map((m) => m.content.slice(0, 150))
684
+ .join(' | ')}`,
685
+ )
686
+ }
687
+
688
+ // Footer should still contain the channel model (variant doesn't crash)
689
+ expect(footerMessage.content).toContain(CHANNEL_MODEL)
690
+ expect(footerMessage.content).not.toContain(DEFAULT_MODEL)
691
+ },
692
+ 15_000,
693
+ )
694
+
695
+ test(
696
+ 'changing channel agent via /plan-agent does not affect existing thread model',
697
+ async () => {
698
+ // 1. Set channel agent to test-agent (uses AGENT_MODEL)
699
+ await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent')
700
+
701
+ // 2. Send a message to create a thread
702
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
703
+ content: 'Reply with exactly: first-thread-msg',
704
+ })
705
+
706
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
707
+ timeout: 4_000,
708
+ predicate: (t) => {
709
+ return t.name === 'Reply with exactly: first-thread-msg'
710
+ },
711
+ })
712
+
713
+ // Wait for footer — proves first run completed with test-agent's model
714
+ await waitForFooterMessage({
715
+ discord,
716
+ threadId: thread.id,
717
+ timeout: 4_000,
718
+ afterMessageIncludes: 'ok',
719
+ afterAuthorId: discord.botUserId,
720
+ })
721
+
722
+ const firstMessages = await discord.thread(thread.id).getMessages()
723
+ const firstFooter = firstMessages.find((m) => {
724
+ return (
725
+ m.author.id === discord.botUserId && m.content.startsWith('*')
726
+ )
727
+ })
728
+ expect(firstFooter).toBeDefined()
729
+ // Verify the first run used test-agent's model
730
+ expect(firstFooter!.content).toContain(AGENT_MODEL)
731
+
732
+ // 3. Switch channel agent to plan via /plan-agent in the CHANNEL
733
+ const { id: interactionId } = await discord
734
+ .channel(TEXT_CHANNEL_ID)
735
+ .user(TEST_USER_ID)
736
+ .runSlashCommand({ name: 'plan-agent' })
737
+
738
+ await discord
739
+ .channel(TEXT_CHANNEL_ID)
740
+ .waitForInteractionAck({ interactionId, timeout: 4_000 })
741
+
742
+ // 4. Send a second message in the EXISTING thread
743
+ const th = discord.thread(thread.id)
744
+ await th.user(TEST_USER_ID).sendMessage({
745
+ content: 'Reply with exactly: second-thread-msg',
746
+ })
747
+
748
+ // Wait for second footer (anchor on the user message, not bot reply)
749
+ await waitForFooterMessage({
750
+ discord,
751
+ threadId: thread.id,
752
+ timeout: 4_000,
753
+ afterMessageIncludes: 'second-thread-msg',
754
+ afterAuthorId: TEST_USER_ID,
755
+ })
756
+
757
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
758
+ "--- from: user (agent-model-tester)
759
+ Reply with exactly: first-thread-msg
760
+ --- from: assistant (TestBot)
761
+ ⬥ ok
762
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
763
+ --- from: user (agent-model-tester)
764
+ Reply with exactly: second-thread-msg
765
+ --- from: assistant (TestBot)
766
+ ⬥ ok
767
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***"
768
+ `)
769
+
770
+ const secondMessages = await discord.thread(thread.id).getMessages()
771
+ const secondFooter = [...secondMessages]
772
+ .reverse()
773
+ .find((m) => {
774
+ return (
775
+ m.author.id === discord.botUserId && m.content.startsWith('*')
776
+ )
777
+ })
778
+ expect(secondFooter).toBeDefined()
779
+
780
+ // The existing thread should still use test-agent's model (AGENT_MODEL),
781
+ // NOT plan agent's model (PLAN_AGENT_MODEL)
782
+ expect(secondFooter!.content).toContain(AGENT_MODEL)
783
+ expect(secondFooter!.content).not.toContain(PLAN_AGENT_MODEL)
784
+ },
785
+ 20_000,
786
+ )
787
+
788
+ test(
789
+ 'thread created with no agent keeps default model after channel agent is set',
790
+ async () => {
791
+ // Clear any channel agent — thread starts with default (no agent)
792
+ const prisma = await getPrisma()
793
+ await prisma.channel_agents.deleteMany({
794
+ where: { channel_id: TEXT_CHANNEL_ID },
795
+ })
796
+ // Also clear channel model so we get the pure default
797
+ await prisma.channel_models.deleteMany({
798
+ where: { channel_id: TEXT_CHANNEL_ID },
799
+ })
800
+
801
+ // 1. Send a message to create a thread (no channel agent set)
802
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
803
+ content: 'Reply with exactly: default-thread-msg',
804
+ })
805
+
806
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
807
+ timeout: 4_000,
808
+ predicate: (t) => {
809
+ return t.name === 'Reply with exactly: default-thread-msg'
810
+ },
811
+ })
812
+
813
+ // Wait for footer — should show the default model
814
+ await waitForFooterMessage({
815
+ discord,
816
+ threadId: thread.id,
817
+ timeout: 4_000,
818
+ afterMessageIncludes: 'ok',
819
+ afterAuthorId: discord.botUserId,
820
+ })
821
+
822
+ const firstMessages = await discord.thread(thread.id).getMessages()
823
+ const firstFooter = firstMessages.find((m) => {
824
+ return (
825
+ m.author.id === discord.botUserId && m.content.startsWith('*')
826
+ )
827
+ })
828
+ expect(firstFooter).toBeDefined()
829
+ // First run uses the default model (no agent set)
830
+ expect(firstFooter!.content).toContain(DEFAULT_MODEL)
831
+ expect(firstFooter!.content).not.toContain(AGENT_MODEL)
832
+
833
+ // 2. Set channel agent to test-agent via /test-agent-agent in the CHANNEL
834
+ const { id: interactionId } = await discord
835
+ .channel(TEXT_CHANNEL_ID)
836
+ .user(TEST_USER_ID)
837
+ .runSlashCommand({ name: 'test-agent-agent' })
838
+
839
+ await discord
840
+ .channel(TEXT_CHANNEL_ID)
841
+ .waitForInteractionAck({ interactionId, timeout: 4_000 })
842
+
843
+ // 3. Send a second message in the EXISTING thread
844
+ await discord
845
+ .thread(thread.id)
846
+ .user(TEST_USER_ID)
847
+ .sendMessage({
848
+ content: 'Reply with exactly: default-second-msg',
849
+ })
850
+
851
+ await waitForFooterMessage({
852
+ discord,
853
+ threadId: thread.id,
854
+ timeout: 4_000,
855
+ afterMessageIncludes: 'default-second-msg',
856
+ afterAuthorId: TEST_USER_ID,
857
+ })
858
+
859
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
860
+ "--- from: user (agent-model-tester)
861
+ Reply with exactly: default-thread-msg
862
+ --- from: assistant (TestBot)
863
+ ⬥ ok
864
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
865
+ --- from: user (agent-model-tester)
866
+ Reply with exactly: default-second-msg
867
+ --- from: assistant (TestBot)
868
+ ⬥ ok
869
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
870
+ `)
871
+
872
+ const secondMessages = await discord.thread(thread.id).getMessages()
873
+ const secondFooter = [...secondMessages]
874
+ .reverse()
875
+ .find((m) => {
876
+ return (
877
+ m.author.id === discord.botUserId && m.content.startsWith('*')
878
+ )
879
+ })
880
+ expect(secondFooter).toBeDefined()
881
+
882
+ // The existing thread should still use the DEFAULT model,
883
+ // NOT the test-agent's model (AGENT_MODEL)
884
+ expect(secondFooter!.content).toContain(DEFAULT_MODEL)
885
+ expect(secondFooter!.content).not.toContain(AGENT_MODEL)
886
+ },
887
+ 20_000,
888
+ )
889
+
890
+ test(
891
+ '/plan-agent inside a thread switches the model for that thread',
892
+ async () => {
893
+ // 1. Start with test-agent on the channel
894
+ await setChannelAgent(TEXT_CHANNEL_ID, 'test-agent')
895
+
896
+ // 2. Create a thread — first run uses test-agent's model
897
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
898
+ content: 'Reply with exactly: switch-in-thread-msg',
899
+ })
900
+
901
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
902
+ timeout: 4_000,
903
+ predicate: (t) => {
904
+ return t.name === 'Reply with exactly: switch-in-thread-msg'
905
+ },
906
+ })
907
+
908
+ await waitForFooterMessage({
909
+ discord,
910
+ threadId: thread.id,
911
+ timeout: 4_000,
912
+ afterMessageIncludes: 'ok',
913
+ afterAuthorId: discord.botUserId,
914
+ })
915
+
916
+ const firstFooter = (await discord.thread(thread.id).getMessages()).find(
917
+ (m) => {
918
+ return (
919
+ m.author.id === discord.botUserId && m.content.startsWith('*')
920
+ )
921
+ },
922
+ )
923
+ expect(firstFooter).toBeDefined()
924
+ expect(firstFooter!.content).toContain(AGENT_MODEL)
925
+
926
+ // 3. Run /plan-agent INSIDE the thread
927
+ const th = discord.thread(thread.id)
928
+ const { id: interactionId } = await th
929
+ .user(TEST_USER_ID)
930
+ .runSlashCommand({ name: 'plan-agent' })
931
+
932
+ await th.waitForInteractionAck({ interactionId, timeout: 4_000 })
933
+
934
+ // 4. Send a second message in the same thread
935
+ await th.user(TEST_USER_ID).sendMessage({
936
+ content: 'Reply with exactly: after-switch-msg',
937
+ })
938
+
939
+ await waitForFooterMessage({
940
+ discord,
941
+ threadId: thread.id,
942
+ timeout: 4_000,
943
+ afterMessageIncludes: 'after-switch-msg',
944
+ afterAuthorId: TEST_USER_ID,
945
+ })
946
+
947
+ expect(await discord.thread(thread.id).text()).toMatchInlineSnapshot(`
948
+ "--- from: user (agent-model-tester)
949
+ Reply with exactly: switch-in-thread-msg
950
+ --- from: assistant (TestBot)
951
+ ⬥ ok
952
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ agent-model-v2 ⋅ **test-agent***
953
+ Switched to **plan** agent for this session next messages (was **test-agent**)
954
+ --- from: user (agent-model-tester)
955
+ Reply with exactly: after-switch-msg
956
+ --- from: assistant (TestBot)
957
+ ⬥ ok
958
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ plan-model-v2 ⋅ **plan***"
959
+ `)
960
+
961
+ const secondFooter = [...(await discord.thread(thread.id).getMessages())]
962
+ .reverse()
963
+ .find((m) => {
964
+ return (
965
+ m.author.id === discord.botUserId && m.content.startsWith('*')
966
+ )
967
+ })
968
+ expect(secondFooter).toBeDefined()
969
+
970
+ // After /plan-agent in the thread, model should switch to plan's model
971
+ expect(secondFooter!.content).toContain(PLAN_AGENT_MODEL)
972
+ expect(secondFooter!.content).not.toContain(AGENT_MODEL)
973
+ },
974
+ 20_000,
975
+ )
976
+ })