@kodelyth/discord 2026.5.42 → 2026.6.2

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 (514) hide show
  1. package/klaw.plugin.json +3822 -2
  2. package/package.json +18 -6
  3. package/account-inspect-api.ts +0 -6
  4. package/action-runtime-api.ts +0 -1
  5. package/api.ts +0 -130
  6. package/channel-config-api.ts +0 -1
  7. package/channel-plugin-api.ts +0 -3
  8. package/config-api.ts +0 -4
  9. package/configured-state.ts +0 -6
  10. package/contract-api.ts +0 -21
  11. package/directory-contract-api.ts +0 -4
  12. package/doctor-contract-api.ts +0 -1
  13. package/index.test.ts +0 -13
  14. package/index.ts +0 -24
  15. package/runtime-api.actions.ts +0 -15
  16. package/runtime-api.lookup.ts +0 -22
  17. package/runtime-api.monitor.ts +0 -50
  18. package/runtime-api.send.ts +0 -79
  19. package/runtime-api.threads.ts +0 -31
  20. package/runtime-api.ts +0 -181
  21. package/runtime-setter-api.ts +0 -3
  22. package/secret-contract-api.ts +0 -4
  23. package/security-audit-contract-api.ts +0 -1
  24. package/security-contract-api.ts +0 -4
  25. package/session-key-api.ts +0 -1
  26. package/setup-entry.ts +0 -9
  27. package/setup-plugin-api.ts +0 -3
  28. package/src/account-inspect.test.ts +0 -126
  29. package/src/account-inspect.ts +0 -128
  30. package/src/accounts.test.ts +0 -381
  31. package/src/accounts.ts +0 -205
  32. package/src/actions/handle-action.guild-admin.ts +0 -421
  33. package/src/actions/handle-action.test.ts +0 -480
  34. package/src/actions/handle-action.ts +0 -402
  35. package/src/actions/runtime.guild.ts +0 -446
  36. package/src/actions/runtime.messaging.messages.ts +0 -226
  37. package/src/actions/runtime.messaging.reactions.ts +0 -67
  38. package/src/actions/runtime.messaging.runtime.ts +0 -73
  39. package/src/actions/runtime.messaging.send.ts +0 -336
  40. package/src/actions/runtime.messaging.shared.ts +0 -97
  41. package/src/actions/runtime.messaging.ts +0 -37
  42. package/src/actions/runtime.moderation-shared.ts +0 -48
  43. package/src/actions/runtime.moderation.authz.test.ts +0 -151
  44. package/src/actions/runtime.moderation.ts +0 -116
  45. package/src/actions/runtime.presence.test.ts +0 -165
  46. package/src/actions/runtime.presence.ts +0 -117
  47. package/src/actions/runtime.shared.ts +0 -86
  48. package/src/actions/runtime.test.ts +0 -1337
  49. package/src/actions/runtime.ts +0 -87
  50. package/src/api-barrel.test.ts +0 -78
  51. package/src/api.test.ts +0 -152
  52. package/src/api.ts +0 -215
  53. package/src/approval-handler.runtime.test.ts +0 -41
  54. package/src/approval-handler.runtime.ts +0 -633
  55. package/src/approval-native.test.ts +0 -330
  56. package/src/approval-native.ts +0 -219
  57. package/src/approval-runtime.ts +0 -14
  58. package/src/approval-shared.ts +0 -50
  59. package/src/audit-core.ts +0 -178
  60. package/src/audit.test.ts +0 -204
  61. package/src/audit.ts +0 -32
  62. package/src/channel-actions.contract.test.ts +0 -45
  63. package/src/channel-actions.runtime.ts +0 -1
  64. package/src/channel-actions.test.ts +0 -504
  65. package/src/channel-actions.ts +0 -254
  66. package/src/channel-api.ts +0 -29
  67. package/src/channel.conversation.ts +0 -159
  68. package/src/channel.loaders.ts +0 -50
  69. package/src/channel.message-adapter.test.ts +0 -230
  70. package/src/channel.runtime.ts +0 -1
  71. package/src/channel.setup.ts +0 -12
  72. package/src/channel.test.ts +0 -828
  73. package/src/channel.ts +0 -728
  74. package/src/chunk.test.ts +0 -170
  75. package/src/chunk.ts +0 -321
  76. package/src/client.proxy.test.ts +0 -177
  77. package/src/client.test.ts +0 -83
  78. package/src/client.ts +0 -143
  79. package/src/component-custom-id.ts +0 -72
  80. package/src/components-registry.ts +0 -356
  81. package/src/components.builders.ts +0 -409
  82. package/src/components.modal.ts +0 -124
  83. package/src/components.parse.ts +0 -407
  84. package/src/components.test.ts +0 -345
  85. package/src/components.ts +0 -54
  86. package/src/components.types.ts +0 -187
  87. package/src/config-schema.test.ts +0 -439
  88. package/src/config-schema.ts +0 -6
  89. package/src/config-ui-hints.ts +0 -354
  90. package/src/conversation-identity.ts +0 -58
  91. package/src/delivery-retry.ts +0 -52
  92. package/src/directory-cache.ts +0 -116
  93. package/src/directory-config.ts +0 -58
  94. package/src/directory-contract.test.ts +0 -129
  95. package/src/directory-live.test.ts +0 -141
  96. package/src/directory-live.ts +0 -135
  97. package/src/doctor-contract.ts +0 -477
  98. package/src/doctor-shared.ts +0 -5
  99. package/src/doctor.test.ts +0 -393
  100. package/src/doctor.ts +0 -340
  101. package/src/draft-chunking.test.ts +0 -64
  102. package/src/draft-chunking.ts +0 -43
  103. package/src/draft-stream.test.ts +0 -193
  104. package/src/draft-stream.ts +0 -162
  105. package/src/durable-delivery.test.ts +0 -103
  106. package/src/error-body.ts +0 -38
  107. package/src/exec-approvals.test.ts +0 -88
  108. package/src/exec-approvals.ts +0 -110
  109. package/src/gateway-logging.test.ts +0 -98
  110. package/src/gateway-logging.ts +0 -67
  111. package/src/group-policy.ts +0 -113
  112. package/src/guilds.ts +0 -29
  113. package/src/inbound-context.contract.test.ts +0 -11
  114. package/src/inbound-event-delivery.ts +0 -135
  115. package/src/interactive-dispatch.ts +0 -104
  116. package/src/internal/api.commands.ts +0 -51
  117. package/src/internal/api.guild.ts +0 -164
  118. package/src/internal/api.interactions.ts +0 -53
  119. package/src/internal/api.messages.ts +0 -113
  120. package/src/internal/api.reactions.ts +0 -38
  121. package/src/internal/api.test.ts +0 -260
  122. package/src/internal/api.ts +0 -61
  123. package/src/internal/api.users.ts +0 -19
  124. package/src/internal/api.webhooks.ts +0 -13
  125. package/src/internal/client.test.ts +0 -472
  126. package/src/internal/client.ts +0 -310
  127. package/src/internal/command-deploy.test.ts +0 -197
  128. package/src/internal/command-deploy.ts +0 -352
  129. package/src/internal/commands.ts +0 -188
  130. package/src/internal/components.base.ts +0 -65
  131. package/src/internal/components.message.ts +0 -279
  132. package/src/internal/components.modal.ts +0 -95
  133. package/src/internal/components.ts +0 -31
  134. package/src/internal/discord.ts +0 -11
  135. package/src/internal/embeds.ts +0 -35
  136. package/src/internal/entity-cache.ts +0 -98
  137. package/src/internal/event-queue.ts +0 -185
  138. package/src/internal/gateway-close-codes.ts +0 -25
  139. package/src/internal/gateway-dispatch.ts +0 -96
  140. package/src/internal/gateway-identify-limiter.ts +0 -26
  141. package/src/internal/gateway-lifecycle.test.ts +0 -114
  142. package/src/internal/gateway-lifecycle.ts +0 -75
  143. package/src/internal/gateway-rate-limit.ts +0 -104
  144. package/src/internal/gateway.test.ts +0 -676
  145. package/src/internal/gateway.ts +0 -479
  146. package/src/internal/interaction-dispatch.test.ts +0 -148
  147. package/src/internal/interaction-dispatch.ts +0 -162
  148. package/src/internal/interaction-options.ts +0 -98
  149. package/src/internal/interaction-response.ts +0 -53
  150. package/src/internal/interactions.test.ts +0 -329
  151. package/src/internal/interactions.ts +0 -378
  152. package/src/internal/listeners.ts +0 -91
  153. package/src/internal/live-smoke.live.test.ts +0 -26
  154. package/src/internal/modal-fields.ts +0 -95
  155. package/src/internal/payload.ts +0 -69
  156. package/src/internal/rest-body.ts +0 -115
  157. package/src/internal/rest-errors.ts +0 -88
  158. package/src/internal/rest-routes.ts +0 -50
  159. package/src/internal/rest-scheduler.ts +0 -557
  160. package/src/internal/rest.test.ts +0 -681
  161. package/src/internal/rest.ts +0 -322
  162. package/src/internal/schemas.ts +0 -36
  163. package/src/internal/structures.test.ts +0 -43
  164. package/src/internal/structures.ts +0 -280
  165. package/src/internal/test-builders.test-support.ts +0 -167
  166. package/src/internal/voice.ts +0 -49
  167. package/src/media-detection.ts +0 -28
  168. package/src/mentions.test.ts +0 -111
  169. package/src/mentions.ts +0 -147
  170. package/src/monitor/ack-reactions.ts +0 -70
  171. package/src/monitor/acp-bind-here.integration.test.ts +0 -219
  172. package/src/monitor/agent-components-auth.ts +0 -7
  173. package/src/monitor/agent-components-context.ts +0 -154
  174. package/src/monitor/agent-components-data.ts +0 -224
  175. package/src/monitor/agent-components-dm-auth.ts +0 -177
  176. package/src/monitor/agent-components-guild-auth.ts +0 -322
  177. package/src/monitor/agent-components-helpers.runtime.ts +0 -3
  178. package/src/monitor/agent-components-helpers.ts +0 -34
  179. package/src/monitor/agent-components-reply.ts +0 -10
  180. package/src/monitor/agent-components.deps.runtime.ts +0 -2
  181. package/src/monitor/agent-components.dispatch.ts +0 -359
  182. package/src/monitor/agent-components.handlers.ts +0 -303
  183. package/src/monitor/agent-components.modal.ts +0 -160
  184. package/src/monitor/agent-components.plugin-interactive.ts +0 -187
  185. package/src/monitor/agent-components.runtime.ts +0 -14
  186. package/src/monitor/agent-components.system-controls.ts +0 -215
  187. package/src/monitor/agent-components.ts +0 -70
  188. package/src/monitor/agent-components.types.ts +0 -58
  189. package/src/monitor/agent-components.wildcard-controls.ts +0 -171
  190. package/src/monitor/agent-components.wildcard.test.ts +0 -71
  191. package/src/monitor/allow-list.test.ts +0 -14
  192. package/src/monitor/allow-list.ts +0 -631
  193. package/src/monitor/auto-presence.test.ts +0 -184
  194. package/src/monitor/auto-presence.ts +0 -356
  195. package/src/monitor/channel-access.test.ts +0 -113
  196. package/src/monitor/channel-access.ts +0 -102
  197. package/src/monitor/commands.test.ts +0 -24
  198. package/src/monitor/commands.ts +0 -9
  199. package/src/monitor/dm-command-auth.test.ts +0 -274
  200. package/src/monitor/dm-command-auth.ts +0 -259
  201. package/src/monitor/dm-command-decision.test.ts +0 -108
  202. package/src/monitor/dm-command-decision.ts +0 -49
  203. package/src/monitor/exec-approvals.test.ts +0 -225
  204. package/src/monitor/exec-approvals.ts +0 -158
  205. package/src/monitor/format.ts +0 -45
  206. package/src/monitor/gateway-handle.ts +0 -33
  207. package/src/monitor/gateway-metadata.test.ts +0 -29
  208. package/src/monitor/gateway-metadata.ts +0 -298
  209. package/src/monitor/gateway-plugin.test.ts +0 -320
  210. package/src/monitor/gateway-plugin.ts +0 -302
  211. package/src/monitor/gateway-registry.ts +0 -37
  212. package/src/monitor/gateway-supervisor.test.ts +0 -157
  213. package/src/monitor/gateway-supervisor.ts +0 -206
  214. package/src/monitor/inbound-context.test-helpers.ts +0 -37
  215. package/src/monitor/inbound-context.test.ts +0 -112
  216. package/src/monitor/inbound-context.ts +0 -95
  217. package/src/monitor/inbound-dedupe.ts +0 -79
  218. package/src/monitor/inbound-job.test.ts +0 -216
  219. package/src/monitor/inbound-job.ts +0 -118
  220. package/src/monitor/listeners.queue.ts +0 -91
  221. package/src/monitor/listeners.reactions.ts +0 -594
  222. package/src/monitor/listeners.test.ts +0 -209
  223. package/src/monitor/listeners.ts +0 -150
  224. package/src/monitor/message-channel-info.ts +0 -96
  225. package/src/monitor/message-forwarded.ts +0 -114
  226. package/src/monitor/message-handler.batch-gate.test.ts +0 -22
  227. package/src/monitor/message-handler.batch-gate.ts +0 -19
  228. package/src/monitor/message-handler.bot-self-filter.test.ts +0 -68
  229. package/src/monitor/message-handler.context.ts +0 -492
  230. package/src/monitor/message-handler.dm-preflight.ts +0 -119
  231. package/src/monitor/message-handler.draft-preview.ts +0 -426
  232. package/src/monitor/message-handler.hydration.test.ts +0 -80
  233. package/src/monitor/message-handler.hydration.ts +0 -198
  234. package/src/monitor/message-handler.inbound-context.test.ts +0 -61
  235. package/src/monitor/message-handler.module-test-helpers.ts +0 -31
  236. package/src/monitor/message-handler.preflight-channel-access.ts +0 -86
  237. package/src/monitor/message-handler.preflight-channel-context.test.ts +0 -18
  238. package/src/monitor/message-handler.preflight-channel-context.ts +0 -58
  239. package/src/monitor/message-handler.preflight-context.ts +0 -54
  240. package/src/monitor/message-handler.preflight-helpers.ts +0 -164
  241. package/src/monitor/message-handler.preflight-history.ts +0 -23
  242. package/src/monitor/message-handler.preflight-logging.ts +0 -36
  243. package/src/monitor/message-handler.preflight-pluralkit.ts +0 -26
  244. package/src/monitor/message-handler.preflight-runtime.ts +0 -28
  245. package/src/monitor/message-handler.preflight-thread.ts +0 -49
  246. package/src/monitor/message-handler.preflight.acp-bindings.test.ts +0 -371
  247. package/src/monitor/message-handler.preflight.test-helpers.ts +0 -114
  248. package/src/monitor/message-handler.preflight.test.ts +0 -2255
  249. package/src/monitor/message-handler.preflight.ts +0 -822
  250. package/src/monitor/message-handler.preflight.types.ts +0 -115
  251. package/src/monitor/message-handler.process.test.ts +0 -2520
  252. package/src/monitor/message-handler.process.ts +0 -1027
  253. package/src/monitor/message-handler.queue.test.ts +0 -680
  254. package/src/monitor/message-handler.routing-preflight.ts +0 -112
  255. package/src/monitor/message-handler.test-harness.ts +0 -99
  256. package/src/monitor/message-handler.test-helpers.ts +0 -75
  257. package/src/monitor/message-handler.ts +0 -309
  258. package/src/monitor/message-media.ts +0 -536
  259. package/src/monitor/message-run-queue.ts +0 -101
  260. package/src/monitor/message-text.ts +0 -171
  261. package/src/monitor/message-utils.test.ts +0 -1234
  262. package/src/monitor/message-utils.ts +0 -34
  263. package/src/monitor/model-picker-preferences.test.ts +0 -67
  264. package/src/monitor/model-picker-preferences.ts +0 -184
  265. package/src/monitor/model-picker.state.ts +0 -364
  266. package/src/monitor/model-picker.test-utils.ts +0 -26
  267. package/src/monitor/model-picker.test.ts +0 -869
  268. package/src/monitor/model-picker.ts +0 -38
  269. package/src/monitor/model-picker.view.ts +0 -722
  270. package/src/monitor/monitor.agent-components.test.ts +0 -410
  271. package/src/monitor/monitor.test.ts +0 -919
  272. package/src/monitor/monitor.threading-utils.test.ts +0 -614
  273. package/src/monitor/native-command-agent-reply.ts +0 -125
  274. package/src/monitor/native-command-arg-ui.ts +0 -233
  275. package/src/monitor/native-command-auth.ts +0 -309
  276. package/src/monitor/native-command-bypass.ts +0 -13
  277. package/src/monitor/native-command-context.test.ts +0 -105
  278. package/src/monitor/native-command-context.ts +0 -109
  279. package/src/monitor/native-command-dispatch.ts +0 -35
  280. package/src/monitor/native-command-model-picker-apply.ts +0 -209
  281. package/src/monitor/native-command-model-picker-interaction.ts +0 -516
  282. package/src/monitor/native-command-model-picker-ui.ts +0 -357
  283. package/src/monitor/native-command-reply.test.ts +0 -68
  284. package/src/monitor/native-command-reply.ts +0 -185
  285. package/src/monitor/native-command-route.ts +0 -91
  286. package/src/monitor/native-command-status.ts +0 -76
  287. package/src/monitor/native-command-ui.ts +0 -26
  288. package/src/monitor/native-command-ui.types.ts +0 -20
  289. package/src/monitor/native-command.args.ts +0 -45
  290. package/src/monitor/native-command.command-arg.test.ts +0 -108
  291. package/src/monitor/native-command.commands-allowfrom.test.ts +0 -504
  292. package/src/monitor/native-command.model-picker.test.ts +0 -930
  293. package/src/monitor/native-command.options.test.ts +0 -379
  294. package/src/monitor/native-command.options.ts +0 -153
  295. package/src/monitor/native-command.plugin-dispatch.test.ts +0 -1212
  296. package/src/monitor/native-command.runtime.ts +0 -51
  297. package/src/monitor/native-command.status-direct.test.ts +0 -278
  298. package/src/monitor/native-command.test-helpers.ts +0 -64
  299. package/src/monitor/native-command.think-autocomplete.test.ts +0 -411
  300. package/src/monitor/native-command.ts +0 -747
  301. package/src/monitor/native-command.types.ts +0 -9
  302. package/src/monitor/native-interaction-channel-context.ts +0 -50
  303. package/src/monitor/preflight-audio.runtime.ts +0 -9
  304. package/src/monitor/preflight-audio.test.ts +0 -157
  305. package/src/monitor/preflight-audio.ts +0 -130
  306. package/src/monitor/presence-cache.ts +0 -61
  307. package/src/monitor/presence.test.ts +0 -61
  308. package/src/monitor/presence.ts +0 -50
  309. package/src/monitor/provider-session.runtime.ts +0 -12
  310. package/src/monitor/provider.acp.ts +0 -89
  311. package/src/monitor/provider.allowlist.test.ts +0 -217
  312. package/src/monitor/provider.allowlist.ts +0 -398
  313. package/src/monitor/provider.cleanup.ts +0 -41
  314. package/src/monitor/provider.commands.ts +0 -129
  315. package/src/monitor/provider.config-log.ts +0 -45
  316. package/src/monitor/provider.deploy-errors.ts +0 -362
  317. package/src/monitor/provider.deploy.ts +0 -221
  318. package/src/monitor/provider.interactions.ts +0 -160
  319. package/src/monitor/provider.lifecycle.test.ts +0 -734
  320. package/src/monitor/provider.lifecycle.ts +0 -562
  321. package/src/monitor/provider.proxy.test.ts +0 -804
  322. package/src/monitor/provider.rest-proxy.test.ts +0 -389
  323. package/src/monitor/provider.runtime.ts +0 -1
  324. package/src/monitor/provider.skill-dedupe.test.ts +0 -42
  325. package/src/monitor/provider.startup-log.ts +0 -32
  326. package/src/monitor/provider.startup.test.ts +0 -440
  327. package/src/monitor/provider.startup.ts +0 -323
  328. package/src/monitor/provider.test.ts +0 -1173
  329. package/src/monitor/provider.ts +0 -688
  330. package/src/monitor/reply-context.ts +0 -64
  331. package/src/monitor/reply-delivery.test.ts +0 -474
  332. package/src/monitor/reply-delivery.ts +0 -212
  333. package/src/monitor/reply-safety.ts +0 -96
  334. package/src/monitor/rest-fetch.ts +0 -94
  335. package/src/monitor/route-resolution.test.ts +0 -209
  336. package/src/monitor/route-resolution.ts +0 -140
  337. package/src/monitor/sender-identity.ts +0 -81
  338. package/src/monitor/startup-status.test.ts +0 -30
  339. package/src/monitor/startup-status.ts +0 -10
  340. package/src/monitor/status.ts +0 -22
  341. package/src/monitor/system-events.ts +0 -55
  342. package/src/monitor/thread-bindings.config.ts +0 -35
  343. package/src/monitor/thread-bindings.discord-api.test.ts +0 -250
  344. package/src/monitor/thread-bindings.discord-api.ts +0 -310
  345. package/src/monitor/thread-bindings.lifecycle.test.ts +0 -1994
  346. package/src/monitor/thread-bindings.lifecycle.ts +0 -354
  347. package/src/monitor/thread-bindings.manager.ts +0 -551
  348. package/src/monitor/thread-bindings.messages.ts +0 -6
  349. package/src/monitor/thread-bindings.persona.test.ts +0 -34
  350. package/src/monitor/thread-bindings.persona.ts +0 -25
  351. package/src/monitor/thread-bindings.session-adapter.ts +0 -229
  352. package/src/monitor/thread-bindings.session-shared.ts +0 -59
  353. package/src/monitor/thread-bindings.session-updates.ts +0 -35
  354. package/src/monitor/thread-bindings.shared-state.test.ts +0 -39
  355. package/src/monitor/thread-bindings.state.ts +0 -540
  356. package/src/monitor/thread-bindings.ts +0 -48
  357. package/src/monitor/thread-bindings.types.ts +0 -83
  358. package/src/monitor/thread-channel-context.ts +0 -112
  359. package/src/monitor/thread-session-close.test.ts +0 -180
  360. package/src/monitor/thread-session-close.ts +0 -63
  361. package/src/monitor/thread-title.generate.test.ts +0 -209
  362. package/src/monitor/thread-title.test.ts +0 -31
  363. package/src/monitor/thread-title.ts +0 -181
  364. package/src/monitor/threading.auto-thread.test.ts +0 -330
  365. package/src/monitor/threading.auto-thread.ts +0 -287
  366. package/src/monitor/threading.cache.ts +0 -45
  367. package/src/monitor/threading.parent-info.test.ts +0 -156
  368. package/src/monitor/threading.starter.test.ts +0 -279
  369. package/src/monitor/threading.starter.ts +0 -288
  370. package/src/monitor/threading.ts +0 -20
  371. package/src/monitor/threading.types.ts +0 -102
  372. package/src/monitor/timeouts.ts +0 -84
  373. package/src/monitor/typing.test.ts +0 -42
  374. package/src/monitor/typing.ts +0 -17
  375. package/src/monitor.gateway.test.ts +0 -187
  376. package/src/monitor.gateway.ts +0 -75
  377. package/src/monitor.test.ts +0 -1416
  378. package/src/monitor.ts +0 -28
  379. package/src/network-config.test.ts +0 -92
  380. package/src/network-config.ts +0 -79
  381. package/src/normalize.test.ts +0 -56
  382. package/src/normalize.ts +0 -86
  383. package/src/outbound-adapter.interactive-order.test.ts +0 -82
  384. package/src/outbound-adapter.test-harness.ts +0 -207
  385. package/src/outbound-adapter.test.ts +0 -804
  386. package/src/outbound-adapter.ts +0 -326
  387. package/src/outbound-approval.ts +0 -29
  388. package/src/outbound-components.ts +0 -86
  389. package/src/outbound-payload.contract.test.ts +0 -49
  390. package/src/outbound-payload.ts +0 -208
  391. package/src/outbound-send-context.ts +0 -89
  392. package/src/outbound-session-route.test.ts +0 -42
  393. package/src/outbound-session-route.ts +0 -72
  394. package/src/pluralkit.test.ts +0 -67
  395. package/src/pluralkit.ts +0 -58
  396. package/src/preview-streaming.ts +0 -18
  397. package/src/probe.intents.test.ts +0 -94
  398. package/src/probe.parse-token.test.ts +0 -43
  399. package/src/probe.runtime.ts +0 -1
  400. package/src/probe.ts +0 -237
  401. package/src/proxy-fetch.ts +0 -92
  402. package/src/proxy-request-client.test.ts +0 -100
  403. package/src/proxy-request-client.ts +0 -21
  404. package/src/recipient-resolution.ts +0 -39
  405. package/src/resolve-allowlist-common.test.ts +0 -40
  406. package/src/resolve-allowlist-common.ts +0 -39
  407. package/src/resolve-channels.test.ts +0 -341
  408. package/src/resolve-channels.ts +0 -369
  409. package/src/resolve-users.test.ts +0 -243
  410. package/src/resolve-users.ts +0 -184
  411. package/src/retry.test.ts +0 -83
  412. package/src/retry.ts +0 -98
  413. package/src/runtime-api.ts +0 -61
  414. package/src/runtime-config.ts +0 -16
  415. package/src/runtime.ts +0 -23
  416. package/src/secret-config-contract.ts +0 -140
  417. package/src/security-audit.runtime.ts +0 -1
  418. package/src/security-audit.test.ts +0 -245
  419. package/src/security-audit.ts +0 -208
  420. package/src/security-contract.ts +0 -47
  421. package/src/security-doctor.test.ts +0 -25
  422. package/src/security-doctor.ts +0 -20
  423. package/src/security.ts +0 -60
  424. package/src/send-target-parsing.ts +0 -14
  425. package/src/send.channels.ts +0 -139
  426. package/src/send.components.test.ts +0 -330
  427. package/src/send.components.ts +0 -391
  428. package/src/send.creates-thread.test.ts +0 -681
  429. package/src/send.emojis-stickers.ts +0 -57
  430. package/src/send.guild.ts +0 -170
  431. package/src/send.message-request.ts +0 -112
  432. package/src/send.messages.test.ts +0 -59
  433. package/src/send.messages.ts +0 -229
  434. package/src/send.outbound.ts +0 -459
  435. package/src/send.permissions.authz.test.ts +0 -190
  436. package/src/send.permissions.ts +0 -283
  437. package/src/send.reactions.ts +0 -155
  438. package/src/send.receipt.ts +0 -69
  439. package/src/send.sends-basic-channel-messages.test.ts +0 -1068
  440. package/src/send.shared.ts +0 -469
  441. package/src/send.test-harness.ts +0 -56
  442. package/src/send.ts +0 -82
  443. package/src/send.types.ts +0 -191
  444. package/src/send.typing.test.ts +0 -41
  445. package/src/send.typing.ts +0 -9
  446. package/src/send.voice.ts +0 -136
  447. package/src/send.webhook-activity.test.ts +0 -152
  448. package/src/send.webhook.proxy.test.ts +0 -210
  449. package/src/send.webhook.ts +0 -137
  450. package/src/session-contract.ts +0 -3
  451. package/src/session-key-normalization.test.ts +0 -44
  452. package/src/session-key-normalization.ts +0 -47
  453. package/src/setup-account-state.test.ts +0 -113
  454. package/src/setup-account-state.ts +0 -141
  455. package/src/setup-adapter.ts +0 -14
  456. package/src/setup-core.ts +0 -215
  457. package/src/setup-runtime-helpers.ts +0 -10
  458. package/src/setup-surface.test.ts +0 -137
  459. package/src/setup-surface.ts +0 -132
  460. package/src/shared-interactive.test.ts +0 -153
  461. package/src/shared-interactive.ts +0 -161
  462. package/src/shared.test.ts +0 -186
  463. package/src/shared.ts +0 -197
  464. package/src/status-issues.test.ts +0 -97
  465. package/src/status-issues.ts +0 -198
  466. package/src/subagent-hooks.test.ts +0 -465
  467. package/src/subagent-hooks.ts +0 -232
  468. package/src/target-parsing.ts +0 -70
  469. package/src/target-resolver.ts +0 -129
  470. package/src/targets.test.ts +0 -393
  471. package/src/targets.ts +0 -12
  472. package/src/test-http-helpers.ts +0 -10
  473. package/src/test-support/component-runtime.ts +0 -194
  474. package/src/test-support/config.ts +0 -7
  475. package/src/test-support/configured-binding-runtime.ts +0 -29
  476. package/src/test-support/partial-channel.ts +0 -26
  477. package/src/test-support/provider.test-support.ts +0 -547
  478. package/src/token.test.ts +0 -174
  479. package/src/token.ts +0 -107
  480. package/src/ui-colors.ts +0 -27
  481. package/src/ui.ts +0 -20
  482. package/src/voice/access.test.ts +0 -288
  483. package/src/voice/access.ts +0 -126
  484. package/src/voice/audio.test.ts +0 -47
  485. package/src/voice/audio.ts +0 -249
  486. package/src/voice/capture-state.test.ts +0 -48
  487. package/src/voice/capture-state.ts +0 -120
  488. package/src/voice/command.test.ts +0 -170
  489. package/src/voice/command.ts +0 -284
  490. package/src/voice/config.ts +0 -8
  491. package/src/voice/ingress.ts +0 -164
  492. package/src/voice/manager.e2e.test.ts +0 -3286
  493. package/src/voice/manager.ready-listener.test.ts +0 -54
  494. package/src/voice/manager.runtime.ts +0 -14
  495. package/src/voice/manager.ts +0 -1155
  496. package/src/voice/prompt.test.ts +0 -30
  497. package/src/voice/prompt.ts +0 -22
  498. package/src/voice/realtime.ts +0 -1370
  499. package/src/voice/receive-recovery.test.ts +0 -81
  500. package/src/voice/receive-recovery.ts +0 -159
  501. package/src/voice/sanitize.test.ts +0 -34
  502. package/src/voice/sanitize.ts +0 -29
  503. package/src/voice/sdk-runtime.ts +0 -14
  504. package/src/voice/segment.ts +0 -160
  505. package/src/voice/session.ts +0 -81
  506. package/src/voice/speaker-context.ts +0 -127
  507. package/src/voice/tts.ts +0 -151
  508. package/src/voice-message.test.ts +0 -376
  509. package/src/voice-message.ts +0 -474
  510. package/subagent-hooks-api.ts +0 -27
  511. package/test-api.ts +0 -4
  512. package/thread-binding-api.ts +0 -1
  513. package/timeouts.ts +0 -6
  514. package/tsconfig.json +0 -16
