kimaki 0.26.0 → 0.28.0

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 (302) hide show
  1. package/dist/agent-model.e2e.test.js +250 -164
  2. package/dist/anthropic-account-identity.js +9 -1
  3. package/dist/anthropic-auth-plugin.js +10 -13
  4. package/dist/anthropic-auth-plugin.test.js +17 -0
  5. package/dist/anthropic-auth-state.js +1 -0
  6. package/dist/bash-tool-schema-plugin.js +32 -0
  7. package/dist/bash-tool-schema-plugin.test.js +49 -0
  8. package/dist/bin.js +73 -19
  9. package/dist/cli-commands/maintenance.js +46 -7
  10. package/dist/cli-commands/project.js +11 -27
  11. package/dist/cli-commands/send.js +30 -1
  12. package/dist/cli-commands/session.js +322 -90
  13. package/dist/cli-commands/task.js +34 -15
  14. package/dist/cli-parsing.test.js +83 -4
  15. package/dist/cli-runner.js +44 -8
  16. package/dist/cli-runner.test.js +27 -0
  17. package/dist/cli-send-thread.e2e.test.js +9 -9
  18. package/dist/cli.js +32 -0
  19. package/dist/commands/abort.js +5 -1
  20. package/dist/commands/action-buttons.js +2 -1
  21. package/dist/commands/add-project.js +4 -7
  22. package/dist/commands/agent.js +164 -32
  23. package/dist/commands/ask-question.js +23 -33
  24. package/dist/commands/ask-question.test.js +43 -1
  25. package/dist/commands/btw.js +28 -6
  26. package/dist/commands/file-upload.js +2 -1
  27. package/dist/commands/fork-subagent.js +2 -4
  28. package/dist/commands/fork.js +4 -4
  29. package/dist/commands/last-sessions.js +17 -10
  30. package/dist/commands/login.js +6 -0
  31. package/dist/commands/merge-worktree.js +47 -14
  32. package/dist/commands/model-variant.js +8 -1
  33. package/dist/commands/model.js +16 -9
  34. package/dist/commands/multioauth.js +1 -0
  35. package/dist/commands/new-worktree.js +170 -102
  36. package/dist/commands/queue.js +3 -2
  37. package/dist/commands/resume.js +4 -4
  38. package/dist/commands/session.js +78 -63
  39. package/dist/commands/user-command.js +6 -8
  40. package/dist/commands/worktrees.js +5 -13
  41. package/dist/config.js +16 -1
  42. package/dist/cpu-profiler.js +211 -0
  43. package/dist/cpu-profiler.test.js +160 -0
  44. package/dist/database.js +152 -3
  45. package/dist/db.js +33 -0
  46. package/dist/db.test.js +267 -1
  47. package/dist/default-channel-provisioning.test.js +148 -0
  48. package/dist/discord-bot.js +517 -488
  49. package/dist/discord-command-registration.js +25 -10
  50. package/dist/discord-command-registration.test.js +17 -0
  51. package/dist/discord-utils.js +97 -5
  52. package/dist/discord-utils.test.js +151 -1
  53. package/dist/errors.js +10 -0
  54. package/dist/exec-async.js +17 -5
  55. package/dist/external-directory-permissions.e2e.test.js +11 -7
  56. package/dist/external-directory-project-deny.e2e.test.js +5 -3
  57. package/dist/external-opencode-sync.js +94 -17
  58. package/dist/external-opencode-sync.test.js +206 -0
  59. package/dist/extract-sdk-error-message.test.js +13 -0
  60. package/dist/file-edit-log.js +197 -0
  61. package/dist/file-edit-log.test.js +258 -0
  62. package/dist/format-tables.js +52 -0
  63. package/dist/format-tables.test.js +93 -0
  64. package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
  65. package/dist/gateway-proxy.e2e.test.js +18 -18
  66. package/dist/git-worktree-core.js +182 -17
  67. package/dist/interaction-handler.js +416 -398
  68. package/dist/ipc-tools-plugin.js +68 -3
  69. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
  70. package/dist/kimaki-opencode-plugin.js +9 -3
  71. package/dist/kimaki-workspace-adaptor.js +33 -10
  72. package/dist/logger.js +11 -1
  73. package/dist/markdown.js +3 -1
  74. package/dist/markdown.test.js +11 -3
  75. package/dist/message-formatting.js +181 -51
  76. package/dist/message-formatting.test.js +529 -22
  77. package/dist/message-preprocessing.js +105 -1
  78. package/dist/oauth-rotation-shared.js +10 -0
  79. package/dist/openai-auth-plugin.js +1 -0
  80. package/dist/openai-auth-state.js +1 -0
  81. package/dist/opencode-command.js +28 -0
  82. package/dist/opencode-command.test.js +109 -1
  83. package/dist/opencode-interrupt-plugin.js +4 -0
  84. package/dist/opencode-interrupt-plugin.test.js +24 -0
  85. package/dist/opencode.js +82 -15
  86. package/dist/project-add.test.js +62 -0
  87. package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
  88. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +150 -0
  89. package/dist/question-text-before-dropdown.e2e.test.js +52 -0
  90. package/dist/queue-advanced-abort.e2e.test.js +8 -10
  91. package/dist/queue-advanced-action-buttons.e2e.test.js +8 -8
  92. package/dist/queue-advanced-e2e-setup.js +78 -5
  93. package/dist/queue-advanced-footer.e2e.test.js +60 -269
  94. package/dist/queue-advanced-model-switch.e2e.test.js +9 -11
  95. package/dist/queue-advanced-permissions-typing.e2e.test.js +15 -15
  96. package/dist/queue-advanced-question.e2e.test.js +32 -27
  97. package/dist/queue-advanced-typing-interrupt.e2e.test.js +10 -12
  98. package/dist/queue-advanced-typing.e2e.test.js +7 -9
  99. package/dist/queue-delete-message.e2e.test.js +3 -3
  100. package/dist/queue-drain-after-interactive-ui.e2e.test.js +5 -5
  101. package/dist/queue-interrupt-drain.e2e.test.js +122 -12
  102. package/dist/queue-question-select-drain.e2e.test.js +34 -132
  103. package/dist/runtime-lifecycle.e2e.test.js +21 -51
  104. package/dist/schema.js +35 -1
  105. package/dist/session-handler/event-stream-state.js +391 -50
  106. package/dist/session-handler/event-stream-state.test.js +736 -8
  107. package/dist/session-handler/model-utils.js +172 -5
  108. package/dist/session-handler/model-utils.test.js +445 -0
  109. package/dist/session-handler/opencode-session-event-log.js +30 -1
  110. package/dist/session-handler/thread-ingress.test.js +41 -0
  111. package/dist/session-handler/thread-runtime-state.js +2 -0
  112. package/dist/session-handler/thread-session-runtime-summary.test.js +121 -0
  113. package/dist/session-handler/thread-session-runtime.js +574 -299
  114. package/dist/session-search.js +123 -4
  115. package/dist/session-search.test.js +248 -1
  116. package/dist/session-sleep.test.js +224 -0
  117. package/dist/session-title-rename.test.js +37 -39
  118. package/dist/sleep.e2e.test.js +494 -0
  119. package/dist/startup-time.e2e.test.js +2 -1
  120. package/dist/store.js +3 -0
  121. package/dist/system-message.js +102 -16
  122. package/dist/system-message.test.js +192 -15
  123. package/dist/task-runner.js +96 -5
  124. package/dist/task-schedule.js +87 -1
  125. package/dist/task-schedule.test.js +149 -1
  126. package/dist/test-utils.js +17 -9
  127. package/dist/thinking-utils.js +10 -0
  128. package/dist/thinking-utils.test.js +35 -0
  129. package/dist/thread-message-queue.e2e.test.js +65 -213
  130. package/dist/undo-redo.e2e.test.js +17 -9
  131. package/dist/voice-attachment.js +23 -1
  132. package/dist/voice-handler.js +21 -4
  133. package/dist/voice-message.e2e.test.js +144 -30
  134. package/dist/voice-routing-input.e2e.test.js +195 -0
  135. package/dist/voice-routing-question.e2e.test.js +125 -0
  136. package/dist/voice.js +28 -8
  137. package/dist/voice.test.js +96 -30
  138. package/dist/worktree-clone-isolation.e2e.test.js +188 -0
  139. package/dist/worktree-lifecycle.e2e.test.js +55 -36
  140. package/dist/worktrees.js +226 -443
  141. package/dist/worktrees.test.js +435 -58
  142. package/dist/xai-auth-plugin.js +1 -0
  143. package/dist/xai-auth-state.js +1 -0
  144. package/package.json +8 -6
  145. package/skills/bump-holocron-consumers/SKILL.md +137 -0
  146. package/skills/critique/SKILL.md +4 -4
  147. package/skills/errore/SKILL.md +60 -218
  148. package/skills/goke/SKILL.md +150 -10
  149. package/skills/holocron/SKILL.md +154 -17
  150. package/skills/npm-package/SKILL.md +55 -35
  151. package/skills/profano/SKILL.md +17 -2
  152. package/skills/spiceflow/SKILL.md +39 -3
  153. package/skills/strada/SKILL.md +39 -0
  154. package/skills/zele/SKILL.md +19 -0
  155. package/src/agent-model.e2e.test.ts +287 -186
  156. package/src/anthropic-account-identity.ts +20 -1
  157. package/src/anthropic-auth-plugin.test.ts +19 -0
  158. package/src/anthropic-auth-plugin.ts +12 -16
  159. package/src/anthropic-auth-state.ts +1 -0
  160. package/src/bash-tool-schema-plugin.test.ts +55 -0
  161. package/src/bash-tool-schema-plugin.ts +46 -0
  162. package/src/bin.ts +78 -26
  163. package/src/cli-commands/maintenance.ts +59 -13
  164. package/src/cli-commands/project.ts +14 -35
  165. package/src/cli-commands/send.ts +31 -1
  166. package/src/cli-commands/session.ts +374 -114
  167. package/src/cli-commands/task.ts +34 -15
  168. package/src/cli-parsing.test.ts +103 -4
  169. package/src/cli-runner.test.ts +49 -0
  170. package/src/cli-runner.ts +44 -7
  171. package/src/cli-send-thread.e2e.test.ts +9 -11
  172. package/src/cli.ts +53 -0
  173. package/src/commands/abort.ts +6 -1
  174. package/src/commands/action-buttons.ts +2 -1
  175. package/src/commands/add-project.ts +4 -8
  176. package/src/commands/agent.ts +205 -29
  177. package/src/commands/ask-question.test.ts +48 -1
  178. package/src/commands/ask-question.ts +23 -33
  179. package/src/commands/btw.ts +40 -5
  180. package/src/commands/file-upload.ts +2 -1
  181. package/src/commands/fork-subagent.ts +2 -3
  182. package/src/commands/fork.ts +4 -3
  183. package/src/commands/last-sessions.ts +10 -4
  184. package/src/commands/login.ts +6 -0
  185. package/src/commands/merge-worktree.ts +54 -16
  186. package/src/commands/model-variant.ts +9 -1
  187. package/src/commands/model.ts +20 -9
  188. package/src/commands/multioauth.ts +1 -0
  189. package/src/commands/new-worktree.ts +138 -55
  190. package/src/commands/queue.ts +3 -2
  191. package/src/commands/resume.ts +4 -3
  192. package/src/commands/session.ts +94 -70
  193. package/src/commands/user-command.ts +6 -6
  194. package/src/commands/worktrees.ts +5 -12
  195. package/src/config.ts +18 -1
  196. package/src/cpu-profiler.test.ts +183 -0
  197. package/src/cpu-profiler.ts +293 -0
  198. package/src/database.ts +235 -3
  199. package/src/db.test.ts +293 -0
  200. package/src/db.ts +38 -0
  201. package/src/default-channel-provisioning.test.ts +155 -0
  202. package/src/discord-bot.ts +78 -36
  203. package/src/discord-command-registration.test.ts +20 -0
  204. package/src/discord-command-registration.ts +42 -12
  205. package/src/discord-utils.test.ts +176 -0
  206. package/src/discord-utils.ts +152 -7
  207. package/src/errors.ts +12 -0
  208. package/src/exec-async.ts +20 -11
  209. package/src/external-directory-permissions.e2e.test.ts +11 -7
  210. package/src/external-directory-project-deny.e2e.test.ts +5 -3
  211. package/src/external-opencode-sync.test.ts +248 -0
  212. package/src/external-opencode-sync.ts +122 -19
  213. package/src/extract-sdk-error-message.test.ts +18 -0
  214. package/src/file-edit-log.test.ts +287 -0
  215. package/src/file-edit-log.ts +283 -0
  216. package/src/format-tables.test.ts +101 -0
  217. package/src/format-tables.ts +75 -0
  218. package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
  219. package/src/gateway-proxy.e2e.test.ts +18 -17
  220. package/src/git-worktree-core.ts +247 -20
  221. package/src/interaction-handler.ts +36 -13
  222. package/src/ipc-tools-plugin.ts +82 -5
  223. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
  224. package/src/kimaki-opencode-plugin.ts +9 -3
  225. package/src/kimaki-workspace-adaptor.ts +40 -11
  226. package/src/logger.ts +14 -4
  227. package/src/markdown.test.ts +13 -3
  228. package/src/markdown.ts +4 -1
  229. package/src/message-formatting.test.ts +672 -25
  230. package/src/message-formatting.ts +242 -53
  231. package/src/message-preprocessing.ts +116 -1
  232. package/src/oauth-rotation-shared.ts +10 -0
  233. package/src/openai-auth-plugin.ts +1 -0
  234. package/src/openai-auth-state.ts +1 -0
  235. package/src/opencode-command.test.ts +140 -0
  236. package/src/opencode-command.ts +38 -0
  237. package/src/opencode-interrupt-plugin.test.ts +32 -0
  238. package/src/opencode-interrupt-plugin.ts +3 -0
  239. package/src/opencode.ts +115 -15
  240. package/src/project-add.test.ts +80 -0
  241. package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
  242. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +172 -0
  243. package/src/question-text-before-dropdown.e2e.test.ts +67 -0
  244. package/src/queue-advanced-abort.e2e.test.ts +8 -9
  245. package/src/queue-advanced-action-buttons.e2e.test.ts +8 -8
  246. package/src/queue-advanced-e2e-setup.ts +83 -4
  247. package/src/queue-advanced-footer.e2e.test.ts +67 -315
  248. package/src/queue-advanced-model-switch.e2e.test.ts +9 -10
  249. package/src/queue-advanced-permissions-typing.e2e.test.ts +15 -15
  250. package/src/queue-advanced-question.e2e.test.ts +36 -30
  251. package/src/queue-advanced-typing-interrupt.e2e.test.ts +11 -11
  252. package/src/queue-advanced-typing.e2e.test.ts +8 -8
  253. package/src/queue-delete-message.e2e.test.ts +3 -3
  254. package/src/queue-drain-after-interactive-ui.e2e.test.ts +5 -5
  255. package/src/queue-interrupt-drain.e2e.test.ts +133 -12
  256. package/src/queue-question-select-drain.e2e.test.ts +36 -161
  257. package/src/runtime-lifecycle.e2e.test.ts +21 -54
  258. package/src/schema.sql +15 -2
  259. package/src/schema.ts +37 -1
  260. package/src/session-handler/event-stream-state.test.ts +790 -7
  261. package/src/session-handler/event-stream-state.ts +568 -53
  262. package/src/session-handler/model-utils.test.ts +537 -0
  263. package/src/session-handler/model-utils.ts +281 -5
  264. package/src/session-handler/opencode-session-event-log.ts +31 -1
  265. package/src/session-handler/thread-ingress.test.ts +54 -0
  266. package/src/session-handler/thread-runtime-state.ts +3 -0
  267. package/src/session-handler/thread-session-runtime-summary.test.ts +169 -0
  268. package/src/session-handler/thread-session-runtime.ts +712 -358
  269. package/src/session-search.test.ts +281 -0
  270. package/src/session-search.ts +241 -7
  271. package/src/session-sleep.test.ts +294 -0
  272. package/src/session-title-rename.test.ts +54 -48
  273. package/src/sleep.e2e.test.ts +565 -0
  274. package/src/startup-time.e2e.test.ts +5 -1
  275. package/src/store.ts +22 -0
  276. package/src/system-message.test.ts +226 -15
  277. package/src/system-message.ts +130 -16
  278. package/src/task-runner.ts +138 -3
  279. package/src/task-schedule.test.ts +170 -0
  280. package/src/task-schedule.ts +141 -1
  281. package/src/test-utils.ts +21 -10
  282. package/src/thinking-utils.test.ts +46 -0
  283. package/src/thinking-utils.ts +21 -0
  284. package/src/thread-message-queue.e2e.test.ts +65 -245
  285. package/src/undo-redo.e2e.test.ts +17 -9
  286. package/src/voice-attachment.ts +29 -1
  287. package/src/voice-handler.ts +23 -4
  288. package/src/voice-message.e2e.test.ts +149 -29
  289. package/src/voice-routing-input.e2e.test.ts +203 -0
  290. package/src/voice-routing-question.e2e.test.ts +131 -0
  291. package/src/voice.test.ts +107 -29
  292. package/src/voice.ts +34 -6
  293. package/src/worktree-clone-isolation.e2e.test.ts +213 -0
  294. package/src/worktree-lifecycle.e2e.test.ts +55 -35
  295. package/src/worktrees.test.ts +505 -64
  296. package/src/worktrees.ts +257 -603
  297. package/src/xai-auth-plugin.ts +1 -0
  298. package/src/xai-auth-state.ts +1 -0
  299. package/dist/message-finish-field.e2e.test.js +0 -169
  300. package/dist/subagent-rate-limit-plugin.js +0 -184
  301. package/src/message-finish-field.e2e.test.ts +0 -196
  302. package/src/subagent-rate-limit-plugin.ts +0 -228
