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