@@ -1,3286 +0,0 @@
1
- import { PassThrough, type Readable } from "node:stream";
2
- import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
3
- import { ChannelType } from "../internal/discord.js";
4
- import { createVoiceCaptureState } from "./capture-state.js";
5
- import { createVoiceReceiveRecoveryState } from "./receive-recovery.js";
6
-
7
- const {
8
- createConnectionMock,
9
- getVoiceConnectionMock,
10
- joinVoiceChannelMock,
11
- entersStateMock,
12
- createAudioPlayerMock,
13
- createAudioResourceMock,
14
- resolveAgentRouteMock,
15
- agentCommandMock,
16
- transcribeAudioFileMock,
17
- textToSpeechStreamMock,
18
- textToSpeechMock,
19
- logVerboseMock,
20
- resolveConfiguredRealtimeVoiceProviderMock,
21
- createRealtimeVoiceBridgeSessionMock,
22
- realtimeSessionMock,
23
- decodeOpusStreamChunksMock,
24
- } = vi.hoisted(() => {
25
- type EventHandler = (...args: unknown[]) => unknown;
26
- type MockConnection = {
27
- destroy: ReturnType<typeof vi.fn>;
28
- subscribe: ReturnType<typeof vi.fn>;
29
- on: ReturnType<typeof vi.fn>;
30
- off: ReturnType<typeof vi.fn>;
31
- receiver: {
32
- speaking: {
33
- on: ReturnType<typeof vi.fn>;
34
- off: ReturnType<typeof vi.fn>;
35
- };
36
- subscribe: ReturnType<typeof vi.fn>;
37
- };
38
- state: {
39
- status: string;
40
- networking: {
41
- state: {
42
- code: string;
43
- dave: {
44
- session: {
45
- setPassthroughMode: ReturnType<typeof vi.fn>;
46
- };
47
- };
48
- };
49
- };
50
- };
51
- daveSetPassthroughMode: ReturnType<typeof vi.fn>;
52
- handlers: Map<string, EventHandler>;
53
- };
54
-
55
- const createConnectionMock = (): MockConnection => {
56
- const handlers = new Map<string, EventHandler>();
57
- const daveSetPassthroughMode = vi.fn();
58
- const connection: MockConnection = {
59
- destroy: vi.fn(),
60
- subscribe: vi.fn(),
61
- on: vi.fn((event: string, handler: EventHandler) => {
62
- handlers.set(event, handler);
63
- }),
64
- off: vi.fn(),
65
- receiver: {
66
- speaking: {
67
- on: vi.fn(),
68
- off: vi.fn(),
69
- },
70
- subscribe: vi.fn(() => ({
71
- on: vi.fn(),
72
- destroy: vi.fn(),
73
- [Symbol.asyncIterator]: async function* () {},
74
- })),
75
- },
76
- state: {
77
- status: "ready",
78
- networking: {
79
- state: {
80
- code: "networking-ready",
81
- dave: {
82
- session: {
83
- setPassthroughMode: daveSetPassthroughMode,
84
- },
85
- },
86
- },
87
- },
88
- },
89
- daveSetPassthroughMode,
90
- handlers,
91
- };
92
- return connection;
93
- };
94
-
95
- const getVoiceConnectionMock = vi.fn((): MockConnection | undefined => undefined);
96
-
97
- const realtimeSessionMock = {
98
- bridge: { supportsToolResultContinuation: true },
99
- acknowledgeMark: vi.fn(),
100
- close: vi.fn(),
101
- connect: vi.fn(async () => undefined),
102
- sendAudio: vi.fn(),
103
- sendUserMessage: vi.fn(),
104
- handleBargeIn: vi.fn(),
105
- setMediaTimestamp: vi.fn(),
106
- submitToolResult: vi.fn(),
107
- triggerGreeting: vi.fn(),
108
- };
109
-
110
- return {
111
- createConnectionMock,
112
- getVoiceConnectionMock,
113
- joinVoiceChannelMock: vi.fn(() => createConnectionMock()),
114
- entersStateMock: vi.fn(async (_target?: unknown, _state?: string, _timeoutMs?: number) => {
115
- return undefined;
116
- }),
117
- createAudioResourceMock: vi.fn(),
118
- createAudioPlayerMock: vi.fn(() => ({
119
- on: vi.fn(),
120
- off: vi.fn(),
121
- stop: vi.fn(),
122
- play: vi.fn(),
123
- state: { status: "idle" },
124
- })),
125
- resolveAgentRouteMock: vi.fn(() => ({ agentId: "agent-1", sessionKey: "discord:g1:c1" })),
126
- agentCommandMock: vi.fn(
127
- async (
128
- _opts?: unknown,
129
- _runtime?: unknown,
130
- ): Promise<{ payloads?: Array<{ text?: string }> }> => ({ payloads: [] }),
131
- ),
132
- transcribeAudioFileMock: vi.fn(async () => ({ text: "hello from voice" })),
133
- textToSpeechStreamMock: vi.fn(
134
- async (): Promise<unknown> => ({ success: false, error: "stream unavailable" }),
135
- ),
136
- textToSpeechMock: vi.fn(async () => ({ success: true, audioPath: "/tmp/voice.mp3" })),
137
- logVerboseMock: vi.fn(),
138
- resolveConfiguredRealtimeVoiceProviderMock: vi.fn(() => ({
139
- provider: { id: "openai" },
140
- providerConfig: { model: "gpt-realtime-2", voice: "cedar" },
141
- })),
142
- createRealtimeVoiceBridgeSessionMock: vi.fn((_params?: unknown) => realtimeSessionMock),
143
- realtimeSessionMock,
144
- decodeOpusStreamChunksMock: vi.fn(),
145
- };
146
- });
147
-
148
- vi.mock("./sdk-runtime.js", () => ({
149
- loadDiscordVoiceSdk: () => ({
150
- AudioPlayerStatus: { Playing: "playing", Idle: "idle" },
151
- EndBehaviorType: { AfterSilence: "AfterSilence", Manual: "Manual" },
152
- NetworkingStatusCode: { Ready: "networking-ready", Resuming: "networking-resuming" },
153
- StreamType: { Raw: "raw" },
154
- VoiceConnectionStatus: {
155
- Ready: "ready",
156
- Disconnected: "disconnected",
157
- Destroyed: "destroyed",
158
- Signalling: "signalling",
159
- Connecting: "connecting",
160
- },
161
- createAudioPlayer: createAudioPlayerMock,
162
- createAudioResource: createAudioResourceMock,
163
- entersState: entersStateMock,
164
- getVoiceConnection: getVoiceConnectionMock,
165
- joinVoiceChannel: joinVoiceChannelMock,
166
- }),
167
- }));
168
-
169
- vi.mock("klaw/plugin-sdk/routing", async () => {
170
- const actual =
171
- await vi.importActual<typeof import("klaw/plugin-sdk/routing")>("klaw/plugin-sdk/routing");
172
- return {
173
- ...actual,
174
- resolveAgentRoute: resolveAgentRouteMock,
175
- };
176
- });
177
-
178
- vi.mock("klaw/plugin-sdk/agent-runtime", async () => {
179
- const actual = await vi.importActual<typeof import("klaw/plugin-sdk/agent-runtime")>(
180
- "klaw/plugin-sdk/agent-runtime",
181
- );
182
- return {
183
- ...actual,
184
- agentCommandFromIngress: agentCommandMock,
185
- };
186
- });
187
-
188
- vi.mock("klaw/plugin-sdk/runtime-env", async () => {
189
- const actual = await vi.importActual<typeof import("klaw/plugin-sdk/runtime-env")>(
190
- "klaw/plugin-sdk/runtime-env",
191
- );
192
- return {
193
- ...actual,
194
- logVerbose: logVerboseMock,
195
- };
196
- });
197
-
198
- vi.mock("klaw/plugin-sdk/realtime-voice", async () => {
199
- const actual = await vi.importActual<typeof import("klaw/plugin-sdk/realtime-voice")>(
200
- "klaw/plugin-sdk/realtime-voice",
201
- );
202
- return {
203
- ...actual,
204
- createRealtimeVoiceBridgeSession: createRealtimeVoiceBridgeSessionMock,
205
- resolveConfiguredRealtimeVoiceProvider: resolveConfiguredRealtimeVoiceProviderMock,
206
- };
207
- });
208
-
209
- vi.mock("./audio.js", async () => {
210
- const actual = await vi.importActual<typeof import("./audio.js")>("./audio.js");
211
- return {
212
- ...actual,
213
- decodeOpusStreamChunks: decodeOpusStreamChunksMock,
214
- };
215
- });
216
-
217
- vi.mock("../runtime.js", () => ({
218
- getDiscordRuntime: () => ({
219
- mediaUnderstanding: {
220
- transcribeAudioFile: transcribeAudioFileMock,
221
- },
222
- tts: {
223
- textToSpeechStream: textToSpeechStreamMock,
224
- textToSpeech: textToSpeechMock,
225
- },
226
- }),
227
- }));
228
-
229
- let managerModule: typeof import("./manager.js");
230
-
231
- function createClient() {
232
- return {
233
- fetchChannel: vi.fn(async (channelId: string) => ({
234
- id: channelId,
235
- guildId: "g1",
236
- guild: { id: "g1", name: "Guild One" },
237
- type: ChannelType.GuildVoice,
238
- })),
239
- fetchGuild: vi.fn(async (guildId: string) => ({
240
- id: guildId,
241
- name: "Guild One",
242
- })),
243
- getPlugin: vi.fn(() => ({
244
- getGatewayAdapterCreator: vi.fn(() => vi.fn()),
245
- })),
246
- fetchMember: vi.fn(),
247
- fetchUser: vi.fn(),
248
- };
249
- }
250
-
251
- function createRuntime() {
252
- return {
253
- log: vi.fn(),
254
- error: vi.fn(),
255
- exit: vi.fn(),
256
- };
257
- }
258
-
259
- describe("DiscordVoiceManager", () => {
260
- beforeAll(async () => {
261
- managerModule = await import("./manager.js");
262
- });
263
-
264
- beforeEach(() => {
265
- getVoiceConnectionMock.mockReset();
266
- getVoiceConnectionMock.mockReturnValue(undefined);
267
- joinVoiceChannelMock.mockReset();
268
- joinVoiceChannelMock.mockImplementation(() => createConnectionMock());
269
- entersStateMock.mockReset();
270
- entersStateMock.mockResolvedValue(undefined);
271
- createAudioPlayerMock.mockClear();
272
- resolveAgentRouteMock.mockReset();
273
- resolveAgentRouteMock.mockReturnValue({ agentId: "agent-1", sessionKey: "discord:g1:c1" });
274
- agentCommandMock.mockReset();
275
- agentCommandMock.mockResolvedValue({ payloads: [] });
276
- transcribeAudioFileMock.mockReset();
277
- transcribeAudioFileMock.mockResolvedValue({ text: "hello from voice" });
278
- textToSpeechStreamMock.mockReset();
279
- textToSpeechStreamMock.mockResolvedValue({ success: false, error: "stream unavailable" });
280
- textToSpeechMock.mockReset();
281
- textToSpeechMock.mockResolvedValue({ success: true, audioPath: "/tmp/voice.mp3" });
282
- logVerboseMock.mockClear();
283
- createAudioResourceMock.mockClear();
284
- realtimeSessionMock.close.mockClear();
285
- realtimeSessionMock.connect.mockClear();
286
- realtimeSessionMock.sendAudio.mockClear();
287
- realtimeSessionMock.sendUserMessage.mockClear();
288
- realtimeSessionMock.handleBargeIn.mockClear();
289
- realtimeSessionMock.setMediaTimestamp.mockClear();
290
- realtimeSessionMock.submitToolResult.mockClear();
291
- createRealtimeVoiceBridgeSessionMock.mockClear();
292
- createRealtimeVoiceBridgeSessionMock.mockReturnValue(realtimeSessionMock);
293
- resolveConfiguredRealtimeVoiceProviderMock.mockClear();
294
- resolveConfiguredRealtimeVoiceProviderMock.mockReturnValue({
295
- provider: { id: "openai" },
296
- providerConfig: { model: "gpt-realtime-2", voice: "cedar" },
297
- });
298
- decodeOpusStreamChunksMock.mockReset();
299
- decodeOpusStreamChunksMock.mockResolvedValue(undefined);
300
- });
301
-
302
- const createManager = (
303
- discordConfig: ConstructorParameters<
304
- typeof managerModule.DiscordVoiceManager
305
- >[0]["discordConfig"] = { voice: { enabled: true, mode: "stt-tts" } },
306
- clientOverride?: ReturnType<typeof createClient>,
307
- cfgOverride: ConstructorParameters<typeof managerModule.DiscordVoiceManager>[0]["cfg"] = {},
308
- ) =>
309
- new managerModule.DiscordVoiceManager({
310
- client: (clientOverride ?? createClient()) as never,
311
- cfg: cfgOverride,
312
- discordConfig,
313
- accountId: "default",
314
- runtime: createRuntime(),
315
- });
316
-
317
- const expectConnectedStatus = (
318
- manager: InstanceType<typeof managerModule.DiscordVoiceManager>,
319
- channelId: string,
320
- ) => {
321
- expect(manager.status()).toEqual([
322
- {
323
- ok: true,
324
- message: `connected: guild g1 channel ${channelId}`,
325
- guildId: "g1",
326
- channelId,
327
- },
328
- ]);
329
- };
330
-
331
- const getSessionEntry = (
332
- manager: InstanceType<typeof managerModule.DiscordVoiceManager>,
333
- guildId = "g1",
334
- ) => {
335
- const entry = (manager as unknown as { sessions: Map<string, unknown> }).sessions.get(guildId);
336
- if (!entry) {
337
- throw new Error(`expected Discord voice session for guild ${guildId}`);
338
- }
339
- return entry;
340
- };
341
-
342
- const getLastAudioPlayer = () => {
343
- const player = createAudioPlayerMock.mock.results.at(-1)?.value as
344
- | {
345
- on: ReturnType<typeof vi.fn>;
346
- play: ReturnType<typeof vi.fn>;
347
- state: { status: string };
348
- stop: ReturnType<typeof vi.fn>;
349
- }
350
- | undefined;
351
- if (!player) {
352
- throw new Error("expected Discord voice audio player to be created");
353
- }
354
- return player;
355
- };
356
-
357
- type MockCallSource = {
358
- mock: {
359
- calls: ArrayLike<ReadonlyArray<unknown>>;
360
- };
361
- };
362
-
363
- const requireRecord = (value: unknown, label: string): Record<string, unknown> => {
364
- if (!value || typeof value !== "object") {
365
- throw new Error(`expected ${label}`);
366
- }
367
- return value as Record<string, unknown>;
368
- };
369
-
370
- const mockCall = (source: MockCallSource, index: number, label: string) => {
371
- const call = source.mock.calls[index];
372
- if (!call) {
373
- throw new Error(`expected mock call: ${label}`);
374
- }
375
- return call;
376
- };
377
-
378
- const lastMockCall = (source: MockCallSource, label: string) => {
379
- const calls = Array.from(source.mock.calls);
380
- const call = calls[calls.length - 1];
381
- if (!call) {
382
- throw new Error(`expected mock call: ${label}`);
383
- }
384
- return call;
385
- };
386
-
387
- const expectOffEventWithFunction = (source: MockCallSource, event: string) => {
388
- const call = Array.from(source.mock.calls).find((candidate) => candidate[0] === event);
389
- if (!call) {
390
- throw new Error(`Expected ${event} listener removal`);
391
- }
392
- expect(call[1], `${event} listener`).toBeTypeOf("function");
393
- };
394
-
395
- const lastAgentCommandArgs = () =>
396
- requireRecord(
397
- lastMockCall(agentCommandMock as unknown as MockCallSource, "agent command")[0],
398
- "agent command args",
399
- );
400
-
401
- const agentCommandArgsAt = (index: number) =>
402
- requireRecord(
403
- mockCall(agentCommandMock as unknown as MockCallSource, index, `agent command ${index}`)[0],
404
- `agent command args ${index}`,
405
- );
406
-
407
- const lastRealtimeBridgeParams = () =>
408
- requireRecord(
409
- lastMockCall(
410
- createRealtimeVoiceBridgeSessionMock as unknown as MockCallSource,
411
- "realtime bridge",
412
- )[0],
413
- "realtime bridge params",
414
- );
415
-
416
- const lastAudioResourceInput = () =>
417
- lastMockCall(createAudioResourceMock as unknown as MockCallSource, "audio resource")[0];
418
-
419
- const lastTtsArgs = () =>
420
- requireRecord(
421
- lastMockCall(textToSpeechMock as unknown as MockCallSource, "tts call")[0],
422
- "tts args",
423
- );
424
-
425
- const lastTtsStreamArgs = () =>
426
- requireRecord(
427
- lastMockCall(textToSpeechStreamMock as unknown as MockCallSource, "tts stream call")[0],
428
- "tts stream args",
429
- );
430
-
431
- const sentUserMessages = () =>
432
- Array.from(realtimeSessionMock.sendUserMessage.mock.calls).map(([message]) => String(message));
433
-
434
- const expectUserMessageIncludes = (text: string) => {
435
- expect(
436
- sentUserMessages().some((message) => message.includes(text)),
437
- text,
438
- ).toBe(true);
439
- };
440
-
441
- const expectUserMessageNotIncludes = (text: string) => {
442
- expect(
443
- sentUserMessages().some((message) => message.includes(text)),
444
- text,
445
- ).toBe(false);
446
- };
447
-
448
- const emitDecryptFailure = (manager: InstanceType<typeof managerModule.DiscordVoiceManager>) => {
449
- const entry = getSessionEntry(manager);
450
- (
451
- manager as unknown as { handleReceiveError: (e: unknown, err: unknown) => void }
452
- ).handleReceiveError(
453
- entry,
454
- new Error("Failed to decrypt: DecryptionFailed(UnencryptedWhenPassthroughDisabled)"),
455
- );
456
- };
457
-
458
- it("rejects joins when Discord voice config is absent", async () => {
459
- const manager = createManager({});
460
-
461
- const result = await manager.join({ guildId: "g1", channelId: "1001" });
462
- expect(result.ok).toBe(false);
463
- expect(result.message).toBe("Discord voice is disabled (channels.discord.voice.enabled).");
464
-
465
- expect(joinVoiceChannelMock).not.toHaveBeenCalled();
466
- });
467
-
468
- type ProcessSegmentInvoker = {
469
- processSegment: (params: {
470
- entry: unknown;
471
- wavPath: string;
472
- userId: string;
473
- durationSeconds: number;
474
- }) => Promise<void>;
475
- };
476
-
477
- const processVoiceSegment = async (
478
- manager: InstanceType<typeof managerModule.DiscordVoiceManager>,
479
- userId: string,
480
- ) =>
481
- await (manager as unknown as ProcessSegmentInvoker).processSegment({
482
- entry: {
483
- guildId: "g1",
484
- channelId: "1001",
485
- sessionChannelId: "1001",
486
- voiceSessionKey: "discord:g1:1001",
487
- route: { sessionKey: "discord:g1:1001", agentId: "agent-1" },
488
- connection: createConnectionMock(),
489
- player: createAudioPlayerMock(),
490
- playbackQueue: Promise.resolve(),
491
- processingQueue: Promise.resolve(),
492
- capture: createVoiceCaptureState(),
493
- receiveRecovery: createVoiceReceiveRecoveryState(),
494
- },
495
- wavPath: "/tmp/test.wav",
496
- userId,
497
- durationSeconds: 1.2,
498
- });
499
-
500
- it("keeps the new session when an old disconnected handler fires", async () => {
501
- const oldConnection = createConnectionMock();
502
- const newConnection = createConnectionMock();
503
- joinVoiceChannelMock.mockReturnValueOnce(oldConnection).mockReturnValueOnce(newConnection);
504
- entersStateMock.mockImplementation(async (target: unknown, status?: string) => {
505
- if (target === oldConnection && (status === "signalling" || status === "connecting")) {
506
- throw new Error("old disconnected");
507
- }
508
- return undefined;
509
- });
510
-
511
- const manager = createManager();
512
-
513
- await manager.join({ guildId: "g1", channelId: "1001" });
514
- await manager.join({ guildId: "g1", channelId: "1002" });
515
-
516
- const oldDisconnected = oldConnection.handlers.get("disconnected");
517
- expect(oldDisconnected).toBeTypeOf("function");
518
- await oldDisconnected?.();
519
-
520
- expectConnectedStatus(manager, "1002");
521
- });
522
-
523
- it("keeps the new session when an old destroyed handler fires", async () => {
524
- const oldConnection = createConnectionMock();
525
- const newConnection = createConnectionMock();
526
- joinVoiceChannelMock.mockReturnValueOnce(oldConnection).mockReturnValueOnce(newConnection);
527
-
528
- const manager = createManager();
529
-
530
- await manager.join({ guildId: "g1", channelId: "1001" });
531
- await manager.join({ guildId: "g1", channelId: "1002" });
532
-
533
- const oldDestroyed = oldConnection.handlers.get("destroyed");
534
- expect(oldDestroyed).toBeTypeOf("function");
535
- oldDestroyed?.();
536
-
537
- expectConnectedStatus(manager, "1002");
538
- });
539
-
540
- it("destroys stale tracked voice connections before joining", async () => {
541
- const staleConnection = createConnectionMock();
542
- const connection = createConnectionMock();
543
- getVoiceConnectionMock.mockReturnValueOnce(staleConnection);
544
- joinVoiceChannelMock.mockReturnValueOnce(connection);
545
- const manager = createManager();
546
-
547
- await manager.join({ guildId: "g1", channelId: "1001" });
548
-
549
- expect(getVoiceConnectionMock).toHaveBeenCalledWith("g1");
550
- expect(staleConnection.destroy).toHaveBeenCalledTimes(1);
551
- expectConnectedStatus(manager, "1001");
552
- });
553
-
554
- it("autoJoin uses the last configured channel for duplicate guild entries", async () => {
555
- const manager = createManager({
556
- voice: {
557
- enabled: true,
558
- autoJoin: [
559
- { guildId: "g1", channelId: "1001" },
560
- { guildId: "g1", channelId: "1002" },
561
- ],
562
- },
563
- });
564
-
565
- await manager.autoJoin();
566
-
567
- expect(joinVoiceChannelMock).toHaveBeenCalledTimes(1);
568
- const joinOptions = requireRecord(
569
- mockCall(joinVoiceChannelMock as unknown as MockCallSource, 0, "join voice call")[0],
570
- "join voice options",
571
- );
572
- expect(joinOptions.guildId).toBe("g1");
573
- expect(joinOptions.channelId).toBe("1002");
574
- expectConnectedStatus(manager, "1002");
575
- });
576
-
577
- it("suppresses repeated autoJoin attempts after fatal realtime startup failures", async () => {
578
- realtimeSessionMock.connect.mockRejectedValueOnce(new Error("Incorrect API key provided"));
579
- const manager = createManager({
580
- voice: {
581
- enabled: true,
582
- mode: "agent-proxy",
583
- autoJoin: [{ guildId: "g1", channelId: "1001" }],
584
- },
585
- });
586
-
587
- await manager.autoJoin();
588
- await manager.autoJoin();
589
-
590
- expect(joinVoiceChannelMock).toHaveBeenCalledTimes(1);
591
- expect(realtimeSessionMock.connect).toHaveBeenCalledTimes(1);
592
- expect(manager.status()).toStrictEqual([]);
593
- });
594
-
595
- it("rejects joins outside configured allowed voice channels", async () => {
596
- const manager = createManager({
597
- voice: {
598
- enabled: true,
599
- mode: "stt-tts",
600
- allowedChannels: [{ guildId: "g1", channelId: "1001" }],
601
- },
602
- });
603
-
604
- const result = await manager.join({ guildId: "g1", channelId: "1002" });
605
-
606
- expect(result.ok).toBe(false);
607
- expect(result.message).toBe(
608
- "<#1002> is not allowed by channels.discord.voice.allowedChannels.",
609
- );
610
- expect(joinVoiceChannelMock).not.toHaveBeenCalled();
611
- });
612
-
613
- it("allows joins inside configured allowed voice channels", async () => {
614
- const manager = createManager({
615
- voice: {
616
- enabled: true,
617
- mode: "stt-tts",
618
- allowedChannels: [{ guildId: "g1", channelId: "1001" }],
619
- },
620
- });
621
-
622
- const result = await manager.join({ guildId: "g1", channelId: "1001" });
623
-
624
- expect(result.ok).toBe(true);
625
- expectConnectedStatus(manager, "1001");
626
- });
627
-
628
- it("treats an empty allowed voice channel list as deny-all", async () => {
629
- const manager = createManager({
630
- voice: {
631
- enabled: true,
632
- mode: "stt-tts",
633
- allowedChannels: [],
634
- },
635
- });
636
-
637
- const result = await manager.join({ guildId: "g1", channelId: "1001" });
638
-
639
- expect(result.ok).toBe(false);
640
- expect(joinVoiceChannelMock).not.toHaveBeenCalled();
641
- });
642
-
643
- it("leaves and rejoins the configured target when Discord moves the bot outside allowed voice channels", async () => {
644
- const manager = createManager({
645
- voice: {
646
- enabled: true,
647
- mode: "stt-tts",
648
- autoJoin: [{ guildId: "g1", channelId: "1001" }],
649
- allowedChannels: [{ guildId: "g1", channelId: "1001" }],
650
- },
651
- });
652
- manager.setBotUserId("bot-user");
653
- await manager.join({ guildId: "g1", channelId: "1001" });
654
-
655
- await manager.handleVoiceStateUpdate({
656
- guild_id: "g1",
657
- user_id: "bot-user",
658
- channel_id: "1002",
659
- } as never);
660
-
661
- expect(joinVoiceChannelMock).toHaveBeenCalledTimes(2);
662
- expectConnectedStatus(manager, "1001");
663
- });
664
-
665
- it("skips destroying stale tracked voice connections that are already destroyed", async () => {
666
- const staleConnection = createConnectionMock();
667
- staleConnection.state.status = "destroyed";
668
- staleConnection.destroy.mockImplementation(() => {
669
- throw new Error("Cannot destroy VoiceConnection - it has already been destroyed");
670
- });
671
- getVoiceConnectionMock.mockReturnValueOnce(staleConnection);
672
- joinVoiceChannelMock.mockReturnValueOnce(createConnectionMock());
673
- const manager = createManager();
674
-
675
- const result = await manager.join({ guildId: "g1", channelId: "1001" });
676
- expect(result.ok).toBe(true);
677
-
678
- expect(staleConnection.destroy).not.toHaveBeenCalled();
679
- });
680
-
681
- it("skips destroying an already destroyed voice connection on leave", async () => {
682
- const connection = createConnectionMock();
683
- connection.destroy.mockImplementation(() => {
684
- throw new Error("Cannot destroy VoiceConnection - it has already been destroyed");
685
- });
686
- joinVoiceChannelMock.mockReturnValueOnce(connection);
687
- const manager = createManager();
688
-
689
- await manager.join({ guildId: "g1", channelId: "1001" });
690
- connection.state.status = "destroyed";
691
-
692
- const result = await manager.leave({ guildId: "g1" });
693
- expect(result.ok).toBe(true);
694
- expect(connection.destroy).not.toHaveBeenCalled();
695
- });
696
-
697
- it("removes voice listeners on leave", async () => {
698
- const connection = createConnectionMock();
699
- joinVoiceChannelMock.mockReturnValueOnce(connection);
700
- const manager = createManager();
701
-
702
- await manager.join({ guildId: "g1", channelId: "1001" });
703
- await manager.leave({ guildId: "g1" });
704
-
705
- const player = createAudioPlayerMock.mock.results[0]?.value;
706
- expectOffEventWithFunction(connection.receiver.speaking.off, "start");
707
- expectOffEventWithFunction(connection.receiver.speaking.off, "end");
708
- expectOffEventWithFunction(connection.off, "disconnected");
709
- expectOffEventWithFunction(connection.off, "destroyed");
710
- expectOffEventWithFunction(player.off, "error");
711
- });
712
-
713
- it("ignores new capture while playback is running", async () => {
714
- const connection = createConnectionMock();
715
- joinVoiceChannelMock.mockReturnValueOnce(connection);
716
- const manager = createManager();
717
-
718
- await manager.join({ guildId: "g1", channelId: "1001" });
719
-
720
- const player = getLastAudioPlayer();
721
- const entry = getSessionEntry(manager);
722
- player.state.status = "playing";
723
-
724
- await (
725
- manager as unknown as {
726
- handleSpeakingStart: (entry: unknown, userId: string) => Promise<void>;
727
- }
728
- ).handleSpeakingStart(entry, "u1");
729
-
730
- expect(player.stop).not.toHaveBeenCalled();
731
- expect(connection.receiver.subscribe).not.toHaveBeenCalled();
732
- });
733
-
734
- it("allows configured realtime barge-in when provider input interruption is disabled", async () => {
735
- const connection = createConnectionMock();
736
- joinVoiceChannelMock.mockReturnValueOnce(connection);
737
- const manager = createManager({
738
- groupPolicy: "open",
739
- allowFrom: ["discord:u1"],
740
- voice: {
741
- enabled: true,
742
- mode: "bidi",
743
- realtime: {
744
- provider: "openai",
745
- bargeIn: true,
746
- providers: {
747
- openai: {
748
- interruptResponseOnInputAudio: false,
749
- },
750
- },
751
- },
752
- },
753
- });
754
-
755
- await manager.join({ guildId: "g1", channelId: "1001" });
756
-
757
- const player = getLastAudioPlayer();
758
- const entry = getSessionEntry(manager);
759
- const bridgeParams = lastRealtimeBridgeParams() as
760
- | {
761
- audioSink?: {
762
- sendAudio: (audio: Buffer) => void;
763
- };
764
- onEvent?: (event: { direction: "server"; type: string }) => void;
765
- }
766
- | undefined;
767
- player.state.status = "playing";
768
- bridgeParams?.audioSink?.sendAudio(Buffer.alloc(480));
769
-
770
- await (
771
- manager as unknown as {
772
- handleSpeakingStart: (entry: unknown, userId: string) => Promise<void>;
773
- }
774
- ).handleSpeakingStart(entry, "u1");
775
-
776
- expect(realtimeSessionMock.handleBargeIn).toHaveBeenCalled();
777
- expect(player.stop).not.toHaveBeenCalled();
778
- const subscribeCall = lastMockCall(
779
- connection.receiver.subscribe as unknown as MockCallSource,
780
- "receiver subscribe",
781
- );
782
- expect(subscribeCall?.[0]).toBe("u1");
783
- expect(requireRecord(subscribeCall?.[1], "subscribe options").end).toBeTypeOf("object");
784
- bridgeParams?.onEvent?.({ direction: "server", type: "response.done" });
785
- });
786
-
787
- it("interrupts realtime playback when an already-active speaker keeps talking", async () => {
788
- const connection = createConnectionMock();
789
- joinVoiceChannelMock.mockReturnValueOnce(connection);
790
- const manager = createManager({
791
- groupPolicy: "open",
792
- allowFrom: ["discord:u1"],
793
- voice: {
794
- enabled: true,
795
- mode: "bidi",
796
- realtime: {
797
- provider: "openai",
798
- bargeIn: true,
799
- providers: {
800
- openai: {
801
- interruptResponseOnInputAudio: false,
802
- },
803
- },
804
- },
805
- },
806
- });
807
-
808
- await manager.join({ guildId: "g1", channelId: "1001" });
809
-
810
- const entry = getSessionEntry(manager) as {
811
- realtime?: {
812
- beginSpeakerTurn: (
813
- context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string },
814
- userId: string,
815
- ) => { close: () => void; sendInputAudio: (audio: Buffer) => void };
816
- };
817
- };
818
- const bridgeParams = lastRealtimeBridgeParams() as
819
- | {
820
- audioSink?: {
821
- sendAudio: (audio: Buffer) => void;
822
- };
823
- onEvent?: (event: { direction: "server"; type: string }) => void;
824
- }
825
- | undefined;
826
- const player = getLastAudioPlayer();
827
- const turn = entry.realtime?.beginSpeakerTurn(
828
- { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
829
- "u1",
830
- );
831
-
832
- bridgeParams?.audioSink?.sendAudio(Buffer.alloc(480));
833
- turn?.sendInputAudio(Buffer.alloc(3840));
834
-
835
- expect(realtimeSessionMock.setMediaTimestamp).toHaveBeenCalledWith(0);
836
- expect(realtimeSessionMock.setMediaTimestamp).toHaveBeenCalledWith(10);
837
- expect(realtimeSessionMock.handleBargeIn).toHaveBeenCalled();
838
- const lastTimestampCall = realtimeSessionMock.setMediaTimestamp.mock.invocationCallOrder.at(-1);
839
- const firstBargeInCall = realtimeSessionMock.handleBargeIn.mock.invocationCallOrder[0];
840
- expect(lastTimestampCall).toBeLessThan(firstBargeInCall);
841
- expect(player.stop).not.toHaveBeenCalled();
842
- expect(realtimeSessionMock.sendAudio).toHaveBeenCalled();
843
- bridgeParams?.onEvent?.({ direction: "server", type: "response.done" });
844
- });
845
-
846
- it("does not interrupt realtime provider state when local playback is already idle", async () => {
847
- const manager = createManager({
848
- groupPolicy: "open",
849
- allowFrom: ["discord:u1"],
850
- voice: {
851
- enabled: true,
852
- mode: "bidi",
853
- realtime: {
854
- provider: "openai",
855
- bargeIn: true,
856
- providers: {
857
- openai: {
858
- interruptResponseOnInputAudio: false,
859
- },
860
- },
861
- },
862
- },
863
- });
864
-
865
- await manager.join({ guildId: "g1", channelId: "1001" });
866
-
867
- const entry = getSessionEntry(manager) as {
868
- realtime?: {
869
- beginSpeakerTurn: (
870
- context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string },
871
- userId: string,
872
- ) => { close: () => void; sendInputAudio: (audio: Buffer) => void };
873
- };
874
- };
875
- const player = getLastAudioPlayer();
876
- const turn = entry.realtime?.beginSpeakerTurn(
877
- { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
878
- "u1",
879
- );
880
-
881
- turn?.sendInputAudio(Buffer.alloc(3840));
882
-
883
- expect(realtimeSessionMock.handleBargeIn).not.toHaveBeenCalled();
884
- expect(player.stop).not.toHaveBeenCalled();
885
- expect(realtimeSessionMock.sendAudio).toHaveBeenCalled();
886
- });
887
-
888
- it("sends trailing realtime silence when a speaker turn closes", async () => {
889
- const manager = createManager({
890
- groupPolicy: "open",
891
- allowFrom: ["discord:u1"],
892
- voice: {
893
- enabled: true,
894
- mode: "bidi",
895
- realtime: {
896
- provider: "openai",
897
- providers: {
898
- openai: {
899
- silenceDurationMs: 450,
900
- },
901
- },
902
- },
903
- },
904
- });
905
-
906
- await manager.join({ guildId: "g1", channelId: "1001" });
907
-
908
- const entry = getSessionEntry(manager) as {
909
- realtime?: {
910
- beginSpeakerTurn: (
911
- context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string },
912
- userId: string,
913
- ) => { close: () => void; sendInputAudio: (audio: Buffer) => void };
914
- };
915
- };
916
- const turn = entry.realtime?.beginSpeakerTurn(
917
- { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
918
- "u1",
919
- );
920
-
921
- turn?.sendInputAudio(Buffer.alloc(3840));
922
- turn?.close();
923
-
924
- expect(realtimeSessionMock.sendAudio).toHaveBeenCalledTimes(2);
925
- const trailingSilence = realtimeSessionMock.sendAudio.mock.calls.at(-1)?.[0] as
926
- | Buffer
927
- | undefined;
928
- expect(trailingSilence).toBeInstanceOf(Buffer);
929
- expect(trailingSilence?.length).toBe(33_600);
930
- expect(trailingSilence?.equals(Buffer.alloc(33_600))).toBe(true);
931
- });
932
-
933
- it("clamps configured realtime trailing silence before allocating audio", async () => {
934
- const manager = createManager({
935
- groupPolicy: "open",
936
- allowFrom: ["discord:u1"],
937
- voice: {
938
- enabled: true,
939
- mode: "bidi",
940
- realtime: {
941
- provider: "openai",
942
- providers: {
943
- openai: {
944
- silenceDurationMs: 60_000,
945
- },
946
- },
947
- },
948
- },
949
- });
950
-
951
- await manager.join({ guildId: "g1", channelId: "1001" });
952
-
953
- const entry = getSessionEntry(manager) as {
954
- realtime?: {
955
- beginSpeakerTurn: (
956
- context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string },
957
- userId: string,
958
- ) => { close: () => void; sendInputAudio: (audio: Buffer) => void };
959
- };
960
- };
961
- const turn = entry.realtime?.beginSpeakerTurn(
962
- { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
963
- "u1",
964
- );
965
-
966
- turn?.sendInputAudio(Buffer.alloc(3840));
967
- turn?.close();
968
-
969
- const trailingSilence = realtimeSessionMock.sendAudio.mock.calls.at(-1)?.[0] as
970
- | Buffer
971
- | undefined;
972
- expect(trailingSilence).toBeInstanceOf(Buffer);
973
- expect(trailingSilence?.length).toBe(144_000);
974
- expect(trailingSilence?.equals(Buffer.alloc(144_000))).toBe(true);
975
- });
976
-
977
- it("ignores realtime capture during playback when barge-in is disabled", async () => {
978
- const connection = createConnectionMock();
979
- joinVoiceChannelMock.mockReturnValueOnce(connection);
980
- const manager = createManager({
981
- groupPolicy: "open",
982
- allowFrom: ["discord:u1"],
983
- voice: {
984
- enabled: true,
985
- mode: "bidi",
986
- realtime: {
987
- provider: "openai",
988
- bargeIn: false,
989
- },
990
- },
991
- });
992
-
993
- await manager.join({ guildId: "g1", channelId: "1001" });
994
-
995
- const player = getLastAudioPlayer();
996
- const entry = getSessionEntry(manager);
997
- player.state.status = "playing";
998
-
999
- await (
1000
- manager as unknown as {
1001
- handleSpeakingStart: (entry: unknown, userId: string) => Promise<void>;
1002
- }
1003
- ).handleSpeakingStart(entry, "u1");
1004
-
1005
- expect(realtimeSessionMock.handleBargeIn).not.toHaveBeenCalled();
1006
- expect(player.stop).not.toHaveBeenCalled();
1007
- expect(connection.receiver.subscribe).not.toHaveBeenCalled();
1008
- });
1009
-
1010
- it("passes DAVE options to joinVoiceChannel", async () => {
1011
- const manager = createManager({
1012
- voice: {
1013
- daveEncryption: false,
1014
- decryptionFailureTolerance: 8,
1015
- },
1016
- });
1017
-
1018
- await manager.join({ guildId: "g1", channelId: "1001" });
1019
-
1020
- const joinOptions = requireRecord(
1021
- mockCall(joinVoiceChannelMock as unknown as MockCallSource, 0, "join voice call")[0],
1022
- "join voice options",
1023
- );
1024
- expect(joinOptions.daveEncryption).toBe(false);
1025
- expect(joinOptions.decryptionFailureTolerance).toBe(8);
1026
- });
1027
-
1028
- it("uses the default timeout for initial voice connection readiness", async () => {
1029
- const connection = createConnectionMock();
1030
- joinVoiceChannelMock.mockReturnValueOnce(connection);
1031
- const manager = createManager();
1032
-
1033
- await manager.join({ guildId: "g1", channelId: "1001" });
1034
-
1035
- expect(entersStateMock).toHaveBeenCalledWith(connection, "ready", 30_000);
1036
- });
1037
-
1038
- it("uses configured voice connection and reconnect timeouts", async () => {
1039
- const connection = createConnectionMock();
1040
- joinVoiceChannelMock.mockReturnValueOnce(connection);
1041
- const manager = createManager({
1042
- voice: {
1043
- connectTimeoutMs: 45_000,
1044
- reconnectGraceMs: 20_000,
1045
- },
1046
- });
1047
-
1048
- await manager.join({ guildId: "g1", channelId: "1001" });
1049
-
1050
- expect(entersStateMock).toHaveBeenCalledWith(connection, "ready", 45_000);
1051
-
1052
- entersStateMock.mockClear();
1053
- entersStateMock.mockRejectedValueOnce(new Error("still disconnected"));
1054
- entersStateMock.mockRejectedValueOnce(new Error("still disconnected"));
1055
-
1056
- const disconnected = connection.handlers.get("disconnected");
1057
- expect(disconnected).toBeTypeOf("function");
1058
- await disconnected?.();
1059
-
1060
- expect(entersStateMock).toHaveBeenCalledWith(connection, "signalling", 20_000);
1061
- expect(entersStateMock).toHaveBeenCalledWith(connection, "connecting", 20_000);
1062
- expect(connection.destroy).toHaveBeenCalledTimes(1);
1063
- expect(manager.status()).toStrictEqual([]);
1064
- });
1065
-
1066
- it("uses the default reconnect grace before destroying disconnected sessions", async () => {
1067
- const connection = createConnectionMock();
1068
- joinVoiceChannelMock.mockReturnValueOnce(connection);
1069
- const manager = createManager();
1070
-
1071
- await manager.join({ guildId: "g1", channelId: "1001" });
1072
-
1073
- entersStateMock.mockClear();
1074
- entersStateMock.mockRejectedValueOnce(new Error("still disconnected"));
1075
- entersStateMock.mockRejectedValueOnce(new Error("still disconnected"));
1076
-
1077
- const disconnected = connection.handlers.get("disconnected");
1078
- expect(disconnected).toBeTypeOf("function");
1079
- await disconnected?.();
1080
-
1081
- expect(entersStateMock).toHaveBeenCalledWith(connection, "signalling", 15_000);
1082
- expect(entersStateMock).toHaveBeenCalledWith(connection, "connecting", 15_000);
1083
- expect(connection.destroy).toHaveBeenCalledTimes(1);
1084
- expect(manager.status()).toStrictEqual([]);
1085
- });
1086
-
1087
- it("closes realtime sessions when disconnected recovery destroys the connection", async () => {
1088
- const connection = createConnectionMock();
1089
- joinVoiceChannelMock.mockReturnValueOnce(connection);
1090
- const manager = createManager({
1091
- groupPolicy: "open",
1092
- voice: {
1093
- enabled: true,
1094
- mode: "agent-proxy",
1095
- realtime: { provider: "openai" },
1096
- },
1097
- });
1098
-
1099
- await manager.join({ guildId: "g1", channelId: "1001" });
1100
-
1101
- entersStateMock.mockClear();
1102
- entersStateMock.mockRejectedValueOnce(new Error("still disconnected"));
1103
- entersStateMock.mockRejectedValueOnce(new Error("still disconnected"));
1104
-
1105
- const disconnected = connection.handlers.get("disconnected");
1106
- expect(disconnected).toBeTypeOf("function");
1107
- await disconnected?.();
1108
-
1109
- expect(realtimeSessionMock.close).toHaveBeenCalledTimes(1);
1110
- expect(connection.destroy).toHaveBeenCalledTimes(1);
1111
- expect(manager.status()).toStrictEqual([]);
1112
- });
1113
-
1114
- it("closes realtime sessions when Discord destroys the connection", async () => {
1115
- const connection = createConnectionMock();
1116
- joinVoiceChannelMock.mockReturnValueOnce(connection);
1117
- const manager = createManager({
1118
- groupPolicy: "open",
1119
- voice: {
1120
- enabled: true,
1121
- mode: "agent-proxy",
1122
- realtime: { provider: "openai" },
1123
- },
1124
- });
1125
-
1126
- await manager.join({ guildId: "g1", channelId: "1001" });
1127
-
1128
- const destroyed = connection.handlers.get("destroyed");
1129
- expect(destroyed).toBeTypeOf("function");
1130
- destroyed?.();
1131
-
1132
- expect(realtimeSessionMock.close).toHaveBeenCalledTimes(1);
1133
- expect(connection.destroy).not.toHaveBeenCalled();
1134
- expect(manager.status()).toStrictEqual([]);
1135
- });
1136
-
1137
- it("uses agent-proxy realtime voice by default", async () => {
1138
- agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "agent proxy answer" }] });
1139
- const manager = createManager({
1140
- groupPolicy: "open",
1141
- voice: {
1142
- enabled: true,
1143
- model: "openai-codex/gpt-5.5",
1144
- realtime: {
1145
- provider: "openai",
1146
- model: "gpt-realtime-2",
1147
- voice: "cedar",
1148
- debounceMs: 1,
1149
- },
1150
- },
1151
- });
1152
-
1153
- const result = await manager.join({ guildId: "g1", channelId: "1001" });
1154
-
1155
- expect(result.ok).toBe(true);
1156
- const entry = (manager as unknown as { sessions: Map<string, unknown> }).sessions.get("g1") as
1157
- | {
1158
- realtime?: {
1159
- beginSpeakerTurn: (
1160
- context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string },
1161
- userId: string,
1162
- ) => { close: () => void; sendInputAudio: (audio: Buffer) => void };
1163
- };
1164
- }
1165
- | undefined;
1166
- const ownerTurn = entry?.realtime?.beginSpeakerTurn(
1167
- { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
1168
- "u-owner",
1169
- );
1170
- ownerTurn?.sendInputAudio(Buffer.alloc(8));
1171
- const providerOptions = requireRecord(
1172
- lastMockCall(
1173
- resolveConfiguredRealtimeVoiceProviderMock as unknown as MockCallSource,
1174
- "provider resolve",
1175
- )[0],
1176
- "provider resolve options",
1177
- );
1178
- expect(providerOptions.configuredProviderId).toBe("openai");
1179
- expect(providerOptions.defaultModel).toBe("gpt-realtime-2");
1180
- expect(providerOptions.providerConfigOverrides).toEqual({
1181
- model: "gpt-realtime-2",
1182
- voice: "cedar",
1183
- });
1184
- const bridgeParams = lastRealtimeBridgeParams() as
1185
- | {
1186
- autoRespondToAudio?: boolean;
1187
- instructions?: string;
1188
- tools?: Array<{ name: string }>;
1189
- onToolCall?: (
1190
- event: {
1191
- itemId: string;
1192
- callId: string;
1193
- name: string;
1194
- args: unknown;
1195
- },
1196
- session: typeof realtimeSessionMock,
1197
- ) => void;
1198
- }
1199
- | undefined;
1200
- expect(bridgeParams?.autoRespondToAudio).toBe(false);
1201
- expect(bridgeParams?.instructions).toContain("same Klaw agent");
1202
- expect(bridgeParams?.tools?.map((tool) => tool.name)).toContain("klaw_agent_consult");
1203
-
1204
- bridgeParams?.onToolCall?.(
1205
- {
1206
- itemId: "item-1",
1207
- callId: "call-1",
1208
- name: "klaw_agent_consult",
1209
- args: { question: "what did I ask?" },
1210
- },
1211
- realtimeSessionMock,
1212
- );
1213
- await vi.waitFor(() =>
1214
- expect(realtimeSessionMock.submitToolResult).toHaveBeenCalledWith("call-1", {
1215
- text: "agent proxy answer",
1216
- }),
1217
- );
1218
-
1219
- const commandArgs = lastAgentCommandArgs();
1220
- expect(commandArgs.model).toBe("openai-codex/gpt-5.5");
1221
- expect(commandArgs.messageProvider).toBe("discord-voice");
1222
- expect(commandArgs.toolsAllow).toBeUndefined();
1223
- const workingToolResultCall = mockCall(
1224
- realtimeSessionMock.submitToolResult as unknown as MockCallSource,
1225
- 0,
1226
- "working tool result",
1227
- );
1228
- expect(workingToolResultCall?.[0]).toBe("call-1");
1229
- expect(requireRecord(workingToolResultCall?.[1], "working tool result payload").status).toBe(
1230
- "working",
1231
- );
1232
- expect(workingToolResultCall?.[2]).toEqual({ willContinue: true });
1233
- });
1234
-
1235
- it("does not require speaker context for internal exact-speech consults", async () => {
1236
- const manager = createManager({
1237
- groupPolicy: "open",
1238
- voice: {
1239
- enabled: true,
1240
- mode: "agent-proxy",
1241
- realtime: { provider: "openai" },
1242
- },
1243
- });
1244
-
1245
- await manager.join({ guildId: "g1", channelId: "1001" });
1246
- const bridgeParams = lastRealtimeBridgeParams() as
1247
- | {
1248
- onToolCall?: (
1249
- event: {
1250
- itemId: string;
1251
- callId: string;
1252
- name: string;
1253
- args: unknown;
1254
- },
1255
- session: typeof realtimeSessionMock,
1256
- ) => void;
1257
- }
1258
- | undefined;
1259
-
1260
- bridgeParams?.onToolCall?.(
1261
- {
1262
- itemId: "item-exact",
1263
- callId: "call-exact",
1264
- name: "klaw_agent_consult",
1265
- args: {
1266
- question: "Speak the provided exact answer verbatim to the Discord voice channel.",
1267
- context: 'Provided answer text: "already answered"\\nSpoken style: verbatim only',
1268
- },
1269
- },
1270
- realtimeSessionMock,
1271
- );
1272
- bridgeParams?.onToolCall?.(
1273
- {
1274
- itemId: "item-internal",
1275
- callId: "call-internal",
1276
- name: "klaw_agent_consult",
1277
- args: {
1278
- question: [
1279
- "Speak this exact Klaw answer to the Discord voice channel, without adding, removing, or rephrasing words.",
1280
- 'Answer: "direct internal answer"',
1281
- ].join("\n"),
1282
- },
1283
- },
1284
- realtimeSessionMock,
1285
- );
1286
-
1287
- expect(agentCommandMock).not.toHaveBeenCalled();
1288
- expect(realtimeSessionMock.submitToolResult).toHaveBeenCalledTimes(2);
1289
- expect(realtimeSessionMock.submitToolResult).toHaveBeenCalledWith("call-exact", {
1290
- text: "already answered",
1291
- });
1292
- expect(realtimeSessionMock.submitToolResult).toHaveBeenCalledWith("call-internal", {
1293
- text: "direct internal answer",
1294
- });
1295
- });
1296
-
1297
- it("creates a fresh realtime output stream after the Discord player idles", async () => {
1298
- const manager = createManager({
1299
- groupPolicy: "open",
1300
- voice: {
1301
- enabled: true,
1302
- mode: "agent-proxy",
1303
- realtime: { provider: "openai" },
1304
- },
1305
- });
1306
-
1307
- const result = await manager.join({ guildId: "g1", channelId: "1001" });
1308
-
1309
- expect(result.ok).toBe(true);
1310
- const player = getLastAudioPlayer() as {
1311
- on: ReturnType<typeof vi.fn>;
1312
- play: ReturnType<typeof vi.fn>;
1313
- };
1314
- const bridgeParams = lastRealtimeBridgeParams() as
1315
- | {
1316
- audioSink?: {
1317
- sendAudio: (audio: Buffer) => void;
1318
- };
1319
- onEvent?: (event: { direction: "server"; type: string }) => void;
1320
- }
1321
- | undefined;
1322
-
1323
- bridgeParams?.audioSink?.sendAudio(Buffer.alloc(480));
1324
- expect(createAudioResourceMock).not.toHaveBeenCalled();
1325
- expect(player.play).not.toHaveBeenCalled();
1326
- bridgeParams?.onEvent?.({ direction: "server", type: "response.done" });
1327
- expect(createAudioResourceMock).toHaveBeenCalledTimes(1);
1328
- expect(player.play).toHaveBeenCalledTimes(1);
1329
- const firstStream = lastAudioResourceInput() as { writableEnded?: boolean } | undefined;
1330
- expect(firstStream?.writableEnded).toBe(true);
1331
-
1332
- const idleHandler = player.on.mock.calls.find(([event]) => event === "idle")?.[1] as
1333
- | (() => void)
1334
- | undefined;
1335
- expect(idleHandler).toBeTypeOf("function");
1336
- idleHandler?.();
1337
-
1338
- bridgeParams?.audioSink?.sendAudio(Buffer.alloc(480));
1339
- expect(createAudioResourceMock).toHaveBeenCalledTimes(1);
1340
- expect(player.play).toHaveBeenCalledTimes(1);
1341
- bridgeParams?.onEvent?.({ direction: "server", type: "response.done" });
1342
- expect(createAudioResourceMock).toHaveBeenCalledTimes(2);
1343
- expect(player.play).toHaveBeenCalledTimes(2);
1344
- });
1345
-
1346
- it("prebuffers realtime output before starting Discord playback", async () => {
1347
- const manager = createManager({
1348
- groupPolicy: "open",
1349
- voice: {
1350
- enabled: true,
1351
- mode: "agent-proxy",
1352
- realtime: { provider: "openai" },
1353
- },
1354
- });
1355
-
1356
- await manager.join({ guildId: "g1", channelId: "1001" });
1357
-
1358
- const player = getLastAudioPlayer();
1359
- const bridgeParams = createRealtimeVoiceBridgeSessionMock.mock.calls.at(-1)?.[0] as
1360
- | {
1361
- audioSink?: {
1362
- sendAudio: (audio: Buffer) => void;
1363
- };
1364
- onEvent?: (event: { direction: "server"; type: string }) => void;
1365
- }
1366
- | undefined;
1367
-
1368
- for (let index = 0; index < 49; index += 1) {
1369
- bridgeParams?.audioSink?.sendAudio(Buffer.alloc(480));
1370
- }
1371
-
1372
- expect(createAudioResourceMock).not.toHaveBeenCalled();
1373
- expect(player.play).not.toHaveBeenCalled();
1374
-
1375
- bridgeParams?.audioSink?.sendAudio(Buffer.alloc(480));
1376
-
1377
- expect(createAudioResourceMock).toHaveBeenCalledTimes(1);
1378
- expect(player.play).toHaveBeenCalledTimes(1);
1379
- bridgeParams?.onEvent?.({ direction: "server", type: "response.done" });
1380
- });
1381
-
1382
- it("discards prebuffered realtime output when the response is cancelled", async () => {
1383
- const manager = createManager({
1384
- groupPolicy: "open",
1385
- voice: {
1386
- enabled: true,
1387
- mode: "agent-proxy",
1388
- realtime: { provider: "openai" },
1389
- },
1390
- });
1391
-
1392
- await manager.join({ guildId: "g1", channelId: "1001" });
1393
-
1394
- const player = getLastAudioPlayer();
1395
- const bridgeParams = createRealtimeVoiceBridgeSessionMock.mock.calls.at(-1)?.[0] as
1396
- | {
1397
- audioSink?: {
1398
- sendAudio: (audio: Buffer) => void;
1399
- };
1400
- onEvent?: (event: { detail?: string; direction: "server"; type: string }) => void;
1401
- }
1402
- | undefined;
1403
-
1404
- bridgeParams?.audioSink?.sendAudio(Buffer.alloc(480));
1405
- bridgeParams?.onEvent?.({ direction: "server", type: "response.cancelled" });
1406
-
1407
- expect(createAudioResourceMock).not.toHaveBeenCalled();
1408
- expect(player.play).not.toHaveBeenCalled();
1409
- expect(player.stop).toHaveBeenCalledWith(true);
1410
-
1411
- bridgeParams?.audioSink?.sendAudio(Buffer.alloc(480));
1412
- bridgeParams?.onEvent?.({
1413
- detail: "response completed with status=cancelled",
1414
- direction: "server",
1415
- type: "response.done",
1416
- });
1417
-
1418
- expect(createAudioResourceMock).not.toHaveBeenCalled();
1419
- expect(player.play).not.toHaveBeenCalled();
1420
- expect(player.stop).toHaveBeenCalledTimes(2);
1421
- });
1422
-
1423
- it("applies Discord realtime model and voice overrides during provider auto-selection", async () => {
1424
- const manager = createManager({
1425
- groupPolicy: "open",
1426
- voice: {
1427
- enabled: true,
1428
- mode: "agent-proxy",
1429
- realtime: {
1430
- model: "gpt-realtime-2",
1431
- voice: "cedar",
1432
- minBargeInAudioEndMs: 500,
1433
- providers: {
1434
- openai: { model: "provider-default", voice: "marin" },
1435
- },
1436
- },
1437
- },
1438
- });
1439
-
1440
- const result = await manager.join({ guildId: "g1", channelId: "1001" });
1441
-
1442
- expect(result.ok).toBe(true);
1443
- const providerOptions = requireRecord(
1444
- lastMockCall(
1445
- resolveConfiguredRealtimeVoiceProviderMock as unknown as MockCallSource,
1446
- "provider resolve",
1447
- )[0],
1448
- "provider resolve options",
1449
- );
1450
- expect(providerOptions.configuredProviderId).toBeUndefined();
1451
- expect(providerOptions.defaultModel).toBe("gpt-realtime-2");
1452
- expect(requireRecord(providerOptions.providerConfigs, "provider configs").openai).toEqual({
1453
- model: "provider-default",
1454
- voice: "marin",
1455
- });
1456
- expect(providerOptions.providerConfigOverrides).toEqual({
1457
- model: "gpt-realtime-2",
1458
- voice: "cedar",
1459
- minBargeInAudioEndMs: 500,
1460
- });
1461
- });
1462
-
1463
- it("keeps agent-proxy realtime transcripts on the audio turn speaker context", async () => {
1464
- agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "non-owner answer" }] });
1465
- const manager = createManager({
1466
- groupPolicy: "open",
1467
- voice: {
1468
- enabled: true,
1469
- mode: "agent-proxy",
1470
- realtime: { provider: "openai", debounceMs: 1 },
1471
- },
1472
- });
1473
-
1474
- await manager.join({ guildId: "g1", channelId: "1001" });
1475
- const entry = (manager as unknown as { sessions: Map<string, unknown> }).sessions.get("g1") as
1476
- | {
1477
- realtime?: {
1478
- beginSpeakerTurn: (
1479
- context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string },
1480
- userId: string,
1481
- ) => { close: () => void; sendInputAudio: (audio: Buffer) => void };
1482
- };
1483
- }
1484
- | undefined;
1485
- const nonOwnerTurn = entry?.realtime?.beginSpeakerTurn(
1486
- { extraSystemPrompt: undefined, senderIsOwner: false, speakerLabel: "Guest" },
1487
- "u-guest",
1488
- );
1489
- nonOwnerTurn?.sendInputAudio(Buffer.alloc(8));
1490
-
1491
- const bridgeParams = lastRealtimeBridgeParams() as
1492
- | {
1493
- onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void;
1494
- onEvent?: (event: { direction: "server"; type: string }) => void;
1495
- }
1496
- | undefined;
1497
- bridgeParams?.onTranscript?.("user", "non-owner question", true);
1498
- const ownerTurn = entry?.realtime?.beginSpeakerTurn(
1499
- { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
1500
- "u-owner",
1501
- );
1502
- ownerTurn?.sendInputAudio(Buffer.alloc(8));
1503
- await new Promise((resolve) => setTimeout(resolve, 260));
1504
-
1505
- expect(lastAgentCommandArgs().senderIsOwner).toBe(false);
1506
- expect(realtimeSessionMock.handleBargeIn).not.toHaveBeenCalled();
1507
- expectUserMessageIncludes("non-owner answer");
1508
- });
1509
-
1510
- it("keeps separate forced agent-proxy fallback timers for rapid transcripts", async () => {
1511
- agentCommandMock
1512
- .mockResolvedValueOnce({ payloads: [{ text: "guest answer" }] })
1513
- .mockResolvedValueOnce({ payloads: [{ text: "owner answer" }] });
1514
- const manager = createManager({
1515
- groupPolicy: "open",
1516
- voice: {
1517
- enabled: true,
1518
- mode: "agent-proxy",
1519
- realtime: { provider: "openai" },
1520
- },
1521
- });
1522
-
1523
- await manager.join({ guildId: "g1", channelId: "1001" });
1524
- const entry = getSessionEntry(manager) as {
1525
- realtime?: {
1526
- beginSpeakerTurn: (
1527
- context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string },
1528
- userId: string,
1529
- ) => { close: () => void; sendInputAudio: (audio: Buffer) => void };
1530
- };
1531
- };
1532
- const bridgeParams = lastRealtimeBridgeParams() as
1533
- | {
1534
- onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void;
1535
- onEvent?: (event: { direction: "server"; type: string }) => void;
1536
- }
1537
- | undefined;
1538
-
1539
- const guestTurn = entry.realtime?.beginSpeakerTurn(
1540
- { extraSystemPrompt: undefined, senderIsOwner: false, speakerLabel: "Guest" },
1541
- "u-guest",
1542
- );
1543
- guestTurn?.sendInputAudio(Buffer.alloc(8));
1544
- bridgeParams?.onTranscript?.("user", "guest question", true);
1545
-
1546
- const ownerTurn = entry.realtime?.beginSpeakerTurn(
1547
- { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
1548
- "u-owner",
1549
- );
1550
- ownerTurn?.sendInputAudio(Buffer.alloc(8));
1551
- bridgeParams?.onTranscript?.("user", "owner question", true);
1552
-
1553
- await new Promise((resolve) => setTimeout(resolve, 260));
1554
- bridgeParams?.onEvent?.({ direction: "server", type: "response.done" });
1555
-
1556
- const guestCommandArgs = agentCommandArgsAt(0);
1557
- expect(guestCommandArgs.message).toContain("guest question");
1558
- expect(guestCommandArgs.senderIsOwner).toBe(false);
1559
- const ownerCommandArgs = agentCommandArgsAt(1);
1560
- expect(ownerCommandArgs.message).toContain("owner question");
1561
- expect(ownerCommandArgs.senderIsOwner).toBe(true);
1562
- expectUserMessageIncludes("guest answer");
1563
- expectUserMessageIncludes("owner answer");
1564
- });
1565
-
1566
- it("skips incomplete and non-actionable forced agent-proxy transcripts", async () => {
1567
- agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "valid answer" }] });
1568
- const manager = createManager({
1569
- groupPolicy: "open",
1570
- voice: {
1571
- enabled: true,
1572
- mode: "agent-proxy",
1573
- realtime: { provider: "openai" },
1574
- },
1575
- });
1576
-
1577
- await manager.join({ guildId: "g1", channelId: "1001" });
1578
- const entry = getSessionEntry(manager) as {
1579
- realtime?: {
1580
- beginSpeakerTurn: (
1581
- context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string },
1582
- userId: string,
1583
- ) => { close: () => void; sendInputAudio: (audio: Buffer) => void };
1584
- };
1585
- };
1586
- const bridgeParams = lastRealtimeBridgeParams() as
1587
- | {
1588
- onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void;
1589
- }
1590
- | undefined;
1591
-
1592
- const incompleteTurn = entry.realtime?.beginSpeakerTurn(
1593
- { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
1594
- "u-owner",
1595
- );
1596
- incompleteTurn?.sendInputAudio(Buffer.alloc(8));
1597
- bridgeParams?.onTranscript?.("user", "Get this working and...", true);
1598
-
1599
- const closingTurn = entry.realtime?.beginSpeakerTurn(
1600
- { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
1601
- "u-owner",
1602
- );
1603
- closingTurn?.sendInputAudio(Buffer.alloc(8));
1604
- bridgeParams?.onTranscript?.("user", "I'll be right back. See you guys. Bye-bye.", true);
1605
-
1606
- await new Promise((resolve) => setTimeout(resolve, 260));
1607
- expect(agentCommandMock).not.toHaveBeenCalled();
1608
-
1609
- const validTurn = entry.realtime?.beginSpeakerTurn(
1610
- { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
1611
- "u-owner",
1612
- );
1613
- validTurn?.sendInputAudio(Buffer.alloc(8));
1614
- bridgeParams?.onTranscript?.("user", "ship it.", true);
1615
-
1616
- await new Promise((resolve) => setTimeout(resolve, 260));
1617
- expect(lastAgentCommandArgs().message).toContain("ship it.");
1618
- expectUserMessageIncludes("valid answer");
1619
- });
1620
-
1621
- it("queues forced agent-proxy answers until current realtime playback idles", async () => {
1622
- let resolveFirst: ((value: { payloads: Array<{ text: string }> }) => void) | undefined;
1623
- let resolveSecond: ((value: { payloads: Array<{ text: string }> }) => void) | undefined;
1624
- let resolveThird: ((value: { payloads: Array<{ text: string }> }) => void) | undefined;
1625
- agentCommandMock
1626
- .mockImplementationOnce(
1627
- () =>
1628
- new Promise<{ payloads: Array<{ text: string }> }>((resolve) => {
1629
- resolveFirst = resolve;
1630
- }),
1631
- )
1632
- .mockImplementationOnce(
1633
- () =>
1634
- new Promise<{ payloads: Array<{ text: string }> }>((resolve) => {
1635
- resolveSecond = resolve;
1636
- }),
1637
- )
1638
- .mockImplementationOnce(
1639
- () =>
1640
- new Promise<{ payloads: Array<{ text: string }> }>((resolve) => {
1641
- resolveThird = resolve;
1642
- }),
1643
- );
1644
- const manager = createManager({
1645
- groupPolicy: "open",
1646
- voice: {
1647
- enabled: true,
1648
- mode: "agent-proxy",
1649
- realtime: { provider: "openai" },
1650
- },
1651
- });
1652
-
1653
- await manager.join({ guildId: "g1", channelId: "1001" });
1654
- const entry = getSessionEntry(manager) as {
1655
- realtime?: {
1656
- beginSpeakerTurn: (
1657
- context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string },
1658
- userId: string,
1659
- ) => { close: () => void; sendInputAudio: (audio: Buffer) => void };
1660
- };
1661
- };
1662
- const player = getLastAudioPlayer() as {
1663
- on: ReturnType<typeof vi.fn>;
1664
- };
1665
- const bridgeParams = lastRealtimeBridgeParams() as
1666
- | {
1667
- audioSink?: { sendAudio: (audio: Buffer) => void };
1668
- onEvent?: (event: { direction: "server"; type: string }) => void;
1669
- onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void;
1670
- }
1671
- | undefined;
1672
-
1673
- const firstTurn = entry.realtime?.beginSpeakerTurn(
1674
- { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
1675
- "u-owner",
1676
- );
1677
- firstTurn?.sendInputAudio(Buffer.alloc(8));
1678
- bridgeParams?.onTranscript?.("user", "first question", true);
1679
- const secondTurn = entry.realtime?.beginSpeakerTurn(
1680
- { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
1681
- "u-owner",
1682
- );
1683
- secondTurn?.sendInputAudio(Buffer.alloc(8));
1684
- bridgeParams?.onTranscript?.("user", "second question", true);
1685
- const thirdTurn = entry.realtime?.beginSpeakerTurn(
1686
- { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
1687
- "u-owner",
1688
- );
1689
- thirdTurn?.sendInputAudio(Buffer.alloc(8));
1690
- bridgeParams?.onTranscript?.("user", "third question", true);
1691
- await new Promise((resolve) => setTimeout(resolve, 260));
1692
-
1693
- resolveFirst?.({ payloads: [{ text: "first answer" }] });
1694
- await vi.waitFor(() => expectUserMessageIncludes("first answer"));
1695
- bridgeParams?.audioSink?.sendAudio(Buffer.alloc(480));
1696
-
1697
- resolveSecond?.({ payloads: [{ text: "second answer" }] });
1698
- resolveThird?.({ payloads: [{ text: "third answer" }] });
1699
- await new Promise<void>((resolve) => setImmediate(resolve));
1700
- expectUserMessageNotIncludes("second answer");
1701
- expectUserMessageNotIncludes("third answer");
1702
-
1703
- bridgeParams?.onEvent?.({ direction: "server", type: "response.done" });
1704
- const firstStream = lastAudioResourceInput() as PassThrough | undefined;
1705
- await vi.waitFor(() => expect(firstStream?.writableEnded).toBe(true));
1706
- await new Promise<void>((resolve) => setImmediate(resolve));
1707
- expectUserMessageNotIncludes("second answer");
1708
-
1709
- const idleHandler = player.on.mock.calls.find(([event]) => event === "idle")?.[1] as
1710
- | (() => void)
1711
- | undefined;
1712
- idleHandler?.();
1713
- expectUserMessageIncludes("second answer");
1714
- expectUserMessageNotIncludes("third answer");
1715
-
1716
- bridgeParams?.audioSink?.sendAudio(Buffer.alloc(480));
1717
- bridgeParams?.onEvent?.({ direction: "server", type: "response.done" });
1718
- const secondStream = lastAudioResourceInput() as PassThrough | undefined;
1719
- await vi.waitFor(() => expect(secondStream?.writableEnded).toBe(true));
1720
- await new Promise<void>((resolve) => setImmediate(resolve));
1721
- expectUserMessageNotIncludes("third answer");
1722
-
1723
- idleHandler?.();
1724
- expectUserMessageIncludes("third answer");
1725
- });
1726
-
1727
- it("does not interrupt active exact speech for a later forced agent-proxy consult", async () => {
1728
- agentCommandMock
1729
- .mockResolvedValueOnce({ payloads: [{ text: "first answer" }] })
1730
- .mockResolvedValueOnce({ payloads: [{ text: "second answer" }] });
1731
- const manager = createManager({
1732
- groupPolicy: "open",
1733
- voice: {
1734
- enabled: true,
1735
- mode: "agent-proxy",
1736
- realtime: { provider: "openai" },
1737
- },
1738
- });
1739
-
1740
- await manager.join({ guildId: "g1", channelId: "1001" });
1741
- const entry = getSessionEntry(manager) as {
1742
- realtime?: {
1743
- beginSpeakerTurn: (
1744
- context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string },
1745
- userId: string,
1746
- ) => { close: () => void; sendInputAudio: (audio: Buffer) => void };
1747
- };
1748
- };
1749
- const player = getLastAudioPlayer();
1750
- const bridgeParams = lastRealtimeBridgeParams() as
1751
- | {
1752
- audioSink?: { sendAudio: (audio: Buffer) => void };
1753
- onEvent?: (event: { direction: "server"; type: string }) => void;
1754
- onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void;
1755
- }
1756
- | undefined;
1757
-
1758
- const firstTurn = entry.realtime?.beginSpeakerTurn(
1759
- { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
1760
- "u-owner",
1761
- );
1762
- firstTurn?.sendInputAudio(Buffer.alloc(8));
1763
- bridgeParams?.onTranscript?.("user", "first question", true);
1764
-
1765
- await new Promise((resolve) => setTimeout(resolve, 260));
1766
- await vi.waitFor(() => expectUserMessageIncludes("first answer"));
1767
- bridgeParams?.audioSink?.sendAudio(Buffer.alloc(480));
1768
-
1769
- const secondTurn = entry.realtime?.beginSpeakerTurn(
1770
- { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
1771
- "u-owner",
1772
- );
1773
- secondTurn?.sendInputAudio(Buffer.alloc(8));
1774
- bridgeParams?.onTranscript?.("user", "second question", true);
1775
-
1776
- await new Promise((resolve) => setTimeout(resolve, 260));
1777
- expect(
1778
- realtimeSessionMock.handleBargeIn.mock.calls.some(([arg]) => {
1779
- return (arg as { force?: boolean } | undefined)?.force === true;
1780
- }),
1781
- ).toBe(false);
1782
- expect(player.stop).not.toHaveBeenCalled();
1783
- expectUserMessageNotIncludes("second answer");
1784
-
1785
- bridgeParams?.onEvent?.({ direction: "server", type: "response.done" });
1786
- const firstStream = lastAudioResourceInput() as PassThrough | undefined;
1787
- await vi.waitFor(() => expect(firstStream?.writableEnded).toBe(true));
1788
- await new Promise<void>((resolve) => setImmediate(resolve));
1789
- expectUserMessageNotIncludes("second answer");
1790
-
1791
- const idleHandler = player.on.mock.calls.find(([event]) => event === "idle")?.[1] as
1792
- | (() => void)
1793
- | undefined;
1794
- idleHandler?.();
1795
- expectUserMessageIncludes("second answer");
1796
- });
1797
-
1798
- it("drains queued exact speech after cancelled prebuffered output is discarded", async () => {
1799
- agentCommandMock
1800
- .mockResolvedValueOnce({ payloads: [{ text: "first answer" }] })
1801
- .mockResolvedValueOnce({ payloads: [{ text: "second answer" }] });
1802
- const manager = createManager({
1803
- groupPolicy: "open",
1804
- voice: {
1805
- enabled: true,
1806
- mode: "agent-proxy",
1807
- realtime: { provider: "openai" },
1808
- },
1809
- });
1810
-
1811
- await manager.join({ guildId: "g1", channelId: "1001" });
1812
- const entry = getSessionEntry(manager) as {
1813
- realtime?: {
1814
- beginSpeakerTurn: (
1815
- context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string },
1816
- userId: string,
1817
- ) => { close: () => void; sendInputAudio: (audio: Buffer) => void };
1818
- };
1819
- };
1820
- const player = getLastAudioPlayer();
1821
- const bridgeParams = createRealtimeVoiceBridgeSessionMock.mock.calls.at(-1)?.[0] as
1822
- | {
1823
- audioSink?: { sendAudio: (audio: Buffer) => void };
1824
- onEvent?: (event: { detail?: string; direction: "server"; type: string }) => void;
1825
- onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void;
1826
- }
1827
- | undefined;
1828
-
1829
- const firstTurn = entry.realtime?.beginSpeakerTurn(
1830
- { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
1831
- "u-owner",
1832
- );
1833
- firstTurn?.sendInputAudio(Buffer.alloc(8));
1834
- bridgeParams?.onTranscript?.("user", "first question", true);
1835
-
1836
- await new Promise((resolve) => setTimeout(resolve, 260));
1837
- await vi.waitFor(() => expectUserMessageIncludes("first answer"));
1838
- bridgeParams?.audioSink?.sendAudio(Buffer.alloc(480));
1839
-
1840
- const secondTurn = entry.realtime?.beginSpeakerTurn(
1841
- { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
1842
- "u-owner",
1843
- );
1844
- secondTurn?.sendInputAudio(Buffer.alloc(8));
1845
- bridgeParams?.onTranscript?.("user", "second question", true);
1846
-
1847
- await new Promise((resolve) => setTimeout(resolve, 260));
1848
- expectUserMessageNotIncludes("second answer");
1849
-
1850
- bridgeParams?.onEvent?.({ direction: "server", type: "response.cancelled" });
1851
-
1852
- expect(createAudioResourceMock).not.toHaveBeenCalled();
1853
- expect(player.play).not.toHaveBeenCalled();
1854
- expect(player.stop).toHaveBeenCalledWith(true);
1855
- expectUserMessageIncludes("second answer");
1856
- });
1857
-
1858
- it("matches agent-proxy consult tool calls to the pending transcript", async () => {
1859
- agentCommandMock
1860
- .mockResolvedValueOnce({ payloads: [{ text: "owner answer" }] })
1861
- .mockResolvedValueOnce({ payloads: [{ text: "guest fallback answer" }] });
1862
- const manager = createManager({
1863
- groupPolicy: "open",
1864
- voice: {
1865
- enabled: true,
1866
- mode: "agent-proxy",
1867
- realtime: { provider: "openai" },
1868
- },
1869
- });
1870
-
1871
- await manager.join({ guildId: "g1", channelId: "1001" });
1872
- const entry = getSessionEntry(manager) as {
1873
- realtime?: {
1874
- beginSpeakerTurn: (
1875
- context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string },
1876
- userId: string,
1877
- ) => { close: () => void; sendInputAudio: (audio: Buffer) => void };
1878
- };
1879
- };
1880
- const bridgeParams = lastRealtimeBridgeParams() as
1881
- | {
1882
- onToolCall?: (
1883
- event: {
1884
- itemId: string;
1885
- callId: string;
1886
- name: string;
1887
- args: unknown;
1888
- },
1889
- session: typeof realtimeSessionMock,
1890
- ) => void;
1891
- onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void;
1892
- onEvent?: (event: { direction: "server"; type: string }) => void;
1893
- }
1894
- | undefined;
1895
-
1896
- const guestTurn = entry.realtime?.beginSpeakerTurn(
1897
- { extraSystemPrompt: undefined, senderIsOwner: false, speakerLabel: "Guest" },
1898
- "u-guest",
1899
- );
1900
- guestTurn?.sendInputAudio(Buffer.alloc(8));
1901
- bridgeParams?.onTranscript?.("user", "guest question", true);
1902
-
1903
- const ownerTurn = entry.realtime?.beginSpeakerTurn(
1904
- { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
1905
- "u-owner",
1906
- );
1907
- ownerTurn?.sendInputAudio(Buffer.alloc(8));
1908
- bridgeParams?.onTranscript?.("user", "owner question", true);
1909
-
1910
- bridgeParams?.onToolCall?.(
1911
- {
1912
- itemId: "item-owner",
1913
- callId: "call-owner",
1914
- name: "klaw_agent_consult",
1915
- args: { question: "owner question" },
1916
- },
1917
- realtimeSessionMock,
1918
- );
1919
- await Promise.resolve();
1920
- await Promise.resolve();
1921
- await new Promise((resolve) => setTimeout(resolve, 260));
1922
-
1923
- const ownerCommandArgs = agentCommandArgsAt(0);
1924
- expect(ownerCommandArgs.message).toContain("owner question");
1925
- expect(ownerCommandArgs.senderIsOwner).toBe(true);
1926
- const guestCommandArgs = agentCommandArgsAt(1);
1927
- expect(guestCommandArgs.message).toContain("guest question");
1928
- expect(guestCommandArgs.senderIsOwner).toBe(false);
1929
- expect(realtimeSessionMock.submitToolResult).toHaveBeenCalledWith("call-owner", {
1930
- text: "owner answer",
1931
- });
1932
- expectUserMessageIncludes("guest fallback answer");
1933
- });
1934
-
1935
- it("reuses forced agent-proxy answers for late matching consult tool calls", async () => {
1936
- agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "forced answer" }] });
1937
- const manager = createManager({
1938
- groupPolicy: "open",
1939
- voice: {
1940
- enabled: true,
1941
- mode: "agent-proxy",
1942
- realtime: { provider: "openai" },
1943
- },
1944
- });
1945
-
1946
- await manager.join({ guildId: "g1", channelId: "1001" });
1947
- const entry = getSessionEntry(manager) as {
1948
- realtime?: {
1949
- beginSpeakerTurn: (
1950
- context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string },
1951
- userId: string,
1952
- ) => { close: () => void; sendInputAudio: (audio: Buffer) => void };
1953
- };
1954
- };
1955
- const bridgeParams = lastRealtimeBridgeParams() as
1956
- | {
1957
- onToolCall?: (
1958
- event: {
1959
- itemId: string;
1960
- callId: string;
1961
- name: string;
1962
- args: unknown;
1963
- },
1964
- session: typeof realtimeSessionMock,
1965
- ) => void;
1966
- onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void;
1967
- onEvent?: (event: { direction: "server"; type: string }) => void;
1968
- }
1969
- | undefined;
1970
-
1971
- const ownerTurn = entry.realtime?.beginSpeakerTurn(
1972
- { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
1973
- "u-owner",
1974
- );
1975
- ownerTurn?.sendInputAudio(Buffer.alloc(8));
1976
- bridgeParams?.onTranscript?.("user", "late question", true);
1977
-
1978
- await new Promise((resolve) => setTimeout(resolve, 260));
1979
-
1980
- bridgeParams?.onToolCall?.(
1981
- {
1982
- itemId: "item-late",
1983
- callId: "call-late",
1984
- name: "klaw_agent_consult",
1985
- args: { question: "late question" },
1986
- },
1987
- realtimeSessionMock,
1988
- );
1989
- await Promise.resolve();
1990
- await Promise.resolve();
1991
-
1992
- expect(agentCommandMock).toHaveBeenCalledTimes(1);
1993
- expectUserMessageIncludes("forced answer");
1994
- expect(realtimeSessionMock.submitToolResult).toHaveBeenCalledWith(
1995
- "call-late",
1996
- {
1997
- status: "already_delivered",
1998
- message: "Klaw already delivered this answer to Discord voice.",
1999
- },
2000
- { suppressResponse: true },
2001
- );
2002
- });
2003
-
2004
- it("suppresses late forced agent-proxy tool calls when the forced consult rejects", async () => {
2005
- let rejectAgentTurn: ((error: unknown) => void) | undefined;
2006
- agentCommandMock.mockReturnValueOnce(
2007
- new Promise((_, reject) => {
2008
- rejectAgentTurn = reject;
2009
- }),
2010
- );
2011
- const manager = createManager({
2012
- groupPolicy: "open",
2013
- voice: {
2014
- enabled: true,
2015
- mode: "agent-proxy",
2016
- realtime: { provider: "openai" },
2017
- },
2018
- });
2019
-
2020
- await manager.join({ guildId: "g1", channelId: "1001" });
2021
- const entry = getSessionEntry(manager) as {
2022
- realtime?: {
2023
- beginSpeakerTurn: (
2024
- context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string },
2025
- userId: string,
2026
- ) => { close: () => void; sendInputAudio: (audio: Buffer) => void };
2027
- };
2028
- };
2029
- const bridgeParams = lastRealtimeBridgeParams() as
2030
- | {
2031
- onToolCall?: (
2032
- event: {
2033
- itemId: string;
2034
- callId: string;
2035
- name: string;
2036
- args: unknown;
2037
- },
2038
- session: typeof realtimeSessionMock,
2039
- ) => void;
2040
- onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void;
2041
- onEvent?: (event: { direction: "server"; type: string }) => void;
2042
- }
2043
- | undefined;
2044
-
2045
- const ownerTurn = entry.realtime?.beginSpeakerTurn(
2046
- { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
2047
- "u-owner",
2048
- );
2049
- ownerTurn?.sendInputAudio(Buffer.alloc(8));
2050
- bridgeParams?.onTranscript?.("user", "late question", true);
2051
-
2052
- await new Promise((resolve) => setTimeout(resolve, 260));
2053
-
2054
- bridgeParams?.onToolCall?.(
2055
- {
2056
- itemId: "item-late",
2057
- callId: "call-late",
2058
- name: "klaw_agent_consult",
2059
- args: { question: "late question" },
2060
- },
2061
- realtimeSessionMock,
2062
- );
2063
- rejectAgentTurn?.(new Error("agent broke"));
2064
- await vi.waitFor(() =>
2065
- expect(realtimeSessionMock.submitToolResult).toHaveBeenCalledWith(
2066
- "call-late",
2067
- {
2068
- status: "already_delivered",
2069
- message: "Klaw already delivered this answer to Discord voice.",
2070
- },
2071
- { suppressResponse: true },
2072
- ),
2073
- );
2074
-
2075
- expect(agentCommandMock).toHaveBeenCalledTimes(1);
2076
- expectUserMessageIncludes("I hit an error while checking that. Please try again.");
2077
- });
2078
-
2079
- it("does not reuse recent agent-proxy answers over newer speaker audio", async () => {
2080
- agentCommandMock
2081
- .mockResolvedValueOnce({ payloads: [{ text: "forced answer" }] })
2082
- .mockResolvedValueOnce({ payloads: [{ text: "guest answer" }] });
2083
- const manager = createManager({
2084
- groupPolicy: "open",
2085
- voice: {
2086
- enabled: true,
2087
- mode: "agent-proxy",
2088
- realtime: { provider: "openai" },
2089
- },
2090
- });
2091
-
2092
- await manager.join({ guildId: "g1", channelId: "1001" });
2093
- const entry = getSessionEntry(manager) as {
2094
- realtime?: {
2095
- beginSpeakerTurn: (
2096
- context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string },
2097
- userId: string,
2098
- ) => { close: () => void; sendInputAudio: (audio: Buffer) => void };
2099
- };
2100
- };
2101
- const bridgeParams = lastRealtimeBridgeParams() as
2102
- | {
2103
- onToolCall?: (
2104
- event: {
2105
- itemId: string;
2106
- callId: string;
2107
- name: string;
2108
- args: unknown;
2109
- },
2110
- session: typeof realtimeSessionMock,
2111
- ) => void;
2112
- onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void;
2113
- onEvent?: (event: { direction: "server"; type: string }) => void;
2114
- }
2115
- | undefined;
2116
-
2117
- const ownerTurn = entry.realtime?.beginSpeakerTurn(
2118
- { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
2119
- "u-owner",
2120
- );
2121
- ownerTurn?.sendInputAudio(Buffer.alloc(8));
2122
- bridgeParams?.onTranscript?.("user", "late question", true);
2123
-
2124
- await new Promise((resolve) => setTimeout(resolve, 260));
2125
-
2126
- const guestTurn = entry.realtime?.beginSpeakerTurn(
2127
- { extraSystemPrompt: undefined, senderIsOwner: false, speakerLabel: "Guest" },
2128
- "u-guest",
2129
- );
2130
- guestTurn?.sendInputAudio(Buffer.alloc(8));
2131
-
2132
- bridgeParams?.onToolCall?.(
2133
- {
2134
- itemId: "item-late",
2135
- callId: "call-late",
2136
- name: "klaw_agent_consult",
2137
- args: { question: "late question" },
2138
- },
2139
- realtimeSessionMock,
2140
- );
2141
- await Promise.resolve();
2142
- await Promise.resolve();
2143
-
2144
- expect(agentCommandMock).toHaveBeenCalledTimes(1);
2145
- expectUserMessageIncludes("forced answer");
2146
- expect(realtimeSessionMock.submitToolResult).toHaveBeenCalledWith("call-late", {
2147
- error: "Discord speaker context changed before this realtime consult completed",
2148
- });
2149
- bridgeParams?.onEvent?.({ direction: "server", type: "response.done" });
2150
-
2151
- bridgeParams?.onTranscript?.("user", "guest followup", true);
2152
- await new Promise((resolve) => setTimeout(resolve, 260));
2153
-
2154
- expect(agentCommandMock).toHaveBeenCalledTimes(2);
2155
- const followupCommandArgs = agentCommandArgsAt(1);
2156
- expect(followupCommandArgs.message).toContain("guest followup");
2157
- expect(followupCommandArgs.senderIsOwner).toBe(false);
2158
- expectUserMessageIncludes("guest answer");
2159
- });
2160
-
2161
- it("prefers the newest recent agent-proxy consult for repeated questions", async () => {
2162
- agentCommandMock
2163
- .mockResolvedValueOnce({ payloads: [{ text: "old direct answer" }] })
2164
- .mockResolvedValueOnce({ payloads: [{ text: "new forced answer" }] });
2165
- const manager = createManager({
2166
- groupPolicy: "open",
2167
- voice: {
2168
- enabled: true,
2169
- mode: "agent-proxy",
2170
- realtime: { provider: "openai" },
2171
- },
2172
- });
2173
-
2174
- await manager.join({ guildId: "g1", channelId: "1001" });
2175
- const entry = getSessionEntry(manager) as {
2176
- realtime?: {
2177
- beginSpeakerTurn: (
2178
- context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string },
2179
- userId: string,
2180
- ) => { close: () => void; sendInputAudio: (audio: Buffer) => void };
2181
- };
2182
- };
2183
- const bridgeParams = lastRealtimeBridgeParams() as
2184
- | {
2185
- onToolCall?: (
2186
- event: {
2187
- itemId: string;
2188
- callId: string;
2189
- name: string;
2190
- args: unknown;
2191
- },
2192
- session: typeof realtimeSessionMock,
2193
- ) => void;
2194
- onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void;
2195
- }
2196
- | undefined;
2197
-
2198
- const firstTurn = entry.realtime?.beginSpeakerTurn(
2199
- { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
2200
- "u-owner",
2201
- );
2202
- firstTurn?.sendInputAudio(Buffer.alloc(8));
2203
- bridgeParams?.onToolCall?.(
2204
- {
2205
- itemId: "item-old",
2206
- callId: "call-old",
2207
- name: "klaw_agent_consult",
2208
- args: { question: "repeat question" },
2209
- },
2210
- realtimeSessionMock,
2211
- );
2212
- await vi.waitFor(() =>
2213
- expect(realtimeSessionMock.submitToolResult).toHaveBeenCalledWith("call-old", {
2214
- text: "old direct answer",
2215
- }),
2216
- );
2217
-
2218
- const secondTurn = entry.realtime?.beginSpeakerTurn(
2219
- { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
2220
- "u-owner",
2221
- );
2222
- secondTurn?.sendInputAudio(Buffer.alloc(8));
2223
- bridgeParams?.onTranscript?.("user", "repeat question", true);
2224
- await new Promise((resolve) => setTimeout(resolve, 260));
2225
-
2226
- bridgeParams?.onToolCall?.(
2227
- {
2228
- itemId: "item-new",
2229
- callId: "call-new",
2230
- name: "klaw_agent_consult",
2231
- args: { question: "repeat question" },
2232
- },
2233
- realtimeSessionMock,
2234
- );
2235
- await Promise.resolve();
2236
- await Promise.resolve();
2237
-
2238
- expect(agentCommandMock).toHaveBeenCalledTimes(2);
2239
- expectUserMessageIncludes("new forced answer");
2240
- expect(realtimeSessionMock.submitToolResult).toHaveBeenCalledWith(
2241
- "call-new",
2242
- {
2243
- status: "already_delivered",
2244
- message: "Klaw already delivered this answer to Discord voice.",
2245
- },
2246
- { suppressResponse: true },
2247
- );
2248
- expect(realtimeSessionMock.submitToolResult).not.toHaveBeenCalledWith("call-new", {
2249
- text: "old direct answer",
2250
- });
2251
- });
2252
-
2253
- it("expires closed agent-proxy turns before later speaker audio", async () => {
2254
- agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "guest answer" }] });
2255
- const manager = createManager({
2256
- groupPolicy: "open",
2257
- voice: {
2258
- enabled: true,
2259
- mode: "agent-proxy",
2260
- realtime: { provider: "openai", debounceMs: 1 },
2261
- },
2262
- });
2263
-
2264
- await manager.join({ guildId: "g1", channelId: "1001" });
2265
- const entry = getSessionEntry(manager) as {
2266
- realtime?: {
2267
- beginSpeakerTurn: (
2268
- context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string },
2269
- userId: string,
2270
- ) => { close: () => void; sendInputAudio: (audio: Buffer) => void };
2271
- };
2272
- };
2273
- const ownerTurn = entry.realtime?.beginSpeakerTurn(
2274
- { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
2275
- "u-owner",
2276
- );
2277
- ownerTurn?.sendInputAudio(Buffer.alloc(8));
2278
- ownerTurn?.close();
2279
- const guestTurn = entry.realtime?.beginSpeakerTurn(
2280
- { extraSystemPrompt: undefined, senderIsOwner: false, speakerLabel: "Guest" },
2281
- "u-guest",
2282
- );
2283
- guestTurn?.sendInputAudio(Buffer.alloc(8));
2284
-
2285
- const bridgeParams = lastRealtimeBridgeParams() as
2286
- | {
2287
- onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void;
2288
- }
2289
- | undefined;
2290
- bridgeParams?.onTranscript?.("user", "guest question", true);
2291
- await new Promise((resolve) => setTimeout(resolve, 260));
2292
-
2293
- expect(lastAgentCommandArgs().senderIsOwner).toBe(false);
2294
- expectUserMessageIncludes("guest answer");
2295
- });
2296
-
2297
- it("starts Discord realtime voice in bidi mode with the consult tool", async () => {
2298
- agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "consult answer" }] });
2299
- const manager = createManager({
2300
- groupPolicy: "open",
2301
- voice: {
2302
- enabled: true,
2303
- mode: "bidi",
2304
- model: "openai-codex/gpt-5.5",
2305
- realtime: {
2306
- provider: "openai",
2307
- model: "gpt-realtime-2",
2308
- voice: "cedar",
2309
- toolPolicy: "safe-read-only",
2310
- consultPolicy: "always",
2311
- providers: {
2312
- openai: {
2313
- interruptResponseOnInputAudio: false,
2314
- },
2315
- },
2316
- },
2317
- },
2318
- });
2319
-
2320
- await manager.join({ guildId: "g1", channelId: "1001" });
2321
- const entry = (manager as unknown as { sessions: Map<string, unknown> }).sessions.get("g1") as
2322
- | {
2323
- realtime?: {
2324
- beginSpeakerTurn: (
2325
- context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string },
2326
- userId: string,
2327
- ) => { close: () => void; sendInputAudio: (audio: Buffer) => void };
2328
- };
2329
- }
2330
- | undefined;
2331
- const ownerTurn = entry?.realtime?.beginSpeakerTurn(
2332
- { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
2333
- "u-owner",
2334
- );
2335
- ownerTurn?.sendInputAudio(Buffer.alloc(8));
2336
-
2337
- const bridgeParams = lastRealtimeBridgeParams() as
2338
- | {
2339
- autoRespondToAudio?: boolean;
2340
- interruptResponseOnInputAudio?: boolean;
2341
- instructions?: string;
2342
- tools?: Array<{ name: string }>;
2343
- onToolCall?: (
2344
- event: {
2345
- itemId: string;
2346
- callId: string;
2347
- name: string;
2348
- args: unknown;
2349
- },
2350
- session: typeof realtimeSessionMock,
2351
- ) => void;
2352
- }
2353
- | undefined;
2354
- expect(bridgeParams?.autoRespondToAudio).toBe(true);
2355
- expect(bridgeParams?.interruptResponseOnInputAudio).toBe(false);
2356
- expect(bridgeParams?.instructions).toContain("Call klaw_agent_consult");
2357
- expect(bridgeParams?.tools?.map((tool) => tool.name)).toContain("klaw_agent_consult");
2358
-
2359
- bridgeParams?.onToolCall?.(
2360
- {
2361
- itemId: "item-1",
2362
- callId: "call-1",
2363
- name: "klaw_agent_consult",
2364
- args: { question: "check my Discord" },
2365
- },
2366
- realtimeSessionMock,
2367
- );
2368
- await vi.waitFor(() =>
2369
- expect(realtimeSessionMock.submitToolResult).toHaveBeenCalledWith("call-1", {
2370
- text: "consult answer",
2371
- }),
2372
- );
2373
-
2374
- const workingToolResultCall = mockCall(
2375
- realtimeSessionMock.submitToolResult as unknown as MockCallSource,
2376
- 0,
2377
- "working tool result",
2378
- );
2379
- expect(workingToolResultCall?.[0]).toBe("call-1");
2380
- expect(requireRecord(workingToolResultCall?.[1], "working tool result payload").status).toBe(
2381
- "working",
2382
- );
2383
- expect(workingToolResultCall?.[2]).toEqual({ willContinue: true });
2384
- const commandArgs = lastAgentCommandArgs();
2385
- expect(commandArgs.senderIsOwner).toBe(true);
2386
- expect(commandArgs.toolsAllow).toEqual([
2387
- "read",
2388
- "web_search",
2389
- "web_fetch",
2390
- "x_search",
2391
- "memory_search",
2392
- "memory_get",
2393
- ]);
2394
- });
2395
-
2396
- it("routes bidi realtime consults through a configured voice agent session target", async () => {
2397
- resolveAgentRouteMock.mockImplementation((params?: { peer?: { id?: string } }) => {
2398
- if (params?.peer?.id === "maintainers") {
2399
- return {
2400
- agentId: "main",
2401
- sessionKey: "agent:main:discord:channel:maintainers",
2402
- };
2403
- }
2404
- return {
2405
- agentId: "main",
2406
- sessionKey: "agent:main:discord:channel:1001",
2407
- };
2408
- });
2409
- agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "maintainer answer" }] });
2410
- const manager = createManager({
2411
- groupPolicy: "open",
2412
- voice: {
2413
- enabled: true,
2414
- mode: "bidi",
2415
- agentSession: {
2416
- mode: "target",
2417
- target: "channel:maintainers",
2418
- },
2419
- realtime: {
2420
- provider: "openai",
2421
- consultPolicy: "always",
2422
- },
2423
- },
2424
- });
2425
-
2426
- await manager.join({ guildId: "g1", channelId: "1001" });
2427
- const entry = getSessionEntry(manager) as {
2428
- realtime?: {
2429
- beginSpeakerTurn: (
2430
- context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string },
2431
- userId: string,
2432
- ) => { close: () => void; sendInputAudio: (audio: Buffer) => void };
2433
- };
2434
- route?: { sessionKey?: string };
2435
- voiceSessionKey?: string;
2436
- };
2437
- expect(entry.voiceSessionKey).toBe("agent:main:discord:channel:1001");
2438
- expect(entry.route?.sessionKey).toBe("agent:main:discord:channel:maintainers");
2439
-
2440
- const ownerTurn = entry.realtime?.beginSpeakerTurn(
2441
- { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
2442
- "u-owner",
2443
- );
2444
- ownerTurn?.sendInputAudio(Buffer.alloc(8));
2445
-
2446
- const bridgeParams = lastRealtimeBridgeParams() as
2447
- | {
2448
- onToolCall?: (
2449
- event: {
2450
- itemId: string;
2451
- callId: string;
2452
- name: string;
2453
- args: unknown;
2454
- },
2455
- session: typeof realtimeSessionMock,
2456
- ) => void;
2457
- }
2458
- | undefined;
2459
- bridgeParams?.onToolCall?.(
2460
- {
2461
- itemId: "item-1",
2462
- callId: "call-1",
2463
- name: "klaw_agent_consult",
2464
- args: { question: "check the maintainer channel context" },
2465
- },
2466
- realtimeSessionMock,
2467
- );
2468
- await vi.waitFor(() =>
2469
- expect(realtimeSessionMock.submitToolResult).toHaveBeenCalledWith("call-1", {
2470
- text: "maintainer answer",
2471
- }),
2472
- );
2473
-
2474
- expect(lastAgentCommandArgs().sessionKey).toBe("agent:main:discord:channel:maintainers");
2475
- });
2476
-
2477
- it("keeps bidi realtime consults on the audio turn speaker context", async () => {
2478
- agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "guest consult answer" }] });
2479
- const manager = createManager({
2480
- groupPolicy: "open",
2481
- voice: {
2482
- enabled: true,
2483
- mode: "bidi",
2484
- realtime: {
2485
- provider: "openai",
2486
- toolPolicy: "safe-read-only",
2487
- consultPolicy: "always",
2488
- },
2489
- },
2490
- });
2491
-
2492
- await manager.join({ guildId: "g1", channelId: "1001" });
2493
- const entry = (manager as unknown as { sessions: Map<string, unknown> }).sessions.get("g1") as
2494
- | {
2495
- realtime?: {
2496
- beginSpeakerTurn: (
2497
- context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string },
2498
- userId: string,
2499
- ) => { close: () => void; sendInputAudio: (audio: Buffer) => void };
2500
- };
2501
- }
2502
- | undefined;
2503
- const nonOwnerTurn = entry?.realtime?.beginSpeakerTurn(
2504
- { extraSystemPrompt: undefined, senderIsOwner: false, speakerLabel: "Guest" },
2505
- "u-guest",
2506
- );
2507
- nonOwnerTurn?.sendInputAudio(Buffer.alloc(8));
2508
- const ownerTurn = entry?.realtime?.beginSpeakerTurn(
2509
- { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
2510
- "u-owner",
2511
- );
2512
- ownerTurn?.sendInputAudio(Buffer.alloc(8));
2513
-
2514
- const bridgeParams = lastRealtimeBridgeParams() as
2515
- | {
2516
- onToolCall?: (
2517
- event: {
2518
- itemId: string;
2519
- callId: string;
2520
- name: string;
2521
- args: unknown;
2522
- },
2523
- session: typeof realtimeSessionMock,
2524
- ) => void;
2525
- }
2526
- | undefined;
2527
- bridgeParams?.onToolCall?.(
2528
- {
2529
- itemId: "item-guest",
2530
- callId: "call-guest",
2531
- name: "klaw_agent_consult",
2532
- args: { question: "guest question" },
2533
- },
2534
- realtimeSessionMock,
2535
- );
2536
- await Promise.resolve();
2537
- await Promise.resolve();
2538
-
2539
- const commandArgs = lastAgentCommandArgs();
2540
- expect(commandArgs.senderIsOwner).toBe(false);
2541
- expect(commandArgs.toolsAllow).toEqual([
2542
- "read",
2543
- "web_search",
2544
- "web_fetch",
2545
- "x_search",
2546
- "memory_search",
2547
- "memory_get",
2548
- ]);
2549
- });
2550
-
2551
- it("expires closed bidi turns before later speaker consults", async () => {
2552
- agentCommandMock.mockResolvedValueOnce({ payloads: [{ text: "guest consult answer" }] });
2553
- const manager = createManager({
2554
- groupPolicy: "open",
2555
- voice: {
2556
- enabled: true,
2557
- mode: "bidi",
2558
- realtime: {
2559
- provider: "openai",
2560
- toolPolicy: "safe-read-only",
2561
- consultPolicy: "always",
2562
- },
2563
- },
2564
- });
2565
-
2566
- await manager.join({ guildId: "g1", channelId: "1001" });
2567
- const entry = getSessionEntry(manager) as {
2568
- realtime?: {
2569
- beginSpeakerTurn: (
2570
- context: { extraSystemPrompt?: string; senderIsOwner: boolean; speakerLabel: string },
2571
- userId: string,
2572
- ) => { close: () => void; sendInputAudio: (audio: Buffer) => void };
2573
- };
2574
- };
2575
- const ownerTurn = entry.realtime?.beginSpeakerTurn(
2576
- { extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
2577
- "u-owner",
2578
- );
2579
- ownerTurn?.sendInputAudio(Buffer.alloc(8));
2580
- ownerTurn?.close();
2581
- const guestTurn = entry.realtime?.beginSpeakerTurn(
2582
- { extraSystemPrompt: undefined, senderIsOwner: false, speakerLabel: "Guest" },
2583
- "u-guest",
2584
- );
2585
- guestTurn?.sendInputAudio(Buffer.alloc(8));
2586
-
2587
- const bridgeParams = lastRealtimeBridgeParams() as
2588
- | {
2589
- onToolCall?: (
2590
- event: {
2591
- itemId: string;
2592
- callId: string;
2593
- name: string;
2594
- args: unknown;
2595
- },
2596
- session: typeof realtimeSessionMock,
2597
- ) => void;
2598
- }
2599
- | undefined;
2600
- bridgeParams?.onToolCall?.(
2601
- {
2602
- itemId: "item-guest",
2603
- callId: "call-guest",
2604
- name: "klaw_agent_consult",
2605
- args: { question: "guest question" },
2606
- },
2607
- realtimeSessionMock,
2608
- );
2609
- await Promise.resolve();
2610
- await Promise.resolve();
2611
-
2612
- const commandArgs = lastAgentCommandArgs();
2613
- expect(commandArgs.senderIsOwner).toBe(false);
2614
- expect(commandArgs.toolsAllow).toEqual([
2615
- "read",
2616
- "web_search",
2617
- "web_fetch",
2618
- "x_search",
2619
- "memory_search",
2620
- "memory_get",
2621
- ]);
2622
- });
2623
-
2624
- it("authorizes realtime speakers before subscribing receiver streams", async () => {
2625
- const connection = createConnectionMock();
2626
- joinVoiceChannelMock.mockReturnValueOnce(connection);
2627
- const client = createClient();
2628
- client.fetchMember.mockResolvedValue({
2629
- nickname: "Denied Speaker",
2630
- roles: [],
2631
- user: {
2632
- id: "u-denied",
2633
- username: "denied",
2634
- globalName: "Denied",
2635
- discriminator: "3333",
2636
- },
2637
- });
2638
- const manager = createManager(
2639
- {
2640
- groupPolicy: "allowlist",
2641
- guilds: {
2642
- g1: {
2643
- channels: {
2644
- "1001": {
2645
- roles: ["role:voice-allowed"],
2646
- },
2647
- },
2648
- },
2649
- },
2650
- voice: {
2651
- enabled: true,
2652
- mode: "bidi",
2653
- realtime: {
2654
- provider: "openai",
2655
- model: "gpt-realtime-2",
2656
- },
2657
- },
2658
- },
2659
- client,
2660
- );
2661
-
2662
- await manager.join({ guildId: "g1", channelId: "1001" });
2663
- const entry = (manager as unknown as { sessions: Map<string, unknown> }).sessions.get("g1") as
2664
- | {
2665
- player: { state: { status: string } };
2666
- }
2667
- | undefined;
2668
- if (!entry) {
2669
- throw new Error("expected voice session for guild g1");
2670
- }
2671
- expect(entry.player.state.status).toBe("idle");
2672
- entry.player.state.status = "playing";
2673
-
2674
- await (
2675
- manager as unknown as {
2676
- handleSpeakingStart: (entry: unknown, userId: string) => Promise<void>;
2677
- }
2678
- ).handleSpeakingStart(entry, "u-denied");
2679
-
2680
- expect(connection.receiver.subscribe).not.toHaveBeenCalled();
2681
- expect(realtimeSessionMock.handleBargeIn).not.toHaveBeenCalled();
2682
- expect(client.fetchMember).toHaveBeenCalledWith("g1", "u-denied");
2683
- });
2684
-
2685
- it("stores guild metadata on joined voice sessions", async () => {
2686
- const manager = createManager();
2687
-
2688
- await manager.join({ guildId: "g1", channelId: "1001" });
2689
-
2690
- const entry = (manager as unknown as { sessions: Map<string, unknown> }).sessions.get("g1") as
2691
- | { guildName?: string }
2692
- | undefined;
2693
- expect(entry?.guildName).toBe("Guild One");
2694
- });
2695
-
2696
- it("enables DAVE receive passthrough after join", async () => {
2697
- const connection = createConnectionMock();
2698
- joinVoiceChannelMock.mockReturnValueOnce(connection);
2699
- const manager = createManager();
2700
-
2701
- await manager.join({ guildId: "g1", channelId: "1001" });
2702
-
2703
- expect(connection.daveSetPassthroughMode).toHaveBeenCalledWith(true, 30);
2704
- });
2705
-
2706
- it("re-arms passthrough but still rejoin-recovers after repeated decrypt failures", async () => {
2707
- const connection = createConnectionMock();
2708
- joinVoiceChannelMock
2709
- .mockReturnValueOnce(connection)
2710
- .mockReturnValueOnce(createConnectionMock());
2711
- const manager = createManager();
2712
-
2713
- await manager.join({ guildId: "g1", channelId: "1001" });
2714
- connection.daveSetPassthroughMode.mockClear();
2715
-
2716
- emitDecryptFailure(manager);
2717
- emitDecryptFailure(manager);
2718
- emitDecryptFailure(manager);
2719
-
2720
- await vi.waitFor(() => {
2721
- expect(connection.daveSetPassthroughMode).toHaveBeenCalledWith(true, 15);
2722
- expect(joinVoiceChannelMock).toHaveBeenCalledTimes(2);
2723
- });
2724
- });
2725
-
2726
- it("resets DAVE receive recovery after realtime audio decodes", async () => {
2727
- const connection = createConnectionMock();
2728
- joinVoiceChannelMock.mockReturnValueOnce(connection);
2729
- decodeOpusStreamChunksMock.mockImplementationOnce(
2730
- async (
2731
- _stream: Readable,
2732
- params: {
2733
- onChunk: (pcm48kStereo: Buffer) => void;
2734
- },
2735
- ) => {
2736
- params.onChunk(Buffer.alloc(8));
2737
- },
2738
- );
2739
- const manager = createManager({
2740
- groupPolicy: "open",
2741
- allowFrom: ["discord:u-speaker"],
2742
- voice: {
2743
- enabled: true,
2744
- mode: "agent-proxy",
2745
- realtime: { provider: "openai" },
2746
- },
2747
- });
2748
-
2749
- await manager.join({ guildId: "g1", channelId: "1001" });
2750
- emitDecryptFailure(manager);
2751
- emitDecryptFailure(manager);
2752
- const entry = getSessionEntry(manager) as {
2753
- receiveRecovery: { decryptFailureCount: number; lastDecryptFailureAt: number };
2754
- };
2755
- expect(entry.receiveRecovery.decryptFailureCount).toBe(2);
2756
- const stream = {
2757
- on: vi.fn(),
2758
- destroy: vi.fn(),
2759
- async *[Symbol.asyncIterator]() {},
2760
- };
2761
- connection.receiver.subscribe.mockReturnValueOnce(stream);
2762
-
2763
- await (
2764
- manager as unknown as {
2765
- handleSpeakingStart: (entry: unknown, userId: string) => Promise<void>;
2766
- }
2767
- ).handleSpeakingStart(entry, "u-speaker");
2768
-
2769
- expect(decodeOpusStreamChunksMock).toHaveBeenCalledTimes(1);
2770
- expect(entry.receiveRecovery.decryptFailureCount).toBe(0);
2771
- expect(entry.receiveRecovery.lastDecryptFailureAt).toBe(0);
2772
- expect(joinVoiceChannelMock).toHaveBeenCalledTimes(1);
2773
- });
2774
-
2775
- it("allows the same speaker to restart after finalize fires", async () => {
2776
- vi.useFakeTimers();
2777
- try {
2778
- const connection = createConnectionMock();
2779
- joinVoiceChannelMock.mockReturnValueOnce(connection);
2780
- const manager = createManager();
2781
-
2782
- await manager.join({ guildId: "g1", channelId: "1001" });
2783
-
2784
- const entry = getSessionEntry(manager) as {
2785
- guildId: string;
2786
- channelId: string;
2787
- capture: {
2788
- activeSpeakers: Set<string>;
2789
- activeCaptureStreams: Map<
2790
- string,
2791
- { generation: number; stream: { destroy: () => void } }
2792
- >;
2793
- captureFinalizeTimers: Map<string, unknown>;
2794
- captureGenerations: Map<string, number>;
2795
- };
2796
- };
2797
-
2798
- const firstStream = { destroy: vi.fn() };
2799
- entry.capture.activeSpeakers.add("u1");
2800
- entry.capture.captureGenerations.set("u1", 1);
2801
- entry.capture.activeCaptureStreams.set("u1", { generation: 1, stream: firstStream });
2802
-
2803
- (
2804
- manager as unknown as {
2805
- scheduleCaptureFinalize: (entry: unknown, userId: string, reason: string) => void;
2806
- }
2807
- ).scheduleCaptureFinalize(entry, "u1", "test");
2808
-
2809
- await vi.advanceTimersByTimeAsync(2_500);
2810
-
2811
- expect(firstStream.destroy).toHaveBeenCalledTimes(1);
2812
- expect(entry?.capture.activeSpeakers.has("u1")).toBe(false);
2813
-
2814
- const secondStream = {
2815
- on: vi.fn(),
2816
- destroy: vi.fn(),
2817
- async *[Symbol.asyncIterator]() {},
2818
- };
2819
- connection.receiver.subscribe.mockReturnValueOnce(secondStream);
2820
-
2821
- await (
2822
- manager as unknown as {
2823
- handleSpeakingStart: (entry: unknown, userId: string) => Promise<void>;
2824
- }
2825
- ).handleSpeakingStart(entry, "u1");
2826
-
2827
- const subscribeCall = lastMockCall(
2828
- connection.receiver.subscribe as unknown as MockCallSource,
2829
- "receiver subscribe",
2830
- );
2831
- expect(subscribeCall?.[0]).toBe("u1");
2832
- expect(
2833
- requireRecord(requireRecord(subscribeCall?.[1], "subscribe options").end, "end").behavior,
2834
- ).toBe("Manual");
2835
- } finally {
2836
- vi.useRealTimers();
2837
- }
2838
- });
2839
-
2840
- it("uses configured silence grace before finalizing voice capture", async () => {
2841
- vi.useFakeTimers();
2842
- try {
2843
- const manager = createManager({
2844
- voice: {
2845
- enabled: true,
2846
- captureSilenceGraceMs: 4_000,
2847
- },
2848
- });
2849
- const stream = { destroy: vi.fn() };
2850
- const entry = {
2851
- guildId: "g1",
2852
- channelId: "1001",
2853
- capture: createVoiceCaptureState(),
2854
- };
2855
- entry.capture.activeSpeakers.add("u1");
2856
- entry.capture.captureGenerations.set("u1", 1);
2857
- entry.capture.activeCaptureStreams.set("u1", {
2858
- generation: 1,
2859
- stream: stream as unknown as Readable,
2860
- });
2861
-
2862
- (
2863
- manager as unknown as {
2864
- scheduleCaptureFinalize: (entry: unknown, userId: string, reason: string) => void;
2865
- }
2866
- ).scheduleCaptureFinalize(entry, "u1", "test");
2867
-
2868
- await vi.advanceTimersByTimeAsync(3_999);
2869
- expect(stream.destroy).not.toHaveBeenCalled();
2870
-
2871
- await vi.advanceTimersByTimeAsync(1);
2872
- expect(stream.destroy).toHaveBeenCalledTimes(1);
2873
- } finally {
2874
- vi.useRealTimers();
2875
- }
2876
- });
2877
-
2878
- it("passes senderIsOwner=true for allowlisted voice speakers", async () => {
2879
- const client = createClient();
2880
- client.fetchMember.mockResolvedValue({
2881
- nickname: "Owner Nick",
2882
- user: {
2883
- id: "u-owner",
2884
- username: "owner",
2885
- globalName: "Owner",
2886
- discriminator: "1234",
2887
- },
2888
- });
2889
- const manager = createManager({ groupPolicy: "open", allowFrom: ["discord:u-owner"] }, client);
2890
- await processVoiceSegment(manager, "u-owner");
2891
-
2892
- const commandArgs = lastAgentCommandArgs() as { senderIsOwner?: boolean } | undefined;
2893
- expect(commandArgs?.senderIsOwner).toBe(true);
2894
- });
2895
-
2896
- it("passes senderIsOwner=false for non-owner voice speakers", async () => {
2897
- const client = createClient();
2898
- client.fetchMember.mockResolvedValue({
2899
- nickname: "Guest Nick",
2900
- user: {
2901
- id: "u-guest",
2902
- username: "guest",
2903
- globalName: "Guest",
2904
- discriminator: "4321",
2905
- },
2906
- });
2907
- const manager = createManager({ groupPolicy: "open", allowFrom: ["discord:u-owner"] }, client, {
2908
- commands: { useAccessGroups: false },
2909
- });
2910
- await processVoiceSegment(manager, "u-guest");
2911
-
2912
- const commandArgs = lastAgentCommandArgs() as { senderIsOwner?: boolean } | undefined;
2913
- expect(commandArgs?.senderIsOwner).toBe(false);
2914
- });
2915
-
2916
- it("passes configured model override to agent command in voice flow", async () => {
2917
- const client = createClient();
2918
- client.fetchMember.mockResolvedValue({
2919
- nickname: "Guest Nick",
2920
- user: {
2921
- id: "u-guest",
2922
- username: "guest",
2923
- globalName: "Guest",
2924
- discriminator: "4321",
2925
- },
2926
- });
2927
- const manager = createManager(
2928
- {
2929
- groupPolicy: "open",
2930
- voice: {
2931
- model: "openai/gpt-5.4-mini",
2932
- },
2933
- },
2934
- client,
2935
- {
2936
- commands: { useAccessGroups: false },
2937
- },
2938
- );
2939
- await processVoiceSegment(manager, "u-guest");
2940
-
2941
- const commandArgs = lastAgentCommandArgs() as
2942
- | { allowModelOverride?: boolean; model?: string }
2943
- | undefined;
2944
-
2945
- expect(commandArgs?.allowModelOverride).toBe(true);
2946
- expect(commandArgs?.model).toBe("openai/gpt-5.4-mini");
2947
- });
2948
-
2949
- it("runs voice replies under Discord voice output policy", async () => {
2950
- agentCommandMock.mockResolvedValueOnce({
2951
- payloads: [{ text: "hello back" }],
2952
- } as never);
2953
-
2954
- const client = createClient();
2955
- client.fetchMember.mockResolvedValue({
2956
- nickname: "Guest Nick",
2957
- user: {
2958
- id: "u-guest",
2959
- username: "guest",
2960
- globalName: "Guest",
2961
- discriminator: "4321",
2962
- },
2963
- });
2964
- const manager = createManager({ groupPolicy: "open" }, client, {
2965
- commands: { useAccessGroups: false },
2966
- });
2967
- await processVoiceSegment(manager, "u-guest");
2968
-
2969
- const commandArgs = lastAgentCommandArgs() as
2970
- | { message?: string; messageChannel?: string; messageProvider?: string }
2971
- | undefined;
2972
-
2973
- expect(commandArgs?.messageChannel).toBe("discord");
2974
- expect(commandArgs?.messageProvider).toBe("discord-voice");
2975
- expect(commandArgs?.message).toContain("Do not call the tts tool");
2976
- expect(commandArgs?.message).toContain("repair obvious transcription artifacts");
2977
- expect(lastTtsArgs().channel).toBe("discord");
2978
- expect(lastTtsArgs().text).toBe("hello back");
2979
- });
2980
-
2981
- it("logs a bounded inbound transcript preview for voice debugging", async () => {
2982
- transcribeAudioFileMock.mockResolvedValueOnce({
2983
- text: `hello from voice\n\n${"x".repeat(700)}`,
2984
- });
2985
- const client = createClient();
2986
- client.fetchMember.mockResolvedValue({
2987
- nickname: "Debug Speaker",
2988
- user: {
2989
- id: "u-debug",
2990
- username: "debug",
2991
- globalName: "Debug",
2992
- discriminator: "0001",
2993
- },
2994
- });
2995
- const manager = createManager({ groupPolicy: "open" }, client, {
2996
- commands: { useAccessGroups: false },
2997
- });
2998
-
2999
- await processVoiceSegment(manager, "u-debug");
3000
-
3001
- const transcriptLog = logVerboseMock.mock.calls
3002
- .map((call) => String(call[0]))
3003
- .find((message) => message.includes("transcript from Debug Speaker (u-debug)"));
3004
- expect(transcriptLog).toContain("hello from voice ");
3005
- expect(transcriptLog).not.toContain("\n");
3006
- expect(transcriptLog?.length).toBeLessThan(650);
3007
- });
3008
-
3009
- it("plays streaming TTS audio before falling back to a synthesized file", async () => {
3010
- const release = vi.fn(async () => undefined);
3011
- textToSpeechStreamMock.mockResolvedValue({
3012
- success: true,
3013
- audioStream: new ReadableStream<Uint8Array>({
3014
- start(controller) {
3015
- controller.enqueue(new Uint8Array([1, 2, 3]));
3016
- controller.close();
3017
- },
3018
- }),
3019
- release,
3020
- });
3021
- agentCommandMock.mockResolvedValueOnce({
3022
- payloads: [{ text: "hello back" }],
3023
- } as never);
3024
-
3025
- const client = createClient();
3026
- client.fetchMember.mockResolvedValue({
3027
- nickname: "Guest Nick",
3028
- user: {
3029
- id: "u-guest",
3030
- username: "guest",
3031
- globalName: "Guest",
3032
- discriminator: "4321",
3033
- },
3034
- });
3035
- const manager = createManager({ groupPolicy: "open" }, client, {
3036
- commands: { useAccessGroups: false },
3037
- });
3038
- await processVoiceSegment(manager, "u-guest");
3039
-
3040
- expect(lastTtsStreamArgs().channel).toBe("discord");
3041
- expect(lastTtsStreamArgs().disableFallback).toBe(true);
3042
- expect(lastTtsStreamArgs().text).toBe("hello back");
3043
- expect(textToSpeechMock).not.toHaveBeenCalled();
3044
- const audioResourceInput = lastMockCall(
3045
- createAudioResourceMock as unknown as MockCallSource,
3046
- "audio resource",
3047
- )[0];
3048
- if (audioResourceInput === undefined) {
3049
- throw new Error("expected Discord audio resource input");
3050
- }
3051
- await vi.waitFor(() => expect(release).toHaveBeenCalledTimes(1));
3052
- });
3053
-
3054
- it("passes per-channel system prompt overrides to voice agent runs", async () => {
3055
- const client = createClient();
3056
- client.fetchMember.mockResolvedValue({
3057
- nickname: "Guest Nick",
3058
- user: {
3059
- id: "u-guest",
3060
- username: "guest",
3061
- globalName: "Guest",
3062
- discriminator: "4321",
3063
- },
3064
- });
3065
- const manager = createManager(
3066
- {
3067
- groupPolicy: "open",
3068
- guilds: {
3069
- g1: {
3070
- channels: {
3071
- "1001": {
3072
- systemPrompt: " Use short voice replies. ",
3073
- },
3074
- },
3075
- },
3076
- },
3077
- },
3078
- client,
3079
- {
3080
- commands: { useAccessGroups: false },
3081
- },
3082
- );
3083
- await processVoiceSegment(manager, "u-guest");
3084
-
3085
- const commandArgs = lastAgentCommandArgs() as { extraSystemPrompt?: string } | undefined;
3086
-
3087
- expect(commandArgs?.extraSystemPrompt).toBe("Use short voice replies.");
3088
- });
3089
-
3090
- it("reuses speaker context cache for repeated segments from the same speaker", async () => {
3091
- const client = createClient();
3092
- client.fetchMember.mockResolvedValue({
3093
- nickname: "Cached Speaker",
3094
- user: {
3095
- id: "u-cache",
3096
- username: "cache",
3097
- globalName: "Cache",
3098
- discriminator: "1111",
3099
- },
3100
- });
3101
- const manager = createManager({ allowFrom: ["discord:u-cache"] }, client);
3102
- const runSegment = async () => await processVoiceSegment(manager, "u-cache");
3103
-
3104
- await runSegment();
3105
- await runSegment();
3106
-
3107
- expect(client.fetchMember).toHaveBeenCalledTimes(3);
3108
- });
3109
-
3110
- it("persists full speaker context in cache writes", async () => {
3111
- const client = createClient();
3112
- client.fetchMember.mockResolvedValue({
3113
- nickname: "Role Speaker",
3114
- roles: ["role-voice"],
3115
- user: {
3116
- id: "u-role",
3117
- username: "role",
3118
- globalName: "Role",
3119
- discriminator: "2222",
3120
- },
3121
- });
3122
- const manager = createManager(
3123
- {
3124
- groupPolicy: "allowlist",
3125
- guilds: {
3126
- g1: {
3127
- channels: {
3128
- "1001": {
3129
- roles: ["role:role-voice"],
3130
- },
3131
- },
3132
- },
3133
- },
3134
- },
3135
- client,
3136
- );
3137
-
3138
- await processVoiceSegment(manager, "u-role");
3139
-
3140
- const cache = (
3141
- manager as unknown as {
3142
- speakerContext: {
3143
- cache: Map<
3144
- string,
3145
- {
3146
- id?: string;
3147
- label: string;
3148
- name?: string;
3149
- tag?: string;
3150
- senderIsOwner: boolean;
3151
- expiresAt: number;
3152
- }
3153
- >;
3154
- };
3155
- }
3156
- ).speakerContext.cache;
3157
- const cached = cache.get("g1:u-role");
3158
-
3159
- expect(cached?.id).toBe("u-role");
3160
- expect(cached?.label).toBe("Role Speaker");
3161
- });
3162
-
3163
- it("re-fetches member roles for repeated voice auth checks", async () => {
3164
- const client = createClient();
3165
- client.fetchMember
3166
- .mockResolvedValueOnce({
3167
- nickname: "Role Speaker",
3168
- roles: ["role-voice"],
3169
- user: {
3170
- id: "u-role",
3171
- username: "role",
3172
- globalName: "Role",
3173
- discriminator: "2222",
3174
- },
3175
- })
3176
- .mockResolvedValueOnce({
3177
- nickname: "Role Speaker",
3178
- roles: ["role-voice"],
3179
- user: {
3180
- id: "u-role",
3181
- username: "role",
3182
- globalName: "Role",
3183
- discriminator: "2222",
3184
- },
3185
- })
3186
- .mockResolvedValueOnce({
3187
- nickname: "Role Speaker",
3188
- roles: [],
3189
- user: {
3190
- id: "u-role",
3191
- username: "role",
3192
- globalName: "Role",
3193
- discriminator: "2222",
3194
- },
3195
- })
3196
- .mockResolvedValue({
3197
- nickname: "Role Speaker",
3198
- roles: [],
3199
- user: {
3200
- id: "u-role",
3201
- username: "role",
3202
- globalName: "Role",
3203
- discriminator: "2222",
3204
- },
3205
- });
3206
- const manager = createManager(
3207
- {
3208
- groupPolicy: "allowlist",
3209
- guilds: {
3210
- g1: {
3211
- channels: {
3212
- "1001": {
3213
- roles: ["role:role-voice"],
3214
- },
3215
- },
3216
- },
3217
- },
3218
- },
3219
- client,
3220
- );
3221
-
3222
- await processVoiceSegment(manager, "u-role");
3223
- await processVoiceSegment(manager, "u-role");
3224
-
3225
- expect(agentCommandMock).toHaveBeenCalledTimes(1);
3226
- expect(client.fetchMember).toHaveBeenCalledTimes(3);
3227
- });
3228
-
3229
- it("fetches guild metadata before allowlist checks when the session lacks a guild name", async () => {
3230
- const client = createClient();
3231
- client.fetchGuild.mockResolvedValue({ id: "g1", name: "Guild One" });
3232
- client.fetchMember.mockResolvedValue({
3233
- nickname: "Owner Nick",
3234
- user: {
3235
- id: "u-owner",
3236
- username: "owner",
3237
- globalName: "Owner",
3238
- discriminator: "1234",
3239
- },
3240
- });
3241
- const manager = createManager(
3242
- {
3243
- groupPolicy: "allowlist",
3244
- guilds: {
3245
- "guild-one": {
3246
- channels: {
3247
- "*": {
3248
- users: ["discord:u-owner"],
3249
- },
3250
- },
3251
- },
3252
- },
3253
- },
3254
- client,
3255
- );
3256
-
3257
- await processVoiceSegment(manager, "u-owner");
3258
-
3259
- expect(client.fetchGuild).toHaveBeenCalledWith("g1");
3260
- expect(agentCommandMock).toHaveBeenCalledTimes(1);
3261
- });
3262
-
3263
- it("DiscordVoiceReadyListener: starts autoJoin fire-and-forget on ready", async () => {
3264
- const manager = createManager();
3265
- const autoJoinSpy = vi
3266
- .spyOn(manager, "autoJoin")
3267
- .mockRejectedValue(new Error("autoJoin rejected"));
3268
-
3269
- const { DiscordVoiceReadyListener } = managerModule;
3270
- const listener = new DiscordVoiceReadyListener(manager);
3271
-
3272
- await expect(listener.handle(undefined, undefined as never)).resolves.toBeUndefined();
3273
- expect(autoJoinSpy).toHaveBeenCalledTimes(1);
3274
- });
3275
-
3276
- it("DiscordVoiceResumedListener: runs autoJoin on gateway resume", async () => {
3277
- const manager = createManager();
3278
- const autoJoinSpy = vi.spyOn(manager, "autoJoin").mockResolvedValue(undefined);
3279
-
3280
- const { DiscordVoiceResumedListener } = managerModule;
3281
- const listener = new DiscordVoiceResumedListener(manager);
3282
-
3283
- await expect(listener.handle(undefined, undefined as never)).resolves.toBeUndefined();
3284
- expect(autoJoinSpy).toHaveBeenCalledTimes(1);
3285
- });
3286
- });