@@ -41,7 +41,7 @@ import {
41
41
  } from './discord-utils.js'
42
42
  import { requestAudioApiKey } from './commands/gemini-apikey.js'
43
43
  import { transcribeAudio, type TranscriptionResult } from './voice.js'
44
- import { DiscordOperationError, FetchError } from './errors.js'
44
+ import { DiscordOperationError, FetchError, EmptyTranscriptionError, TranscriptionError } from './errors.js'
45
45
  import { store } from './store.js'
46
46
  import {
47
47
  getVoiceAttachmentMatchReason,
@@ -486,6 +486,7 @@ type ProcessVoiceAttachmentArgs = {
486
486
  lastSessionContext?: string
487
487
  /** Available agents for voice-based agent selection. Passed to the transcription prompt as enum values. */
488
488
  agents?: Array<{ name: string; description?: string }>
489
+ canForkSession?: boolean
489
490
  }
490
491
 
491
492
  // Per-thread serialization is handled by ThreadSessionRuntime.enqueueIncoming()
@@ -499,7 +500,8 @@ export async function processVoiceAttachment({
499
500
  currentSessionContext,
500
501
  lastSessionContext,
501
502
  agents,
502
- }: ProcessVoiceAttachmentArgs): Promise<TranscriptionResult | null> {
503
+ canForkSession = false,
504
+ }: ProcessVoiceAttachmentArgs): Promise<TranscriptionResult | TranscriptionError | EmptyTranscriptionError | null> {
503
505
  const audioAttachment = Array.from(message.attachments.values()).find(
504
506
  (attachment) => isVoiceAttachment(attachment),
505
507
  )
@@ -544,6 +546,11 @@ export async function processVoiceAttachment({
544
546
  transcription: deterministicConfig.transcription,
545
547
  queueMessage: deterministicConfig.queueMessage,
546
548
  agent: deterministicConfig.agent,
549
+ sessionAction: deterministicConfig.sessionAction,
550
+ }
551
+ if (!result.transcription.trim()) {
552
+ await sendThreadMessage(thread, 'No request was transcribed. Record another voice message with the task to send.')
553
+ return new EmptyTranscriptionError()
547
554
  }
548
555
  voiceLogger.log(
549
556
  `[DETERMINISTIC] Returning canned transcription: "${result.transcription}"${result.queueMessage ? ' [QUEUE]' : ''}`,
@@ -566,6 +573,10 @@ export async function processVoiceAttachment({
566
573
  thread,
567
574
  `📝 **Transcribed message:** ${escapeDiscordFormatting(result.transcription)}`,
568
575
  )
576
+ if (result.sessionAction === 'btw' && !canForkSession) {
577
+ await sendThreadMessage(thread, 'A contextual side chat needs an existing session. Nothing was sent to the agent. Retry from a session, or ask to create a fresh chat with this request.')
578
+ return new TranscriptionError({ reason: 'Contextual routing is unavailable without a source session' })
579
+ }
569
580
  return result
570
581
  }
571
582
 
@@ -627,7 +638,7 @@ export async function processVoiceAttachment({
627
638
  // No user-configured key: gateway-mode installs get one free transcription
628
639
  // via the kimaki.dev Whisper fallback before we bother the user with the
629
640
  // "add API key" dialog. This fallback has no tool-calling, so it can't
630
- // detect queueMessage/agent hints spoken in the voice message the way the
641
+ // detect queueMessage/sessionAction/agent hints spoken in the voice message the way the
631
642
  // OpenAI/Gemini path can.
632
643
  let gatewayTranscription: string | undefined
633
644
  if (!transcriptionApiKey) {
@@ -677,14 +688,18 @@ export async function processVoiceAttachment({
677
688
  currentSessionContext,
678
689
  lastSessionContext,
679
690
  agents,
691
+ canForkSession,
680
692
  })
681
693
 
682
694
  if (transcription instanceof Error) {
695
+ if (transcription instanceof EmptyTranscriptionError) {
696
+ await sendThreadMessage(thread, 'No request was transcribed. Record another voice message with the task to send.')
697
+ return transcription
698
+ }
683
699
  const errMsg = errore.matchError(transcription, {
684
700
  ApiKeyMissingError: (e) => e.message,
685
701
  InvalidAudioFormatError: (e) => e.message,
686
702
  TranscriptionError: (e) => e.message,
687
- EmptyTranscriptionError: (e) => e.message,
688
703
  NoResponseContentError: (e) => e.message,
689
704
  NoToolResponseError: (e) => e.message,
690
705
  Error: (e) => e.message,
@@ -728,6 +743,10 @@ export async function processVoiceAttachment({
728
743
  thread,
729
744
  `📝 **Transcribed message:** ${escapeDiscordFormatting(text)}`,
730
745
  )
746
+ if (transcription.sessionAction === 'btw' && !canForkSession) {
747
+ await sendThreadMessage(thread, 'A contextual side chat needs an existing session. Nothing was sent to the agent. Retry from a session, or ask to create a fresh chat with this request.')
748
+ return new TranscriptionError({ reason: 'Contextual routing is unavailable without a source session' })
749
+ }
731
750
  if (agent) {
732
751
  await sendThreadMessage(thread, `Detected agent: ${agent}`)
733
752
  }
@@ -28,6 +28,10 @@ import {
28
28
  closeDatabase,
29
29
  setChannelDirectory,
30
30
  setChannelVerbosity,
31
+ getThreadSession,
32
+ createPendingWorkspace,
33
+ setWorkspaceReady,
34
+ getThreadWorktreeOrWorkspace,
31
35
  } from './database.js'
32
36
  import { startHranaServer, stopHranaServer } from './hrana-server.js'
33
37
  import { initializeOpencodeForDirectory, getOpencodeClient, stopOpencodeServer } from './opencode.js'
@@ -429,6 +433,122 @@ e2eTest('voice message handling', () => {
429
433
 
430
434
  // ── Test 1: Voice message in a channel creates thread + session ──
431
435
 
436
+ // Vitest disallows inline snapshots in test.each; register separate tests instead.
437
+ for (const sessionAction of ['btw', 'new-session'] as const) {
438
+ test(
439
+ `voice ${sessionAction} routing creates a separate thread with the correct history`,
440
+ async () => {
441
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID)
442
+ .sendMessage({ content: `Source history for voice ${sessionAction}` })
443
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
444
+ timeout: 4_000,
445
+ predicate: (candidate) => Boolean(candidate.name?.includes(`Source history for voice ${sessionAction}`)),
446
+ })
447
+ await waitForFooterMessage({ discord, threadId: thread.id, timeout: 4_000 })
448
+ const sourceSessionId = await getThreadSession(thread.id)
449
+ await createPendingWorkspace({
450
+ threadId: thread.id,
451
+ workspaceType: 'worktree',
452
+ workspaceName: 'voice-workspace',
453
+ projectDirectory: directories.projectDirectory,
454
+ })
455
+ await setWorkspaceReady({
456
+ threadId: thread.id,
457
+ workspaceId: 'voice-workspace-id',
458
+ workspaceDirectory: directories.projectDirectory,
459
+ })
460
+ const prompt = `Explain voice routing ${sessionAction}`
461
+ setDeterministicTranscription({ transcription: prompt, queueMessage: false, sessionAction })
462
+ await discord.thread(thread.id).user(TEST_USER_ID).sendVoiceMessage()
463
+ const confirmation = sessionAction === 'btw' ? 'Session forked!' : 'Created new session in'
464
+ const routingMessages = await waitForBotMessageContaining({
465
+ discord,
466
+ threadId: thread.id,
467
+ text: confirmation,
468
+ timeout: 4_000,
469
+ })
470
+ // OpenCode can rename the destination before discovery; follow its confirmed ID.
471
+ const targetId = routingMessages.find((message) => message.content.includes(confirmation))
472
+ ?.content.match(/<#(\d+)>/)?.[1]
473
+ if (!targetId) throw new Error('Expected destination thread link')
474
+ const target = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
475
+ timeout: 4_000,
476
+ predicate: (candidate) => candidate.id === targetId,
477
+ })
478
+ await waitForFooterMessage({ discord, threadId: target.id, timeout: 4_000 })
479
+ const th = discord.thread(target.id)
480
+ const transcript = {
481
+ source: (await discord.thread(thread.id).text()).replaceAll(target.id, 'TARGET_THREAD'),
482
+ target: (await th.text()).replaceAll(thread.id, 'SOURCE_THREAD'),
483
+ }
484
+ if (sessionAction === 'btw') {
485
+ expect(transcript).toMatchInlineSnapshot(`
486
+ {
487
+ "source": "--- from: user (voice-tester)
488
+ Source history for voice btw
489
+ --- from: assistant (TestBot)
490
+ > *using deterministic-provider/deterministic-v2*
491
+ > session-reply
492
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@300000000000000777>
493
+ --- from: user (voice-tester)
494
+ [attachment: voice-message.ogg]
495
+ --- from: assistant (TestBot)
496
+ 🎤 Transcribing voice message...
497
+ 📝 **Transcribed message:** Explain voice routing btw
498
+ Session forked! Continue in <#TARGET_THREAD>",
499
+ "target": "--- from: assistant (TestBot)
500
+ Reusing context from <#SOURCE_THREAD> to answer prompt...
501
+ Explain voice routing btw
502
+ > session-reply
503
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@300000000000000777>",
504
+ }
505
+ `)
506
+ } else {
507
+ expect(transcript).toMatchInlineSnapshot(`
508
+ {
509
+ "source": "--- from: user (voice-tester)
510
+ Source history for voice new-session
511
+ --- from: assistant (TestBot)
512
+ > *using deterministic-provider/deterministic-v2*
513
+ > session-reply
514
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@300000000000000777>
515
+ --- from: user (voice-tester)
516
+ [attachment: voice-message.ogg]
517
+ --- from: assistant (TestBot)
518
+ 🎤 Transcribing voice message...
519
+ 📝 **Transcribed message:** Explain voice routing new-session
520
+ Created new session in <#TARGET_THREAD>",
521
+ "target": "--- from: assistant (TestBot)
522
+ **Starting OpenCode session**
523
+ Explain voice routing new-session
524
+ > *using deterministic-provider/deterministic-v2*
525
+ > session-reply
526
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@300000000000000777>",
527
+ }
528
+ `)
529
+ }
530
+ const targetSessionId = await getThreadSession(target.id)
531
+ expect(targetSessionId).toBeTruthy()
532
+ expect(targetSessionId).not.toBe(sourceSessionId)
533
+ expect(await getThreadWorktreeOrWorkspace(target.id)).toMatchObject({
534
+ status: 'ready',
535
+ workspace_type: 'worktree',
536
+ workspace_name: 'voice-workspace',
537
+ workspace_id: 'voice-workspace-id',
538
+ workspace_directory: directories.projectDirectory,
539
+ })
540
+ const client = getOpencodeClientForTest(directories.projectDirectory)
541
+ const targetMessages = await client.session.messages({ sessionID: targetSessionId! })
542
+ const targetTexts = getUserTexts(targetMessages.data ?? []).join('\n')
543
+ expect(targetTexts).toContain(prompt)
544
+ expect(targetTexts).toContain(`Voice message transcription from Discord user:\n${prompt}`)
545
+ expect(targetTexts.includes(`Source history for voice ${sessionAction}`)).toBe(sessionAction === 'btw')
546
+ const sourceMessages = await client.session.messages({ sessionID: sourceSessionId! })
547
+ expect(getUserTexts(sourceMessages.data ?? []).join('\n')).not.toContain(prompt)
548
+ },
549
+ )
550
+ }
551
+
432
552
  test(
433
553
  'voice message in channel creates thread and starts session',
434
554
  async () => {
@@ -503,9 +623,9 @@ e2eTest('voice message handling', () => {
503
623
  --- from: assistant (TestBot)
504
624
  🎤 Transcribing voice message...
505
625
  📝 **Transcribed message:** Fix the login bug in auth.ts
506
- *using deterministic-provider/deterministic-v2*
507
- ⬥ session-reply
508
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
626
+ > *using deterministic-provider/deterministic-v2*
627
+ > session-reply
628
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@300000000000000777>"
509
629
  `)
510
630
  expect(finalState.sessionId).toBeDefined()
511
631
 
@@ -607,9 +727,9 @@ e2eTest('voice message handling', () => {
607
727
  Voice transcription requires an API key (OpenAI or Gemini). Set one to enable voice message transcription.
608
728
  Gemini API key saved. Retrying the original voice message.
609
729
  📝 **Transcribed message:** Resume the original voice note
610
- *using deterministic-provider/deterministic-v2*
611
- ⬥ session-reply
612
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
730
+ > *using deterministic-provider/deterministic-v2*
731
+ > session-reply
732
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@300000000000000777>"
613
733
  `)
614
734
 
615
735
  const finalState = getThreadState(thread.id)
@@ -685,9 +805,9 @@ e2eTest('voice message handling', () => {
685
805
  --- from: assistant (TestBot)
686
806
  🎤 Transcribing voice message...
687
807
  📝 **Transcribed message:** Investigate the missing content type path
688
- *using deterministic-provider/deterministic-v2*
689
- ⬥ session-reply
690
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
808
+ > *using deterministic-provider/deterministic-v2*
809
+ > session-reply
810
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@300000000000000777>"
691
811
  `)
692
812
 
693
813
  const messages = await waitForSessionMessages({
@@ -794,16 +914,16 @@ e2eTest('voice message handling', () => {
794
914
  "--- from: user (voice-tester)
795
915
  FAST_RESPONSE_MARKER initial setup
796
916
  --- from: assistant (TestBot)
797
- *using deterministic-provider/deterministic-v2*
798
- ⬥ fast-response-done
799
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
917
+ > *using deterministic-provider/deterministic-v2*
918
+ > fast-response-done
919
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@300000000000000777>
800
920
  --- from: user (voice-tester)
801
921
  [attachment: voice-message.ogg]
802
922
  --- from: assistant (TestBot)
803
923
  🎤 Transcribing voice message...
804
924
  📝 **Transcribed message:** Add error handling to the parser
805
- ⬥ session-reply
806
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
925
+ > session-reply
926
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@300000000000000777>"
807
927
  `)
808
928
  expect(finalState?.sessionId).toBeDefined()
809
929
  if (!finalState?.sessionId) {
@@ -1053,19 +1173,19 @@ e2eTest('voice message handling', () => {
1053
1173
  "--- from: user (voice-tester)
1054
1174
  SLOW_RESPONSE_MARKER start queued task
1055
1175
  --- from: assistant (TestBot)
1056
- *using deterministic-provider/deterministic-v2*
1176
+ > *using deterministic-provider/deterministic-v2*
1057
1177
  --- from: user (voice-tester)
1058
1178
  [attachment: voice-message.ogg]
1059
1179
  --- from: assistant (TestBot)
1060
1180
  🎤 Transcribing voice message...
1061
1181
  📝 **Transcribed message:** Queue this task for later
1062
1182
  Queued at position 1. Edit or delete your message to update the queue
1063
- ⬥ slow-response-done
1064
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
1183
+ > slow-response-done
1184
+ > *project ⋅ main ⋅ 2s ⋅ 0% ⋅ deterministic-v2*
1065
1185
  » **voice-tester:** Voice message transcription from Discord user:
1066
1186
  Queue this task for later
1067
- ⬥ session-reply
1068
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
1187
+ > session-reply
1188
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@300000000000000777>"
1069
1189
  `)
1070
1190
  expect(finalState.queueItems.length).toBe(0)
1071
1191
 
@@ -1181,16 +1301,16 @@ e2eTest('voice message handling', () => {
1181
1301
  "--- from: user (voice-tester)
1182
1302
  FAST_RESPONSE_MARKER quick task
1183
1303
  --- from: assistant (TestBot)
1184
- *using deterministic-provider/deterministic-v2*
1185
- ⬥ fast-response-done
1186
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
1304
+ > *using deterministic-provider/deterministic-v2*
1305
+ > fast-response-done
1306
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@300000000000000777>
1187
1307
  --- from: user (voice-tester)
1188
1308
  [attachment: voice-message.ogg]
1189
1309
  --- from: assistant (TestBot)
1190
1310
  🎤 Transcribing voice message...
1191
1311
  📝 **Transcribed message:** Delayed transcription result
1192
- ⬥ session-reply
1193
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
1312
+ > session-reply
1313
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@300000000000000777>"
1194
1314
  `)
1195
1315
  expect(finalState.sessionId).toBeDefined()
1196
1316
  expect(finalState.queueItems.length).toBe(0)
@@ -1319,16 +1439,16 @@ e2eTest('voice message handling', () => {
1319
1439
  "--- from: user (voice-tester)
1320
1440
  FAST_RESPONSE_MARKER fast before queued voice
1321
1441
  --- from: assistant (TestBot)
1322
- *using deterministic-provider/deterministic-v2*
1323
- ⬥ fast-response-done
1324
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
1442
+ > *using deterministic-provider/deterministic-v2*
1443
+ > fast-response-done
1444
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@300000000000000777>
1325
1445
  --- from: user (voice-tester)
1326
1446
  [attachment: voice-message.ogg]
1327
1447
  --- from: assistant (TestBot)
1328
1448
  🎤 Transcribing voice message...
1329
1449
  📝 **Transcribed message:** Queued voice after idle
1330
- ⬥ session-reply
1331
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
1450
+ > session-reply
1451
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@300000000000000777>"
1332
1452
  `)
1333
1453
  expect(finalState.sessionId).toBeDefined()
1334
1454
  expect(finalState.queueItems.length).toBe(0)
@@ -0,0 +1,203 @@
1
+ // Cover long routed prompts and rejected voice routes without an existing session.
2
+ import { afterEach, describe, expect, test } from 'vitest'
3
+ import { ChannelType } from 'discord.js'
4
+ import { setupQueueAdvancedSuite, TEST_USER_ID } from './queue-advanced-e2e-setup.js'
5
+ import { waitForBotMessageContaining, waitForFooterMessage } from './test-utils.js'
6
+ import { getThreadSession } from './database.js'
7
+ import { getOpencodeClient } from './opencode.js'
8
+ import { store } from './store.js'
9
+
10
+ const TEXT_CHANNEL_ID = '200000000000001084'
11
+
12
+ describe('voice routing input boundaries', () => {
13
+ const ctx = setupQueueAdvancedSuite({
14
+ channelId: TEXT_CHANNEL_ID,
15
+ channelName: 'voice-routing-input',
16
+ dirName: 'voice-routing-input',
17
+ username: 'voice-input-tester',
18
+ })
19
+
20
+ afterEach(() => {
21
+ store.setState({ test: { deterministicTranscription: null } })
22
+ })
23
+
24
+ test('fresh voice route splits display and preserves the full model prompt', async () => {
25
+ const channel = await ctx.botClient.channels.fetch(TEXT_CHANNEL_ID)
26
+ if (channel?.type !== ChannelType.GuildText) throw new Error('Expected text channel')
27
+ const source = await channel.threads.create({ name: 'Long voice source' })
28
+ const prompt = `Long voice request ${'Z'.repeat(2100)} END_OF_REQUEST`
29
+ const caption = 'Keep the existing public API unchanged.'
30
+ store.setState({
31
+ test: {
32
+ deterministicTranscription: {
33
+ transcription: prompt,
34
+ queueMessage: false,
35
+ sessionAction: 'new-session',
36
+ },
37
+ },
38
+ })
39
+ await ctx.discord.thread(source.id).user(TEST_USER_ID).sendVoiceMessage({ content: caption })
40
+ const routingMessages = await waitForBotMessageContaining({
41
+ discord: ctx.discord,
42
+ threadId: source.id,
43
+ text: 'Created new session in',
44
+ timeout: 4_000,
45
+ })
46
+ const targetId = routingMessages.find((message) => message.content.includes('Created new session in'))
47
+ ?.content.match(/<#(\d+)>/)?.[1]
48
+ if (!targetId) throw new Error('Expected destination thread link')
49
+ const target = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
50
+ timeout: 4_000,
51
+ predicate: (thread) => thread.id === targetId,
52
+ })
53
+ await waitForFooterMessage({ discord: ctx.discord, threadId: target.id, timeout: 4_000 })
54
+ expect((await ctx.discord.channel(TEXT_CHANNEL_ID).text()).replace(/Z+/g, '[DETAILS]'))
55
+ .toMatchInlineSnapshot(`
56
+ "--- from: assistant (TestBot)
57
+ **Starting OpenCode session**
58
+ Keep the existing public API unchanged.
59
+
60
+ "
61
+ `)
62
+ expect((await ctx.discord.thread(target.id).text()).replace(/Z+/g, '[DETAILS]'))
63
+ .toMatchInlineSnapshot(`
64
+ "--- from: assistant (TestBot)
65
+ **Starting OpenCode session**
66
+ Keep the existing public API unchanged.
67
+
68
+
69
+ Long voice request [DETAILS]
70
+ [DETAILS] END_OF_REQUEST
71
+ > *using deterministic-provider/deterministic-v2*
72
+ > ok
73
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>"
74
+ `)
75
+ const channelMessages = await ctx.discord.channel(TEXT_CHANNEL_ID).getMessages()
76
+ expect(channelMessages).toHaveLength(1)
77
+ expect(channelMessages.every((message) => message.content.length <= 2000)).toBe(true)
78
+ const threadText = await ctx.discord.thread(target.id).text()
79
+ expect(threadText).toContain(caption)
80
+ expect(threadText.replace(/\s/g, '')).toContain(prompt.replace(/\s/g, ''))
81
+ expect(
82
+ threadText.match(/Z/g),
83
+ ).toHaveLength(2100)
84
+ const sessionId = await getThreadSession(target.id)
85
+ if (!sessionId) throw new Error('Expected target session')
86
+ const client = getOpencodeClient(ctx.directories.projectDirectory)
87
+ if (!client) throw new Error('Expected OpenCode client')
88
+ const messages = await client.session.messages({ sessionID: sessionId })
89
+ const userTexts = messages.data
90
+ ?.filter((message) => message.info.role === 'user')
91
+ .flatMap((message) =>
92
+ message.parts.flatMap((part) => (part.type === 'text' ? [part.text] : [])),
93
+ )
94
+ expect(userTexts?.join('\n')).toContain(
95
+ `Voice message transcription from Discord user:\n${prompt}`,
96
+ )
97
+ expect(userTexts?.join('\n')).toContain(caption)
98
+ expect(await getThreadSession(source.id)).toBeFalsy()
99
+ })
100
+
101
+ test('unsupported contextual route keeps the transcript and never dispatches raw text', async () => {
102
+ const channel = await ctx.botClient.channels.fetch(TEXT_CHANNEL_ID)
103
+ if (channel?.type !== ChannelType.GuildText) throw new Error('Expected text channel')
104
+ const source = await channel.threads.create({ name: 'Unsupported voice source' })
105
+ const th = ctx.discord.thread(source.id)
106
+ store.setState({
107
+ test: {
108
+ deterministicTranscription: {
109
+ transcription: 'Explain the parser in a contextual side chat',
110
+ queueMessage: false,
111
+ sessionAction: 'btw',
112
+ },
113
+ },
114
+ })
115
+ await th.user(TEST_USER_ID).sendVoiceMessage({ content: 'Do not dispatch this raw text' })
116
+ await waitForBotMessageContaining({
117
+ discord: ctx.discord,
118
+ threadId: source.id,
119
+ text: 'Nothing was sent to the agent',
120
+ timeout: 4_000,
121
+ })
122
+ expect(await th.text()).toMatchInlineSnapshot(`
123
+ "--- from: user (voice-input-tester)
124
+ Do not dispatch this raw text
125
+ [attachment: voice-message.ogg]
126
+ --- from: assistant (TestBot)
127
+ 🎤 Transcribing voice message...
128
+ 📝 **Transcribed message:** Explain the parser in a contextual side chat
129
+ A contextual side chat needs an existing session. Nothing was sent to the agent. Retry from a session, or ask to create a fresh chat with this request."
130
+ `)
131
+ for (let index = 0; index < 10; index++) {
132
+ await new Promise((resolve) => setTimeout(resolve, 20))
133
+ expect(await getThreadSession(source.id)).toBeFalsy()
134
+ }
135
+ })
136
+
137
+ test('routing-only audio does not start a junk turn', async () => {
138
+ const channel = await ctx.botClient.channels.fetch(TEXT_CHANNEL_ID)
139
+ if (channel?.type !== ChannelType.GuildText) throw new Error('Expected text channel')
140
+ const source = await channel.threads.create({ name: 'Empty voice source' })
141
+ const th = ctx.discord.thread(source.id)
142
+ store.setState({
143
+ test: {
144
+ deterministicTranscription: {
145
+ transcription: '',
146
+ queueMessage: false,
147
+ sessionAction: 'new-session',
148
+ },
149
+ },
150
+ })
151
+ await th.user(TEST_USER_ID).sendVoiceMessage({ content: 'Create a new chat' })
152
+ await waitForBotMessageContaining({
153
+ discord: ctx.discord,
154
+ threadId: source.id,
155
+ text: 'No request was transcribed',
156
+ timeout: 4_000,
157
+ })
158
+ expect(await th.text()).toMatchInlineSnapshot(`
159
+ "--- from: user (voice-input-tester)
160
+ Create a new chat
161
+ [attachment: voice-message.ogg]
162
+ --- from: assistant (TestBot)
163
+ 🎤 Transcribing voice message...
164
+ No request was transcribed. Record another voice message with the task to send."
165
+ `)
166
+ for (let index = 0; index < 10; index++) {
167
+ await new Promise((resolve) => setTimeout(resolve, 20))
168
+ expect(await getThreadSession(source.id)).toBeFalsy()
169
+ }
170
+ })
171
+
172
+ test('/new-session confirms the destination and sends the prompt', async () => {
173
+ const channel = await ctx.botClient.channels.fetch(TEXT_CHANNEL_ID)
174
+ if (channel?.type !== ChannelType.GuildText) throw new Error('Expected text channel')
175
+ const source = await channel.threads.create({ name: 'Slash source' })
176
+ const th = ctx.discord.thread(source.id)
177
+ const interaction = await th.user(TEST_USER_ID).runSlashCommand({
178
+ name: 'new-session',
179
+ options: [{ name: 'prompt', type: 3, value: 'Slash fresh request' }],
180
+ })
181
+ await th.waitForInteractionAck({ interactionId: interaction.id, timeout: 4_000 })
182
+ const target = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
183
+ timeout: 4_000,
184
+ predicate: (thread) => thread.name === 'Slash fresh request',
185
+ })
186
+ await waitForFooterMessage({ discord: ctx.discord, threadId: target.id, timeout: 4_000 })
187
+ expect(
188
+ (await th.text({ showInteractions: true })).replaceAll(target.id, 'TARGET_THREAD'),
189
+ ).toMatchInlineSnapshot(`
190
+ "[user interaction]
191
+ --- from: assistant (TestBot)
192
+ Created new session in <#TARGET_THREAD>"
193
+ `)
194
+ expect(await ctx.discord.thread(target.id).text()).toMatchInlineSnapshot(`
195
+ "--- from: assistant (TestBot)
196
+ **Starting OpenCode session**
197
+ Slash fresh request
198
+ > *using deterministic-provider/deterministic-v2*
199
+ > ok
200
+ > *project ⋅ main ⋅ <1s ⋅ 0% ⋅ deterministic-v2* <@200000000000000991>"
201
+ `)
202
+ })
203
+ })