@kodelyth/discord 2026.5.39 → 2026.5.42

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 (639) hide show
  1. package/account-inspect-api.ts +6 -0
  2. package/action-runtime-api.ts +1 -0
  3. package/api.ts +130 -0
  4. package/channel-config-api.ts +1 -0
  5. package/channel-plugin-api.ts +3 -0
  6. package/config-api.ts +4 -0
  7. package/configured-state.ts +6 -0
  8. package/contract-api.ts +21 -0
  9. package/directory-contract-api.ts +4 -0
  10. package/dist/account-inspect-Dqw-enky.js +81 -0
  11. package/dist/account-inspect-api.js +10 -0
  12. package/dist/accounts-B7OBFePq.js +224 -0
  13. package/dist/action-runtime-api.js +2 -0
  14. package/dist/agent-components.runtime-DVY_1VB4.js +4 -0
  15. package/dist/allow-list-B0s7evD7.js +354 -0
  16. package/dist/api-CXAcv9nZ.js +130 -0
  17. package/dist/api.js +23 -0
  18. package/dist/approval-handler.runtime-B9xUAF3n.js +426 -0
  19. package/dist/audit-DoiK49WO.js +24 -0
  20. package/dist/audit-core-BGrq3G7r.js +105 -0
  21. package/dist/channel-U_aeoFwW.js +795 -0
  22. package/dist/channel-actions-BxEBnEuv.js +173 -0
  23. package/dist/channel-actions.runtime-CPtpH-yl.js +263 -0
  24. package/dist/channel-api-BfjklLby.js +21 -0
  25. package/dist/channel-config-api.js +2 -0
  26. package/dist/channel-plugin-api.js +2 -0
  27. package/dist/channel.setup-BUSC0apv.js +337 -0
  28. package/dist/components-luonoe13.js +909 -0
  29. package/dist/config-api-DSYGqaLQ.js +2 -0
  30. package/dist/config-schema-DIqJBGwC.js +357 -0
  31. package/dist/configured-state.js +6 -0
  32. package/dist/contract-api.js +8 -0
  33. package/dist/conversation-identity-DXAm0_Mk.js +270 -0
  34. package/dist/directory-config-CYbuMmPS.js +49 -0
  35. package/dist/directory-contract-api.js +2 -0
  36. package/dist/directory-live-DX4dLRpJ.js +159 -0
  37. package/dist/doctor-bbKSvGVD.js +244 -0
  38. package/dist/doctor-contract-Btjt6NJD.js +383 -0
  39. package/dist/doctor-contract-api.js +2 -0
  40. package/dist/gateway-registry-BKSpa4GB.js +74 -0
  41. package/dist/handle-action.guild-admin-B5BArS2n.js +286 -0
  42. package/dist/inbound-context-WAOqhGlT.js +48 -0
  43. package/dist/inbound-event-delivery-C-1Ji3WP.js +65 -0
  44. package/dist/index.js +26 -0
  45. package/dist/manager.runtime-DXHynKE4.js +2356 -0
  46. package/dist/message-handler-mXzc3tA_.js +381 -0
  47. package/dist/message-handler.preflight-BPD1a347.js +1113 -0
  48. package/dist/message-handler.process-GUa3aV8z.js +1438 -0
  49. package/dist/message-utils-dUbem16p.js +549 -0
  50. package/dist/outbound-adapter-C18OAc1y.js +536 -0
  51. package/dist/pluralkit-D1Q2x0w5.js +22 -0
  52. package/dist/preflight-audio-CZtpWcIm.js +72 -0
  53. package/dist/preflight-audio.runtime-Brx_0_xW.js +7 -0
  54. package/dist/preview-streaming-D_slNIiO.js +8 -0
  55. package/dist/probe-D--Ca4JF.js +139 -0
  56. package/dist/probe.runtime-DQBchZzv.js +2 -0
  57. package/dist/provider-B2-31CIT.js +9565 -0
  58. package/dist/provider-session.runtime-BwzzSsrH.js +6 -0
  59. package/dist/provider.runtime-CP3oHLls.js +2 -0
  60. package/dist/resolve-allowlist-common-CqxPLcJO.js +34 -0
  61. package/dist/resolve-channels-0LX4pUbB.js +265 -0
  62. package/dist/resolve-users-CztOv0Qs.js +120 -0
  63. package/dist/runtime-DUaw66V_.js +1073 -0
  64. package/dist/runtime-api.actions.js +3 -0
  65. package/dist/runtime-api.js +30 -0
  66. package/dist/runtime-api.lookup.js +7 -0
  67. package/dist/runtime-api.monitor-CvVKvEXW.js +5 -0
  68. package/dist/runtime-api.monitor.js +8 -0
  69. package/dist/runtime-api.send.js +6 -0
  70. package/dist/runtime-api.threads.js +6 -0
  71. package/dist/runtime-fC6f4UF2.js +8 -0
  72. package/dist/runtime-setter-api.js +2 -0
  73. package/dist/secret-config-contract-B6WW5V88.js +115 -0
  74. package/dist/secret-contract-api.js +2 -0
  75. package/dist/security-audit-CnyIQKz6.js +120 -0
  76. package/dist/security-audit-contract-api.js +2 -0
  77. package/dist/security-audit.runtime-CQSkjNLu.js +2 -0
  78. package/dist/security-contract-DLvYOgLM.js +26 -0
  79. package/dist/security-contract-api.js +2 -0
  80. package/dist/security-doctor-DepqtNCI.js +18 -0
  81. package/dist/send-DCtPCHGk.js +881 -0
  82. package/dist/send.components-Bcgxvm52.js +474 -0
  83. package/dist/send.outbound-S9t0UuHc.js +330 -0
  84. package/dist/send.receipt-CDn3GBWC.js +3119 -0
  85. package/dist/send.shared-D4iBnAmn.js +669 -0
  86. package/dist/sender-identity-CxCe3_1a.js +43 -0
  87. package/dist/session-contract-Dwhw3RTY.js +6 -0
  88. package/dist/session-key-api.js +2 -0
  89. package/dist/session-key-normalization-CP8dPUid.js +23 -0
  90. package/dist/setup-entry.js +11 -0
  91. package/dist/setup-plugin-api.js +2 -0
  92. package/dist/shared-AIlvuZXt.js +171 -0
  93. package/dist/subagent-hooks-8bK-mgiU.js +120 -0
  94. package/dist/subagent-hooks-api.js +22 -0
  95. package/dist/system-events-Ba1TklaL.js +34 -0
  96. package/dist/target-resolver-BrtFQtoK.js +82 -0
  97. package/dist/targets-DWLLZE2l.js +3 -0
  98. package/dist/test-api.js +45 -0
  99. package/dist/thread-binding-api.js +4 -0
  100. package/dist/thread-bindings-9aKRmZv0.js +255 -0
  101. package/dist/thread-bindings.discord-api-ssGH5wc2.js +244 -0
  102. package/dist/thread-bindings.manager-0YBHGemk.js +534 -0
  103. package/dist/thread-bindings.session-updates-DJZGIwaU.js +54 -0
  104. package/dist/thread-bindings.state-eTFl-PqJ.js +318 -0
  105. package/dist/timeouts-CEwuGaWT.js +52 -0
  106. package/dist/timeouts.js +2 -0
  107. package/dist/typing-BmJKRpCS.js +14 -0
  108. package/doctor-contract-api.ts +1 -0
  109. package/index.test.ts +13 -0
  110. package/index.ts +24 -0
  111. package/klaw.plugin.json +2 -3822
  112. package/package.json +4 -4
  113. package/runtime-api.actions.ts +15 -0
  114. package/runtime-api.lookup.ts +22 -0
  115. package/runtime-api.monitor.ts +50 -0
  116. package/runtime-api.send.ts +79 -0
  117. package/runtime-api.threads.ts +31 -0
  118. package/runtime-api.ts +181 -0
  119. package/runtime-setter-api.ts +3 -0
  120. package/secret-contract-api.ts +4 -0
  121. package/security-audit-contract-api.ts +1 -0
  122. package/security-contract-api.ts +4 -0
  123. package/session-key-api.ts +1 -0
  124. package/setup-entry.ts +9 -0
  125. package/setup-plugin-api.ts +3 -0
  126. package/src/account-inspect.test.ts +126 -0
  127. package/src/account-inspect.ts +128 -0
  128. package/src/accounts.test.ts +381 -0
  129. package/src/accounts.ts +205 -0
  130. package/src/actions/handle-action.guild-admin.ts +421 -0
  131. package/src/actions/handle-action.test.ts +480 -0
  132. package/src/actions/handle-action.ts +402 -0
  133. package/src/actions/runtime.guild.ts +446 -0
  134. package/src/actions/runtime.messaging.messages.ts +226 -0
  135. package/src/actions/runtime.messaging.reactions.ts +67 -0
  136. package/src/actions/runtime.messaging.runtime.ts +73 -0
  137. package/src/actions/runtime.messaging.send.ts +336 -0
  138. package/src/actions/runtime.messaging.shared.ts +97 -0
  139. package/src/actions/runtime.messaging.ts +37 -0
  140. package/src/actions/runtime.moderation-shared.ts +48 -0
  141. package/src/actions/runtime.moderation.authz.test.ts +151 -0
  142. package/src/actions/runtime.moderation.ts +116 -0
  143. package/src/actions/runtime.presence.test.ts +165 -0
  144. package/src/actions/runtime.presence.ts +117 -0
  145. package/src/actions/runtime.shared.ts +86 -0
  146. package/src/actions/runtime.test.ts +1337 -0
  147. package/src/actions/runtime.ts +87 -0
  148. package/src/api-barrel.test.ts +78 -0
  149. package/src/api.test.ts +152 -0
  150. package/src/api.ts +215 -0
  151. package/src/approval-handler.runtime.test.ts +41 -0
  152. package/src/approval-handler.runtime.ts +633 -0
  153. package/src/approval-native.test.ts +330 -0
  154. package/src/approval-native.ts +219 -0
  155. package/src/approval-runtime.ts +14 -0
  156. package/src/approval-shared.ts +50 -0
  157. package/src/audit-core.ts +178 -0
  158. package/src/audit.test.ts +204 -0
  159. package/src/audit.ts +32 -0
  160. package/src/channel-actions.contract.test.ts +45 -0
  161. package/src/channel-actions.runtime.ts +1 -0
  162. package/src/channel-actions.test.ts +504 -0
  163. package/src/channel-actions.ts +254 -0
  164. package/src/channel-api.ts +29 -0
  165. package/src/channel.conversation.ts +159 -0
  166. package/src/channel.loaders.ts +50 -0
  167. package/src/channel.message-adapter.test.ts +230 -0
  168. package/src/channel.runtime.ts +1 -0
  169. package/src/channel.setup.ts +12 -0
  170. package/src/channel.test.ts +828 -0
  171. package/src/channel.ts +728 -0
  172. package/src/chunk.test.ts +170 -0
  173. package/src/chunk.ts +321 -0
  174. package/src/client.proxy.test.ts +177 -0
  175. package/src/client.test.ts +83 -0
  176. package/src/client.ts +143 -0
  177. package/src/component-custom-id.ts +72 -0
  178. package/src/components-registry.ts +356 -0
  179. package/src/components.builders.ts +409 -0
  180. package/src/components.modal.ts +124 -0
  181. package/src/components.parse.ts +407 -0
  182. package/src/components.test.ts +345 -0
  183. package/src/components.ts +54 -0
  184. package/src/components.types.ts +187 -0
  185. package/src/config-schema.test.ts +439 -0
  186. package/src/config-schema.ts +6 -0
  187. package/src/config-ui-hints.ts +354 -0
  188. package/src/conversation-identity.ts +58 -0
  189. package/src/delivery-retry.ts +52 -0
  190. package/src/directory-cache.ts +116 -0
  191. package/src/directory-config.ts +58 -0
  192. package/src/directory-contract.test.ts +129 -0
  193. package/src/directory-live.test.ts +141 -0
  194. package/src/directory-live.ts +135 -0
  195. package/src/doctor-contract.ts +477 -0
  196. package/src/doctor-shared.ts +5 -0
  197. package/src/doctor.test.ts +393 -0
  198. package/src/doctor.ts +340 -0
  199. package/src/draft-chunking.test.ts +64 -0
  200. package/src/draft-chunking.ts +43 -0
  201. package/src/draft-stream.test.ts +193 -0
  202. package/src/draft-stream.ts +162 -0
  203. package/src/durable-delivery.test.ts +103 -0
  204. package/src/error-body.ts +38 -0
  205. package/src/exec-approvals.test.ts +88 -0
  206. package/src/exec-approvals.ts +110 -0
  207. package/src/gateway-logging.test.ts +98 -0
  208. package/src/gateway-logging.ts +67 -0
  209. package/src/group-policy.ts +113 -0
  210. package/src/guilds.ts +29 -0
  211. package/src/inbound-context.contract.test.ts +11 -0
  212. package/src/inbound-event-delivery.ts +135 -0
  213. package/src/interactive-dispatch.ts +104 -0
  214. package/src/internal/api.commands.ts +51 -0
  215. package/src/internal/api.guild.ts +164 -0
  216. package/src/internal/api.interactions.ts +53 -0
  217. package/src/internal/api.messages.ts +113 -0
  218. package/src/internal/api.reactions.ts +38 -0
  219. package/src/internal/api.test.ts +260 -0
  220. package/src/internal/api.ts +61 -0
  221. package/src/internal/api.users.ts +19 -0
  222. package/src/internal/api.webhooks.ts +13 -0
  223. package/src/internal/client.test.ts +472 -0
  224. package/src/internal/client.ts +310 -0
  225. package/src/internal/command-deploy.test.ts +197 -0
  226. package/src/internal/command-deploy.ts +352 -0
  227. package/src/internal/commands.ts +188 -0
  228. package/src/internal/components.base.ts +65 -0
  229. package/src/internal/components.message.ts +279 -0
  230. package/src/internal/components.modal.ts +95 -0
  231. package/src/internal/components.ts +31 -0
  232. package/src/internal/discord.ts +11 -0
  233. package/src/internal/embeds.ts +35 -0
  234. package/src/internal/entity-cache.ts +98 -0
  235. package/src/internal/event-queue.ts +185 -0
  236. package/src/internal/gateway-close-codes.ts +25 -0
  237. package/src/internal/gateway-dispatch.ts +96 -0
  238. package/src/internal/gateway-identify-limiter.ts +26 -0
  239. package/src/internal/gateway-lifecycle.test.ts +114 -0
  240. package/src/internal/gateway-lifecycle.ts +75 -0
  241. package/src/internal/gateway-rate-limit.ts +104 -0
  242. package/src/internal/gateway.test.ts +676 -0
  243. package/src/internal/gateway.ts +479 -0
  244. package/src/internal/interaction-dispatch.test.ts +148 -0
  245. package/src/internal/interaction-dispatch.ts +162 -0
  246. package/src/internal/interaction-options.ts +98 -0
  247. package/src/internal/interaction-response.ts +53 -0
  248. package/src/internal/interactions.test.ts +329 -0
  249. package/src/internal/interactions.ts +378 -0
  250. package/src/internal/listeners.ts +91 -0
  251. package/src/internal/live-smoke.live.test.ts +26 -0
  252. package/src/internal/modal-fields.ts +95 -0
  253. package/src/internal/payload.ts +69 -0
  254. package/src/internal/rest-body.ts +115 -0
  255. package/src/internal/rest-errors.ts +88 -0
  256. package/src/internal/rest-routes.ts +50 -0
  257. package/src/internal/rest-scheduler.ts +557 -0
  258. package/src/internal/rest.test.ts +681 -0
  259. package/src/internal/rest.ts +322 -0
  260. package/src/internal/schemas.ts +36 -0
  261. package/src/internal/structures.test.ts +43 -0
  262. package/src/internal/structures.ts +280 -0
  263. package/src/internal/test-builders.test-support.ts +167 -0
  264. package/src/internal/voice.ts +49 -0
  265. package/src/media-detection.ts +28 -0
  266. package/src/mentions.test.ts +111 -0
  267. package/src/mentions.ts +147 -0
  268. package/src/monitor/ack-reactions.ts +70 -0
  269. package/src/monitor/acp-bind-here.integration.test.ts +219 -0
  270. package/src/monitor/agent-components-auth.ts +7 -0
  271. package/src/monitor/agent-components-context.ts +154 -0
  272. package/src/monitor/agent-components-data.ts +224 -0
  273. package/src/monitor/agent-components-dm-auth.ts +177 -0
  274. package/src/monitor/agent-components-guild-auth.ts +322 -0
  275. package/src/monitor/agent-components-helpers.runtime.ts +3 -0
  276. package/src/monitor/agent-components-helpers.ts +34 -0
  277. package/src/monitor/agent-components-reply.ts +10 -0
  278. package/src/monitor/agent-components.deps.runtime.ts +2 -0
  279. package/src/monitor/agent-components.dispatch.ts +359 -0
  280. package/src/monitor/agent-components.handlers.ts +303 -0
  281. package/src/monitor/agent-components.modal.ts +160 -0
  282. package/src/monitor/agent-components.plugin-interactive.ts +187 -0
  283. package/src/monitor/agent-components.runtime.ts +14 -0
  284. package/src/monitor/agent-components.system-controls.ts +215 -0
  285. package/src/monitor/agent-components.ts +70 -0
  286. package/src/monitor/agent-components.types.ts +58 -0
  287. package/src/monitor/agent-components.wildcard-controls.ts +171 -0
  288. package/src/monitor/agent-components.wildcard.test.ts +71 -0
  289. package/src/monitor/allow-list.test.ts +14 -0
  290. package/src/monitor/allow-list.ts +631 -0
  291. package/src/monitor/auto-presence.test.ts +184 -0
  292. package/src/monitor/auto-presence.ts +356 -0
  293. package/src/monitor/channel-access.test.ts +113 -0
  294. package/src/monitor/channel-access.ts +102 -0
  295. package/src/monitor/commands.test.ts +24 -0
  296. package/src/monitor/commands.ts +9 -0
  297. package/src/monitor/dm-command-auth.test.ts +274 -0
  298. package/src/monitor/dm-command-auth.ts +259 -0
  299. package/src/monitor/dm-command-decision.test.ts +108 -0
  300. package/src/monitor/dm-command-decision.ts +49 -0
  301. package/src/monitor/exec-approvals.test.ts +225 -0
  302. package/src/monitor/exec-approvals.ts +158 -0
  303. package/src/monitor/format.ts +45 -0
  304. package/src/monitor/gateway-handle.ts +33 -0
  305. package/src/monitor/gateway-metadata.test.ts +29 -0
  306. package/src/monitor/gateway-metadata.ts +298 -0
  307. package/src/monitor/gateway-plugin.test.ts +320 -0
  308. package/src/monitor/gateway-plugin.ts +302 -0
  309. package/src/monitor/gateway-registry.ts +37 -0
  310. package/src/monitor/gateway-supervisor.test.ts +157 -0
  311. package/src/monitor/gateway-supervisor.ts +206 -0
  312. package/src/monitor/inbound-context.test-helpers.ts +37 -0
  313. package/src/monitor/inbound-context.test.ts +112 -0
  314. package/src/monitor/inbound-context.ts +95 -0
  315. package/src/monitor/inbound-dedupe.ts +79 -0
  316. package/src/monitor/inbound-job.test.ts +216 -0
  317. package/src/monitor/inbound-job.ts +118 -0
  318. package/src/monitor/listeners.queue.ts +91 -0
  319. package/src/monitor/listeners.reactions.ts +594 -0
  320. package/src/monitor/listeners.test.ts +209 -0
  321. package/src/monitor/listeners.ts +150 -0
  322. package/src/monitor/message-channel-info.ts +96 -0
  323. package/src/monitor/message-forwarded.ts +114 -0
  324. package/src/monitor/message-handler.batch-gate.test.ts +22 -0
  325. package/src/monitor/message-handler.batch-gate.ts +19 -0
  326. package/src/monitor/message-handler.bot-self-filter.test.ts +68 -0
  327. package/src/monitor/message-handler.context.ts +492 -0
  328. package/src/monitor/message-handler.dm-preflight.ts +119 -0
  329. package/src/monitor/message-handler.draft-preview.ts +426 -0
  330. package/src/monitor/message-handler.hydration.test.ts +80 -0
  331. package/src/monitor/message-handler.hydration.ts +198 -0
  332. package/src/monitor/message-handler.inbound-context.test.ts +61 -0
  333. package/src/monitor/message-handler.module-test-helpers.ts +31 -0
  334. package/src/monitor/message-handler.preflight-channel-access.ts +86 -0
  335. package/src/monitor/message-handler.preflight-channel-context.test.ts +18 -0
  336. package/src/monitor/message-handler.preflight-channel-context.ts +58 -0
  337. package/src/monitor/message-handler.preflight-context.ts +54 -0
  338. package/src/monitor/message-handler.preflight-helpers.ts +164 -0
  339. package/src/monitor/message-handler.preflight-history.ts +23 -0
  340. package/src/monitor/message-handler.preflight-logging.ts +36 -0
  341. package/src/monitor/message-handler.preflight-pluralkit.ts +26 -0
  342. package/src/monitor/message-handler.preflight-runtime.ts +28 -0
  343. package/src/monitor/message-handler.preflight-thread.ts +49 -0
  344. package/src/monitor/message-handler.preflight.acp-bindings.test.ts +371 -0
  345. package/src/monitor/message-handler.preflight.test-helpers.ts +114 -0
  346. package/src/monitor/message-handler.preflight.test.ts +2255 -0
  347. package/src/monitor/message-handler.preflight.ts +822 -0
  348. package/src/monitor/message-handler.preflight.types.ts +115 -0
  349. package/src/monitor/message-handler.process.test.ts +2520 -0
  350. package/src/monitor/message-handler.process.ts +1027 -0
  351. package/src/monitor/message-handler.queue.test.ts +680 -0
  352. package/src/monitor/message-handler.routing-preflight.ts +112 -0
  353. package/src/monitor/message-handler.test-harness.ts +99 -0
  354. package/src/monitor/message-handler.test-helpers.ts +75 -0
  355. package/src/monitor/message-handler.ts +309 -0
  356. package/src/monitor/message-media.ts +536 -0
  357. package/src/monitor/message-run-queue.ts +101 -0
  358. package/src/monitor/message-text.ts +171 -0
  359. package/src/monitor/message-utils.test.ts +1234 -0
  360. package/src/monitor/message-utils.ts +34 -0
  361. package/src/monitor/model-picker-preferences.test.ts +67 -0
  362. package/src/monitor/model-picker-preferences.ts +184 -0
  363. package/src/monitor/model-picker.state.ts +364 -0
  364. package/src/monitor/model-picker.test-utils.ts +26 -0
  365. package/src/monitor/model-picker.test.ts +869 -0
  366. package/src/monitor/model-picker.ts +38 -0
  367. package/src/monitor/model-picker.view.ts +722 -0
  368. package/src/monitor/monitor.agent-components.test.ts +410 -0
  369. package/src/monitor/monitor.test.ts +919 -0
  370. package/src/monitor/monitor.threading-utils.test.ts +614 -0
  371. package/src/monitor/native-command-agent-reply.ts +125 -0
  372. package/src/monitor/native-command-arg-ui.ts +233 -0
  373. package/src/monitor/native-command-auth.ts +309 -0
  374. package/src/monitor/native-command-bypass.ts +13 -0
  375. package/src/monitor/native-command-context.test.ts +105 -0
  376. package/src/monitor/native-command-context.ts +109 -0
  377. package/src/monitor/native-command-dispatch.ts +35 -0
  378. package/src/monitor/native-command-model-picker-apply.ts +209 -0
  379. package/src/monitor/native-command-model-picker-interaction.ts +516 -0
  380. package/src/monitor/native-command-model-picker-ui.ts +357 -0
  381. package/src/monitor/native-command-reply.test.ts +68 -0
  382. package/src/monitor/native-command-reply.ts +185 -0
  383. package/src/monitor/native-command-route.ts +91 -0
  384. package/src/monitor/native-command-status.ts +76 -0
  385. package/src/monitor/native-command-ui.ts +26 -0
  386. package/src/monitor/native-command-ui.types.ts +20 -0
  387. package/src/monitor/native-command.args.ts +45 -0
  388. package/src/monitor/native-command.command-arg.test.ts +108 -0
  389. package/src/monitor/native-command.commands-allowfrom.test.ts +504 -0
  390. package/src/monitor/native-command.model-picker.test.ts +930 -0
  391. package/src/monitor/native-command.options.test.ts +379 -0
  392. package/src/monitor/native-command.options.ts +153 -0
  393. package/src/monitor/native-command.plugin-dispatch.test.ts +1212 -0
  394. package/src/monitor/native-command.runtime.ts +51 -0
  395. package/src/monitor/native-command.status-direct.test.ts +278 -0
  396. package/src/monitor/native-command.test-helpers.ts +64 -0
  397. package/src/monitor/native-command.think-autocomplete.test.ts +411 -0
  398. package/src/monitor/native-command.ts +747 -0
  399. package/src/monitor/native-command.types.ts +9 -0
  400. package/src/monitor/native-interaction-channel-context.ts +50 -0
  401. package/src/monitor/preflight-audio.runtime.ts +9 -0
  402. package/src/monitor/preflight-audio.test.ts +157 -0
  403. package/src/monitor/preflight-audio.ts +130 -0
  404. package/src/monitor/presence-cache.ts +61 -0
  405. package/src/monitor/presence.test.ts +61 -0
  406. package/src/monitor/presence.ts +50 -0
  407. package/src/monitor/provider-session.runtime.ts +12 -0
  408. package/src/monitor/provider.acp.ts +89 -0
  409. package/src/monitor/provider.allowlist.test.ts +217 -0
  410. package/src/monitor/provider.allowlist.ts +398 -0
  411. package/src/monitor/provider.cleanup.ts +41 -0
  412. package/src/monitor/provider.commands.ts +129 -0
  413. package/src/monitor/provider.config-log.ts +45 -0
  414. package/src/monitor/provider.deploy-errors.ts +362 -0
  415. package/src/monitor/provider.deploy.ts +221 -0
  416. package/src/monitor/provider.interactions.ts +160 -0
  417. package/src/monitor/provider.lifecycle.test.ts +734 -0
  418. package/src/monitor/provider.lifecycle.ts +562 -0
  419. package/src/monitor/provider.proxy.test.ts +804 -0
  420. package/src/monitor/provider.rest-proxy.test.ts +389 -0
  421. package/src/monitor/provider.runtime.ts +1 -0
  422. package/src/monitor/provider.skill-dedupe.test.ts +42 -0
  423. package/src/monitor/provider.startup-log.ts +32 -0
  424. package/src/monitor/provider.startup.test.ts +440 -0
  425. package/src/monitor/provider.startup.ts +323 -0
  426. package/src/monitor/provider.test.ts +1173 -0
  427. package/src/monitor/provider.ts +688 -0
  428. package/src/monitor/reply-context.ts +64 -0
  429. package/src/monitor/reply-delivery.test.ts +474 -0
  430. package/src/monitor/reply-delivery.ts +212 -0
  431. package/src/monitor/reply-safety.ts +96 -0
  432. package/src/monitor/rest-fetch.ts +94 -0
  433. package/src/monitor/route-resolution.test.ts +209 -0
  434. package/src/monitor/route-resolution.ts +140 -0
  435. package/src/monitor/sender-identity.ts +81 -0
  436. package/src/monitor/startup-status.test.ts +30 -0
  437. package/src/monitor/startup-status.ts +10 -0
  438. package/src/monitor/status.ts +22 -0
  439. package/src/monitor/system-events.ts +55 -0
  440. package/src/monitor/thread-bindings.config.ts +35 -0
  441. package/src/monitor/thread-bindings.discord-api.test.ts +250 -0
  442. package/src/monitor/thread-bindings.discord-api.ts +310 -0
  443. package/src/monitor/thread-bindings.lifecycle.test.ts +1994 -0
  444. package/src/monitor/thread-bindings.lifecycle.ts +354 -0
  445. package/src/monitor/thread-bindings.manager.ts +551 -0
  446. package/src/monitor/thread-bindings.messages.ts +6 -0
  447. package/src/monitor/thread-bindings.persona.test.ts +34 -0
  448. package/src/monitor/thread-bindings.persona.ts +25 -0
  449. package/src/monitor/thread-bindings.session-adapter.ts +229 -0
  450. package/src/monitor/thread-bindings.session-shared.ts +59 -0
  451. package/src/monitor/thread-bindings.session-updates.ts +35 -0
  452. package/src/monitor/thread-bindings.shared-state.test.ts +39 -0
  453. package/src/monitor/thread-bindings.state.ts +540 -0
  454. package/src/monitor/thread-bindings.ts +48 -0
  455. package/src/monitor/thread-bindings.types.ts +83 -0
  456. package/src/monitor/thread-channel-context.ts +112 -0
  457. package/src/monitor/thread-session-close.test.ts +180 -0
  458. package/src/monitor/thread-session-close.ts +63 -0
  459. package/src/monitor/thread-title.generate.test.ts +209 -0
  460. package/src/monitor/thread-title.test.ts +31 -0
  461. package/src/monitor/thread-title.ts +181 -0
  462. package/src/monitor/threading.auto-thread.test.ts +330 -0
  463. package/src/monitor/threading.auto-thread.ts +287 -0
  464. package/src/monitor/threading.cache.ts +45 -0
  465. package/src/monitor/threading.parent-info.test.ts +156 -0
  466. package/src/monitor/threading.starter.test.ts +279 -0
  467. package/src/monitor/threading.starter.ts +288 -0
  468. package/src/monitor/threading.ts +20 -0
  469. package/src/monitor/threading.types.ts +102 -0
  470. package/src/monitor/timeouts.ts +84 -0
  471. package/src/monitor/typing.test.ts +42 -0
  472. package/src/monitor/typing.ts +17 -0
  473. package/src/monitor.gateway.test.ts +187 -0
  474. package/src/monitor.gateway.ts +75 -0
  475. package/src/monitor.test.ts +1416 -0
  476. package/src/monitor.ts +28 -0
  477. package/src/network-config.test.ts +92 -0
  478. package/src/network-config.ts +79 -0
  479. package/src/normalize.test.ts +56 -0
  480. package/src/normalize.ts +86 -0
  481. package/src/outbound-adapter.interactive-order.test.ts +82 -0
  482. package/src/outbound-adapter.test-harness.ts +207 -0
  483. package/src/outbound-adapter.test.ts +804 -0
  484. package/src/outbound-adapter.ts +326 -0
  485. package/src/outbound-approval.ts +29 -0
  486. package/src/outbound-components.ts +86 -0
  487. package/src/outbound-payload.contract.test.ts +49 -0
  488. package/src/outbound-payload.ts +208 -0
  489. package/src/outbound-send-context.ts +89 -0
  490. package/src/outbound-session-route.test.ts +42 -0
  491. package/src/outbound-session-route.ts +72 -0
  492. package/src/pluralkit.test.ts +67 -0
  493. package/src/pluralkit.ts +58 -0
  494. package/src/preview-streaming.ts +18 -0
  495. package/src/probe.intents.test.ts +94 -0
  496. package/src/probe.parse-token.test.ts +43 -0
  497. package/src/probe.runtime.ts +1 -0
  498. package/src/probe.ts +237 -0
  499. package/src/proxy-fetch.ts +92 -0
  500. package/src/proxy-request-client.test.ts +100 -0
  501. package/src/proxy-request-client.ts +21 -0
  502. package/src/recipient-resolution.ts +39 -0
  503. package/src/resolve-allowlist-common.test.ts +40 -0
  504. package/src/resolve-allowlist-common.ts +39 -0
  505. package/src/resolve-channels.test.ts +341 -0
  506. package/src/resolve-channels.ts +369 -0
  507. package/src/resolve-users.test.ts +243 -0
  508. package/src/resolve-users.ts +184 -0
  509. package/src/retry.test.ts +83 -0
  510. package/src/retry.ts +98 -0
  511. package/src/runtime-api.ts +61 -0
  512. package/src/runtime-config.ts +16 -0
  513. package/src/runtime.ts +23 -0
  514. package/src/secret-config-contract.ts +140 -0
  515. package/src/security-audit.runtime.ts +1 -0
  516. package/src/security-audit.test.ts +245 -0
  517. package/src/security-audit.ts +208 -0
  518. package/src/security-contract.ts +47 -0
  519. package/src/security-doctor.test.ts +25 -0
  520. package/src/security-doctor.ts +20 -0
  521. package/src/security.ts +60 -0
  522. package/src/send-target-parsing.ts +14 -0
  523. package/src/send.channels.ts +139 -0
  524. package/src/send.components.test.ts +330 -0
  525. package/src/send.components.ts +391 -0
  526. package/src/send.creates-thread.test.ts +681 -0
  527. package/src/send.emojis-stickers.ts +57 -0
  528. package/src/send.guild.ts +170 -0
  529. package/src/send.message-request.ts +112 -0
  530. package/src/send.messages.test.ts +59 -0
  531. package/src/send.messages.ts +229 -0
  532. package/src/send.outbound.ts +459 -0
  533. package/src/send.permissions.authz.test.ts +190 -0
  534. package/src/send.permissions.ts +283 -0
  535. package/src/send.reactions.ts +155 -0
  536. package/src/send.receipt.ts +69 -0
  537. package/src/send.sends-basic-channel-messages.test.ts +1068 -0
  538. package/src/send.shared.ts +469 -0
  539. package/src/send.test-harness.ts +56 -0
  540. package/src/send.ts +82 -0
  541. package/src/send.types.ts +191 -0
  542. package/src/send.typing.test.ts +41 -0
  543. package/src/send.typing.ts +9 -0
  544. package/src/send.voice.ts +136 -0
  545. package/src/send.webhook-activity.test.ts +152 -0
  546. package/src/send.webhook.proxy.test.ts +210 -0
  547. package/src/send.webhook.ts +137 -0
  548. package/src/session-contract.ts +3 -0
  549. package/src/session-key-normalization.test.ts +44 -0
  550. package/src/session-key-normalization.ts +47 -0
  551. package/src/setup-account-state.test.ts +113 -0
  552. package/src/setup-account-state.ts +141 -0
  553. package/src/setup-adapter.ts +14 -0
  554. package/src/setup-core.ts +215 -0
  555. package/src/setup-runtime-helpers.ts +10 -0
  556. package/src/setup-surface.test.ts +137 -0
  557. package/src/setup-surface.ts +132 -0
  558. package/src/shared-interactive.test.ts +153 -0
  559. package/src/shared-interactive.ts +161 -0
  560. package/src/shared.test.ts +186 -0
  561. package/src/shared.ts +197 -0
  562. package/src/status-issues.test.ts +97 -0
  563. package/src/status-issues.ts +198 -0
  564. package/src/subagent-hooks.test.ts +465 -0
  565. package/src/subagent-hooks.ts +232 -0
  566. package/src/target-parsing.ts +70 -0
  567. package/src/target-resolver.ts +129 -0
  568. package/src/targets.test.ts +393 -0
  569. package/src/targets.ts +12 -0
  570. package/src/test-http-helpers.ts +10 -0
  571. package/src/test-support/component-runtime.ts +194 -0
  572. package/src/test-support/config.ts +7 -0
  573. package/src/test-support/configured-binding-runtime.ts +29 -0
  574. package/src/test-support/partial-channel.ts +26 -0
  575. package/src/test-support/provider.test-support.ts +547 -0
  576. package/src/token.test.ts +174 -0
  577. package/src/token.ts +107 -0
  578. package/src/ui-colors.ts +27 -0
  579. package/src/ui.ts +20 -0
  580. package/src/voice/access.test.ts +288 -0
  581. package/src/voice/access.ts +126 -0
  582. package/src/voice/audio.test.ts +47 -0
  583. package/src/voice/audio.ts +249 -0
  584. package/src/voice/capture-state.test.ts +48 -0
  585. package/src/voice/capture-state.ts +120 -0
  586. package/src/voice/command.test.ts +170 -0
  587. package/src/voice/command.ts +284 -0
  588. package/src/voice/config.ts +8 -0
  589. package/src/voice/ingress.ts +164 -0
  590. package/src/voice/manager.e2e.test.ts +3286 -0
  591. package/src/voice/manager.ready-listener.test.ts +54 -0
  592. package/src/voice/manager.runtime.ts +14 -0
  593. package/src/voice/manager.ts +1155 -0
  594. package/src/voice/prompt.test.ts +30 -0
  595. package/src/voice/prompt.ts +22 -0
  596. package/src/voice/realtime.ts +1370 -0
  597. package/src/voice/receive-recovery.test.ts +81 -0
  598. package/src/voice/receive-recovery.ts +159 -0
  599. package/src/voice/sanitize.test.ts +34 -0
  600. package/src/voice/sanitize.ts +29 -0
  601. package/src/voice/sdk-runtime.ts +14 -0
  602. package/src/voice/segment.ts +160 -0
  603. package/src/voice/session.ts +81 -0
  604. package/src/voice/speaker-context.ts +127 -0
  605. package/src/voice/tts.ts +151 -0
  606. package/src/voice-message.test.ts +376 -0
  607. package/src/voice-message.ts +474 -0
  608. package/subagent-hooks-api.ts +27 -0
  609. package/test-api.ts +4 -0
  610. package/thread-binding-api.ts +1 -0
  611. package/timeouts.ts +6 -0
  612. package/tsconfig.json +16 -0
  613. package/account-inspect-api.js +0 -7
  614. package/action-runtime-api.js +0 -7
  615. package/api.js +0 -7
  616. package/channel-config-api.js +0 -7
  617. package/channel-plugin-api.js +0 -7
  618. package/configured-state.js +0 -7
  619. package/contract-api.js +0 -7
  620. package/directory-contract-api.js +0 -7
  621. package/doctor-contract-api.js +0 -7
  622. package/index.js +0 -7
  623. package/runtime-api.actions.js +0 -7
  624. package/runtime-api.js +0 -7
  625. package/runtime-api.lookup.js +0 -7
  626. package/runtime-api.monitor.js +0 -7
  627. package/runtime-api.send.js +0 -7
  628. package/runtime-api.threads.js +0 -7
  629. package/runtime-setter-api.js +0 -7
  630. package/secret-contract-api.js +0 -7
  631. package/security-audit-contract-api.js +0 -7
  632. package/security-contract-api.js +0 -7
  633. package/session-key-api.js +0 -7
  634. package/setup-entry.js +0 -7
  635. package/setup-plugin-api.js +0 -7
  636. package/subagent-hooks-api.js +0 -7
  637. package/test-api.js +0 -7
  638. package/thread-binding-api.js +0 -7
  639. package/timeouts.js +0 -7
@@ -0,0 +1,1994 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { ChannelType } from "discord-api-types/v10";
5
+ import { getSessionBindingService } from "klaw/plugin-sdk/conversation-runtime";
6
+ import {
7
+ clearRuntimeConfigSnapshot,
8
+ setRuntimeConfigSnapshot,
9
+ type KlawConfig,
10
+ } from "klaw/plugin-sdk/runtime-config-snapshot";
11
+ import { beforeEach, describe, expect, it, vi } from "vitest";
12
+ import { EMPTY_DISCORD_TEST_CONFIG } from "../test-support/config.js";
13
+
14
+ const hoisted = vi.hoisted(() => {
15
+ const sendMessageDiscord = vi.fn(async (_to: string, _text: string, _opts?: unknown) => ({}));
16
+ const sendWebhookMessageDiscord = vi.fn(async (_text: string, _opts?: unknown) => ({}));
17
+ const restGet = vi.fn(async (..._args: unknown[]) => ({
18
+ id: "thread-1",
19
+ type: 11,
20
+ parent_id: "parent-1",
21
+ }));
22
+ const restPost = vi.fn(async (..._args: unknown[]) => ({
23
+ id: "wh-created",
24
+ token: "tok-created",
25
+ }));
26
+ const createDiscordRestClient = vi.fn((..._args: unknown[]) => ({
27
+ rest: {
28
+ get: restGet,
29
+ post: restPost,
30
+ },
31
+ }));
32
+ const createThreadDiscord = vi.fn(async (..._args: unknown[]) => ({ id: "thread-created" }));
33
+ const readAcpSessionEntry = vi.fn();
34
+ return {
35
+ sendMessageDiscord,
36
+ sendWebhookMessageDiscord,
37
+ restGet,
38
+ restPost,
39
+ createDiscordRestClient,
40
+ createThreadDiscord,
41
+ readAcpSessionEntry,
42
+ };
43
+ });
44
+
45
+ vi.mock("../send.js", async () => {
46
+ const actual = await vi.importActual<typeof import("../send.js")>("../send.js");
47
+ return {
48
+ ...actual,
49
+ addRoleDiscord: vi.fn(),
50
+ sendMessageDiscord: hoisted.sendMessageDiscord,
51
+ sendWebhookMessageDiscord: hoisted.sendWebhookMessageDiscord,
52
+ };
53
+ });
54
+
55
+ vi.mock("../send.messages.js", () => ({
56
+ createThreadDiscord: hoisted.createThreadDiscord,
57
+ }));
58
+
59
+ const { testing, createThreadBindingManager } = await import("./thread-bindings.manager.js");
60
+ const {
61
+ autoBindSpawnedDiscordSubagent,
62
+ reconcileAcpThreadBindingsOnStartup,
63
+ setThreadBindingIdleTimeoutBySessionKey,
64
+ setThreadBindingMaxAgeBySessionKey,
65
+ unbindThreadBindingsBySessionKey,
66
+ } = await import("./thread-bindings.lifecycle.js");
67
+ const { resolveThreadBindingInactivityExpiresAt, resolveThreadBindingMaxAgeExpiresAt } =
68
+ await import("./thread-bindings.state.js");
69
+ const { resolveThreadBindingIntroText } = await import("./thread-bindings.messages.js");
70
+ const discordClientModule = await import("../client.js");
71
+ const discordThreadBindingApi = await import("./thread-bindings.discord-api.js");
72
+ const acpRuntime = await import("klaw/plugin-sdk/acp-runtime");
73
+
74
+ function createTestThreadBindingManager(
75
+ params: Omit<Parameters<typeof createThreadBindingManager>[0], "cfg"> & {
76
+ cfg?: KlawConfig;
77
+ },
78
+ ) {
79
+ return createThreadBindingManager({
80
+ cfg: EMPTY_DISCORD_TEST_CONFIG,
81
+ ...params,
82
+ });
83
+ }
84
+
85
+ function requireRecord(value: unknown, label: string): Record<string, unknown> {
86
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
87
+ throw new Error(`Expected ${label}`);
88
+ }
89
+ return value as Record<string, unknown>;
90
+ }
91
+
92
+ function expectFields(
93
+ value: unknown,
94
+ label: string,
95
+ fields: Record<string, unknown>,
96
+ ): Record<string, unknown> {
97
+ const record = requireRecord(value, label);
98
+ for (const [key, expected] of Object.entries(fields)) {
99
+ expect(record[key]).toEqual(expected);
100
+ }
101
+ return record;
102
+ }
103
+
104
+ function mockCallArg(mock: unknown, callIndex: number, argIndex: number, label: string) {
105
+ const calls = (mock as { mock?: { calls?: unknown[][] } }).mock?.calls;
106
+ if (!Array.isArray(calls)) {
107
+ throw new Error(`Expected ${label} mock calls`);
108
+ }
109
+ const call = calls[callIndex];
110
+ if (!call) {
111
+ throw new Error(`Expected ${label} call ${callIndex + 1}`);
112
+ }
113
+ return call[argIndex];
114
+ }
115
+
116
+ describe("thread binding lifecycle", () => {
117
+ beforeEach(() => {
118
+ testing.resetThreadBindingsForTests();
119
+ clearRuntimeConfigSnapshot();
120
+ vi.restoreAllMocks();
121
+ hoisted.sendMessageDiscord.mockReset().mockResolvedValue({});
122
+ hoisted.sendWebhookMessageDiscord.mockReset().mockResolvedValue({});
123
+ hoisted.restGet.mockReset().mockResolvedValue({
124
+ id: "thread-1",
125
+ type: 11,
126
+ parent_id: "parent-1",
127
+ });
128
+ hoisted.restPost.mockReset().mockResolvedValue({
129
+ id: "wh-created",
130
+ token: "tok-created",
131
+ });
132
+ hoisted.createDiscordRestClient.mockReset().mockImplementation((..._args: unknown[]) => ({
133
+ rest: {
134
+ get: hoisted.restGet,
135
+ post: hoisted.restPost,
136
+ },
137
+ }));
138
+ hoisted.createThreadDiscord.mockReset().mockResolvedValue({ id: "thread-created" });
139
+ hoisted.readAcpSessionEntry.mockReset().mockReturnValue(null);
140
+ vi.spyOn(discordClientModule, "createDiscordRestClient").mockImplementation(
141
+ (...args) =>
142
+ hoisted.createDiscordRestClient(...args) as unknown as ReturnType<
143
+ typeof discordClientModule.createDiscordRestClient
144
+ >,
145
+ );
146
+ vi.spyOn(discordThreadBindingApi, "createWebhookForChannel").mockImplementation(
147
+ async (params) => {
148
+ const rest = hoisted.createDiscordRestClient(
149
+ {
150
+ accountId: params.accountId,
151
+ token: params.token,
152
+ },
153
+ params.cfg,
154
+ ).rest;
155
+ const created = (await rest.post("mock:channel-webhook")) as {
156
+ id?: string;
157
+ token?: string;
158
+ };
159
+ return {
160
+ webhookId: typeof created?.id === "string" ? created.id.trim() || undefined : undefined,
161
+ webhookToken:
162
+ typeof created?.token === "string" ? created.token.trim() || undefined : undefined,
163
+ };
164
+ },
165
+ );
166
+ vi.spyOn(discordThreadBindingApi, "resolveChannelIdForBinding").mockImplementation(
167
+ async (params) => {
168
+ const explicit = params.channelId?.trim();
169
+ if (explicit) {
170
+ return explicit;
171
+ }
172
+ const rest = hoisted.createDiscordRestClient(
173
+ {
174
+ accountId: params.accountId,
175
+ token: params.token,
176
+ },
177
+ params.cfg,
178
+ ).rest;
179
+ const channel = (await rest.get("mock:channel-resolve")) as {
180
+ id?: string;
181
+ type?: number;
182
+ parent_id?: string;
183
+ parentId?: string;
184
+ };
185
+ const channelId = typeof channel?.id === "string" ? channel.id.trim() : "";
186
+ const parentId =
187
+ typeof channel?.parent_id === "string"
188
+ ? channel.parent_id.trim()
189
+ : typeof channel?.parentId === "string"
190
+ ? channel.parentId.trim()
191
+ : "";
192
+ const isThreadType =
193
+ channel?.type === ChannelType.PublicThread ||
194
+ channel?.type === ChannelType.PrivateThread ||
195
+ channel?.type === ChannelType.AnnouncementThread;
196
+ if (parentId && isThreadType) {
197
+ return parentId;
198
+ }
199
+ return channelId || null;
200
+ },
201
+ );
202
+ vi.spyOn(discordThreadBindingApi, "createThreadForBinding").mockImplementation(
203
+ async (params) => {
204
+ const created = await hoisted.createThreadDiscord(
205
+ params.channelId,
206
+ {
207
+ name: params.threadName,
208
+ autoArchiveMinutes: 60,
209
+ },
210
+ {
211
+ accountId: params.accountId,
212
+ token: params.token,
213
+ cfg: params.cfg,
214
+ },
215
+ );
216
+ return typeof created?.id === "string" ? created.id.trim() || null : null;
217
+ },
218
+ );
219
+ vi.spyOn(discordThreadBindingApi, "maybeSendBindingMessage").mockImplementation(
220
+ async (params) => {
221
+ if (
222
+ params.preferWebhook !== false &&
223
+ params.record.webhookId &&
224
+ params.record.webhookToken
225
+ ) {
226
+ await hoisted.sendWebhookMessageDiscord(params.text, {
227
+ cfg: params.cfg,
228
+ webhookId: params.record.webhookId,
229
+ webhookToken: params.record.webhookToken,
230
+ accountId: params.record.accountId,
231
+ threadId: params.record.threadId,
232
+ });
233
+ return;
234
+ }
235
+ await hoisted.sendMessageDiscord(`channel:${params.record.threadId}`, params.text, {
236
+ cfg: params.cfg,
237
+ accountId: params.record.accountId,
238
+ });
239
+ },
240
+ );
241
+ vi.spyOn(acpRuntime, "readAcpSessionEntry").mockImplementation(hoisted.readAcpSessionEntry);
242
+ vi.useRealTimers();
243
+ });
244
+
245
+ const createDefaultSweeperManager = () =>
246
+ createTestThreadBindingManager({
247
+ accountId: "default",
248
+ persist: false,
249
+ enableSweeper: false,
250
+ idleTimeoutMs: 24 * 60 * 60 * 1000,
251
+ maxAgeMs: 0,
252
+ });
253
+
254
+ const bindDefaultThreadTarget = async (
255
+ manager: ReturnType<typeof createThreadBindingManager>,
256
+ ) => {
257
+ await manager.bindTarget({
258
+ threadId: "thread-1",
259
+ channelId: "parent-1",
260
+ targetKind: "subagent",
261
+ targetSessionKey: "agent:main:subagent:child",
262
+ agentId: "main",
263
+ webhookId: "wh-1",
264
+ webhookToken: "tok-1",
265
+ });
266
+ };
267
+
268
+ const requireBinding = (
269
+ manager: ReturnType<typeof createThreadBindingManager>,
270
+ threadId: string,
271
+ ) => {
272
+ const binding = manager.getByThreadId(threadId);
273
+ if (!binding) {
274
+ throw new Error(`missing thread binding: ${threadId}`);
275
+ }
276
+ return binding;
277
+ };
278
+
279
+ it("includes idle and max-age details in intro text", () => {
280
+ const intro = resolveThreadBindingIntroText({
281
+ agentId: "main",
282
+ label: "worker",
283
+ idleTimeoutMs: 24 * 60 * 60 * 1000,
284
+ maxAgeMs: 48 * 60 * 60 * 1000,
285
+ });
286
+ expect(intro).toContain("idle auto-unfocus after 24h inactivity");
287
+ expect(intro).toContain("max age 48h");
288
+ });
289
+
290
+ it("includes cwd near the top of intro text", () => {
291
+ const intro = resolveThreadBindingIntroText({
292
+ agentId: "codex",
293
+ idleTimeoutMs: 24 * 60 * 60 * 1000,
294
+ sessionCwd: "/home/bob/clawd",
295
+ sessionDetails: ["session ids: pending (available after the first reply)"],
296
+ });
297
+ expect(intro).toContain("\ncwd: /home/bob/clawd\nsession ids: pending");
298
+ });
299
+
300
+ it("auto-unfocuses idle-expired bindings and sends inactivity message", async () => {
301
+ vi.useFakeTimers();
302
+ try {
303
+ const manager = createTestThreadBindingManager({
304
+ accountId: "default",
305
+ cfg: EMPTY_DISCORD_TEST_CONFIG,
306
+ persist: false,
307
+ enableSweeper: false,
308
+ idleTimeoutMs: 60_000,
309
+ maxAgeMs: 0,
310
+ });
311
+
312
+ const binding = await manager.bindTarget({
313
+ threadId: "thread-1",
314
+ channelId: "parent-1",
315
+ targetKind: "subagent",
316
+ targetSessionKey: "agent:main:subagent:child",
317
+ agentId: "main",
318
+ webhookId: "wh-1",
319
+ webhookToken: "tok-1",
320
+ introText: "intro",
321
+ });
322
+ expectFields(binding, "binding", {
323
+ threadId: "thread-1",
324
+ targetSessionKey: "agent:main:subagent:child",
325
+ });
326
+ hoisted.sendMessageDiscord.mockClear();
327
+ hoisted.sendWebhookMessageDiscord.mockClear();
328
+
329
+ await vi.advanceTimersByTimeAsync(120_000);
330
+ await testing.runThreadBindingSweepForAccount("default");
331
+
332
+ expect(manager.getByThreadId("thread-1")).toBeUndefined();
333
+ expect(hoisted.restGet).not.toHaveBeenCalled();
334
+ expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled();
335
+ expect(hoisted.sendMessageDiscord).toHaveBeenCalledTimes(1);
336
+ const farewell = mockCallArg(hoisted.sendMessageDiscord, 0, 1, "sendMessageDiscord") as
337
+ | string
338
+ | undefined;
339
+ expect(farewell).toContain("after 1m of inactivity");
340
+ } finally {
341
+ vi.useRealTimers();
342
+ }
343
+ });
344
+
345
+ it("auto-unfocuses max-age-expired bindings and sends max-age message", async () => {
346
+ vi.useFakeTimers();
347
+ try {
348
+ const manager = createTestThreadBindingManager({
349
+ accountId: "default",
350
+ cfg: EMPTY_DISCORD_TEST_CONFIG,
351
+ persist: false,
352
+ enableSweeper: false,
353
+ idleTimeoutMs: 0,
354
+ maxAgeMs: 60_000,
355
+ });
356
+
357
+ const binding = await manager.bindTarget({
358
+ threadId: "thread-1",
359
+ channelId: "parent-1",
360
+ targetKind: "subagent",
361
+ targetSessionKey: "agent:main:subagent:child",
362
+ agentId: "main",
363
+ webhookId: "wh-1",
364
+ webhookToken: "tok-1",
365
+ });
366
+ expectFields(binding, "binding", {
367
+ threadId: "thread-1",
368
+ targetSessionKey: "agent:main:subagent:child",
369
+ });
370
+ hoisted.sendMessageDiscord.mockClear();
371
+
372
+ await vi.advanceTimersByTimeAsync(120_000);
373
+ await testing.runThreadBindingSweepForAccount("default");
374
+
375
+ expect(manager.getByThreadId("thread-1")).toBeUndefined();
376
+ expect(hoisted.sendMessageDiscord).toHaveBeenCalledTimes(1);
377
+ const farewell = mockCallArg(hoisted.sendMessageDiscord, 0, 1, "sendMessageDiscord") as
378
+ | string
379
+ | undefined;
380
+ expect(farewell).toContain("max age of 1m");
381
+ } finally {
382
+ vi.useRealTimers();
383
+ }
384
+ });
385
+
386
+ it("keeps binding when thread sweep probe fails transiently", async () => {
387
+ vi.useFakeTimers();
388
+ try {
389
+ const manager = createDefaultSweeperManager();
390
+ await bindDefaultThreadTarget(manager);
391
+
392
+ hoisted.restGet.mockRejectedValueOnce(new Error("ECONNRESET"));
393
+
394
+ await vi.advanceTimersByTimeAsync(120_000);
395
+ await testing.runThreadBindingSweepForAccount("default");
396
+
397
+ expectFields(requireBinding(manager, "thread-1"), "thread binding", {
398
+ threadId: "thread-1",
399
+ targetSessionKey: "agent:main:subagent:child",
400
+ webhookId: "wh-1",
401
+ webhookToken: "tok-1",
402
+ });
403
+ expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled();
404
+ } finally {
405
+ vi.useRealTimers();
406
+ }
407
+ });
408
+
409
+ it("unbinds when thread sweep probe reports unknown channel", async () => {
410
+ vi.useFakeTimers();
411
+ try {
412
+ const manager = createDefaultSweeperManager();
413
+ await bindDefaultThreadTarget(manager);
414
+
415
+ hoisted.restGet.mockRejectedValueOnce({
416
+ status: 404,
417
+ rawError: { code: 10003, message: "Unknown Channel" },
418
+ });
419
+
420
+ await vi.advanceTimersByTimeAsync(120_000);
421
+ await testing.runThreadBindingSweepForAccount("default");
422
+
423
+ expect(manager.getByThreadId("thread-1")).toBeUndefined();
424
+ expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled();
425
+ } finally {
426
+ vi.useRealTimers();
427
+ }
428
+ });
429
+
430
+ it("updates idle timeout by target session key", async () => {
431
+ vi.useFakeTimers();
432
+ try {
433
+ vi.setSystemTime(new Date("2026-02-20T23:00:00.000Z"));
434
+ const manager = createTestThreadBindingManager({
435
+ accountId: "default",
436
+ persist: false,
437
+ enableSweeper: false,
438
+ idleTimeoutMs: 24 * 60 * 60 * 1000,
439
+ maxAgeMs: 0,
440
+ });
441
+
442
+ await manager.bindTarget({
443
+ threadId: "thread-1",
444
+ channelId: "parent-1",
445
+ targetKind: "subagent",
446
+ targetSessionKey: "agent:main:subagent:child",
447
+ agentId: "main",
448
+ webhookId: "wh-1",
449
+ webhookToken: "tok-1",
450
+ });
451
+
452
+ const boundAt = manager.getByThreadId("thread-1")?.boundAt;
453
+ vi.setSystemTime(new Date("2026-02-20T23:15:00.000Z"));
454
+
455
+ const updated = setThreadBindingIdleTimeoutBySessionKey({
456
+ accountId: "default",
457
+ targetSessionKey: "agent:main:subagent:child",
458
+ idleTimeoutMs: 2 * 60 * 60 * 1000,
459
+ });
460
+
461
+ expect(updated).toHaveLength(1);
462
+ expect(updated[0]?.lastActivityAt).toBe(new Date("2026-02-20T23:15:00.000Z").getTime());
463
+ expect(updated[0]?.boundAt).toBe(boundAt);
464
+ expect(
465
+ resolveThreadBindingInactivityExpiresAt({
466
+ record: updated[0],
467
+ defaultIdleTimeoutMs: manager.getIdleTimeoutMs(),
468
+ }),
469
+ ).toBe(new Date("2026-02-21T01:15:00.000Z").getTime());
470
+ } finally {
471
+ vi.useRealTimers();
472
+ }
473
+ });
474
+
475
+ it("updates max age by target session key", async () => {
476
+ vi.useFakeTimers();
477
+ try {
478
+ vi.setSystemTime(new Date("2026-02-20T10:00:00.000Z"));
479
+ const manager = createTestThreadBindingManager({
480
+ accountId: "default",
481
+ persist: false,
482
+ enableSweeper: false,
483
+ idleTimeoutMs: 24 * 60 * 60 * 1000,
484
+ maxAgeMs: 0,
485
+ });
486
+
487
+ await manager.bindTarget({
488
+ threadId: "thread-1",
489
+ channelId: "parent-1",
490
+ targetKind: "subagent",
491
+ targetSessionKey: "agent:main:subagent:child",
492
+ agentId: "main",
493
+ });
494
+
495
+ vi.setSystemTime(new Date("2026-02-20T10:30:00.000Z"));
496
+ const updated = setThreadBindingMaxAgeBySessionKey({
497
+ accountId: "default",
498
+ targetSessionKey: "agent:main:subagent:child",
499
+ maxAgeMs: 3 * 60 * 60 * 1000,
500
+ });
501
+
502
+ expect(updated).toHaveLength(1);
503
+ expect(updated[0]?.boundAt).toBe(new Date("2026-02-20T10:30:00.000Z").getTime());
504
+ expect(updated[0]?.lastActivityAt).toBe(new Date("2026-02-20T10:30:00.000Z").getTime());
505
+ expect(
506
+ resolveThreadBindingMaxAgeExpiresAt({
507
+ record: updated[0],
508
+ defaultMaxAgeMs: manager.getMaxAgeMs(),
509
+ }),
510
+ ).toBe(new Date("2026-02-20T13:30:00.000Z").getTime());
511
+ } finally {
512
+ vi.useRealTimers();
513
+ }
514
+ });
515
+
516
+ it("preserves explicit lifecycle windows when rebinding the same thread", async () => {
517
+ vi.useFakeTimers();
518
+ try {
519
+ vi.setSystemTime(new Date("2026-02-20T10:00:00.000Z"));
520
+ const manager = createTestThreadBindingManager({
521
+ accountId: "default",
522
+ persist: false,
523
+ enableSweeper: false,
524
+ idleTimeoutMs: 24 * 60 * 60 * 1000,
525
+ maxAgeMs: 0,
526
+ });
527
+
528
+ await manager.bindTarget({
529
+ threadId: "thread-1",
530
+ channelId: "parent-1",
531
+ targetKind: "subagent",
532
+ targetSessionKey: "agent:main:subagent:child",
533
+ agentId: "main",
534
+ webhookId: "wh-1",
535
+ webhookToken: "tok-1",
536
+ });
537
+
538
+ setThreadBindingIdleTimeoutBySessionKey({
539
+ accountId: "default",
540
+ targetSessionKey: "agent:main:subagent:child",
541
+ idleTimeoutMs: 2 * 60 * 60 * 1000,
542
+ });
543
+ setThreadBindingMaxAgeBySessionKey({
544
+ accountId: "default",
545
+ targetSessionKey: "agent:main:subagent:child",
546
+ maxAgeMs: 3 * 60 * 60 * 1000,
547
+ });
548
+
549
+ vi.setSystemTime(new Date("2026-02-20T10:30:00.000Z"));
550
+ const rebound = await manager.bindTarget({
551
+ threadId: "thread-1",
552
+ channelId: "parent-1",
553
+ targetKind: "subagent",
554
+ targetSessionKey: "agent:main:subagent:child",
555
+ webhookId: "wh-1",
556
+ webhookToken: "tok-1",
557
+ });
558
+
559
+ expectFields(rebound, "rebound binding", {
560
+ idleTimeoutMs: 2 * 60 * 60 * 1000,
561
+ maxAgeMs: 3 * 60 * 60 * 1000,
562
+ });
563
+ expectFields(requireBinding(manager, "thread-1"), "thread binding", {
564
+ idleTimeoutMs: 2 * 60 * 60 * 1000,
565
+ maxAgeMs: 3 * 60 * 60 * 1000,
566
+ });
567
+ } finally {
568
+ vi.useRealTimers();
569
+ }
570
+ });
571
+
572
+ it("keeps binding when idle timeout is disabled per session key", async () => {
573
+ vi.useFakeTimers();
574
+ try {
575
+ const manager = createTestThreadBindingManager({
576
+ accountId: "default",
577
+ persist: false,
578
+ enableSweeper: false,
579
+ idleTimeoutMs: 60_000,
580
+ maxAgeMs: 0,
581
+ });
582
+
583
+ await manager.bindTarget({
584
+ threadId: "thread-1",
585
+ channelId: "parent-1",
586
+ targetKind: "subagent",
587
+ targetSessionKey: "agent:main:subagent:child",
588
+ agentId: "main",
589
+ webhookId: "wh-1",
590
+ webhookToken: "tok-1",
591
+ });
592
+
593
+ const updated = setThreadBindingIdleTimeoutBySessionKey({
594
+ accountId: "default",
595
+ targetSessionKey: "agent:main:subagent:child",
596
+ idleTimeoutMs: 0,
597
+ });
598
+ expect(updated).toHaveLength(1);
599
+ expect(updated[0]?.idleTimeoutMs).toBe(0);
600
+
601
+ await vi.advanceTimersByTimeAsync(240_000);
602
+ await testing.runThreadBindingSweepForAccount("default");
603
+
604
+ expectFields(requireBinding(manager, "thread-1"), "thread binding", {
605
+ threadId: "thread-1",
606
+ targetSessionKey: "agent:main:subagent:child",
607
+ idleTimeoutMs: 0,
608
+ });
609
+ } finally {
610
+ vi.useRealTimers();
611
+ }
612
+ });
613
+
614
+ it("keeps a binding when activity is touched during the same sweep pass", async () => {
615
+ vi.useFakeTimers();
616
+ try {
617
+ const manager = createTestThreadBindingManager({
618
+ accountId: "default",
619
+ persist: false,
620
+ enableSweeper: false,
621
+ idleTimeoutMs: 60_000,
622
+ maxAgeMs: 0,
623
+ });
624
+
625
+ await manager.bindTarget({
626
+ threadId: "thread-1",
627
+ channelId: "parent-1",
628
+ targetKind: "subagent",
629
+ targetSessionKey: "agent:main:subagent:first",
630
+ agentId: "main",
631
+ webhookId: "wh-1",
632
+ webhookToken: "tok-1",
633
+ });
634
+ await manager.bindTarget({
635
+ threadId: "thread-2",
636
+ channelId: "parent-1",
637
+ targetKind: "subagent",
638
+ targetSessionKey: "agent:main:subagent:second",
639
+ agentId: "main",
640
+ webhookId: "wh-2",
641
+ webhookToken: "tok-2",
642
+ });
643
+
644
+ // Keep the first binding off the idle-expire path so the sweep performs
645
+ // an awaited probe and gives a window for in-pass touches.
646
+ setThreadBindingIdleTimeoutBySessionKey({
647
+ accountId: "default",
648
+ targetSessionKey: "agent:main:subagent:first",
649
+ idleTimeoutMs: 0,
650
+ });
651
+
652
+ hoisted.restGet.mockImplementation(async (...args: unknown[]) => {
653
+ const route = typeof args[0] === "string" ? args[0] : "";
654
+ if (route.includes("thread-1")) {
655
+ manager.touchThread({ threadId: "thread-2", persist: false });
656
+ }
657
+ return {
658
+ id: route.split("/").at(-1) ?? "thread-1",
659
+ type: 11,
660
+ parent_id: "parent-1",
661
+ };
662
+ });
663
+ hoisted.sendMessageDiscord.mockClear();
664
+
665
+ await vi.advanceTimersByTimeAsync(120_000);
666
+ await testing.runThreadBindingSweepForAccount("default");
667
+
668
+ expectFields(requireBinding(manager, "thread-2"), "thread binding", {
669
+ threadId: "thread-2",
670
+ targetSessionKey: "agent:main:subagent:second",
671
+ });
672
+ expect(hoisted.sendMessageDiscord).not.toHaveBeenCalled();
673
+ } finally {
674
+ vi.useRealTimers();
675
+ }
676
+ });
677
+
678
+ it("refreshes inactivity window when thread activity is touched", async () => {
679
+ vi.useFakeTimers();
680
+ try {
681
+ vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
682
+ const manager = createTestThreadBindingManager({
683
+ accountId: "default",
684
+ persist: false,
685
+ enableSweeper: false,
686
+ idleTimeoutMs: 60_000,
687
+ maxAgeMs: 0,
688
+ });
689
+
690
+ await manager.bindTarget({
691
+ threadId: "thread-1",
692
+ channelId: "parent-1",
693
+ targetKind: "subagent",
694
+ targetSessionKey: "agent:main:subagent:child",
695
+ agentId: "main",
696
+ });
697
+
698
+ vi.setSystemTime(new Date("2026-02-20T00:00:30.000Z"));
699
+ const touched = manager.touchThread({ threadId: "thread-1", persist: false });
700
+ expectFields(touched, "touched binding", {
701
+ threadId: "thread-1",
702
+ lastActivityAt: new Date("2026-02-20T00:00:30.000Z").getTime(),
703
+ });
704
+
705
+ const record = requireBinding(manager, "thread-1");
706
+ expect(record.lastActivityAt).toBe(new Date("2026-02-20T00:00:30.000Z").getTime());
707
+ expect(
708
+ resolveThreadBindingInactivityExpiresAt({
709
+ record,
710
+ defaultIdleTimeoutMs: manager.getIdleTimeoutMs(),
711
+ }),
712
+ ).toBe(new Date("2026-02-20T00:01:30.000Z").getTime());
713
+ } finally {
714
+ vi.useRealTimers();
715
+ }
716
+ });
717
+
718
+ it("persists touched activity timestamps across restart when persistence is enabled", async () => {
719
+ vi.useFakeTimers();
720
+ const previousStateDir = process.env.KLAW_STATE_DIR;
721
+ const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "klaw-thread-bindings-"));
722
+ process.env.KLAW_STATE_DIR = stateDir;
723
+ try {
724
+ testing.resetThreadBindingsForTests();
725
+ vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
726
+ const manager = createTestThreadBindingManager({
727
+ accountId: "default",
728
+ persist: true,
729
+ enableSweeper: false,
730
+ idleTimeoutMs: 60_000,
731
+ maxAgeMs: 0,
732
+ });
733
+
734
+ await manager.bindTarget({
735
+ threadId: "thread-1",
736
+ channelId: "parent-1",
737
+ targetKind: "subagent",
738
+ targetSessionKey: "agent:main:subagent:child",
739
+ agentId: "main",
740
+ webhookId: "wh-1",
741
+ webhookToken: "tok-1",
742
+ });
743
+
744
+ const touchedAt = new Date("2026-02-20T00:00:30.000Z").getTime();
745
+ vi.setSystemTime(touchedAt);
746
+ manager.touchThread({ threadId: "thread-1" });
747
+
748
+ testing.resetThreadBindingsForTests();
749
+ const reloaded = createTestThreadBindingManager({
750
+ accountId: "default",
751
+ persist: true,
752
+ enableSweeper: false,
753
+ idleTimeoutMs: 60_000,
754
+ maxAgeMs: 0,
755
+ });
756
+
757
+ const record = requireBinding(reloaded, "thread-1");
758
+ expect(record.lastActivityAt).toBe(touchedAt);
759
+ expect(
760
+ resolveThreadBindingInactivityExpiresAt({
761
+ record,
762
+ defaultIdleTimeoutMs: reloaded.getIdleTimeoutMs(),
763
+ }),
764
+ ).toBe(new Date("2026-02-20T00:01:30.000Z").getTime());
765
+ } finally {
766
+ testing.resetThreadBindingsForTests();
767
+ if (previousStateDir === undefined) {
768
+ delete process.env.KLAW_STATE_DIR;
769
+ } else {
770
+ process.env.KLAW_STATE_DIR = previousStateDir;
771
+ }
772
+ fs.rmSync(stateDir, { recursive: true, force: true });
773
+ vi.useRealTimers();
774
+ }
775
+ });
776
+
777
+ it("reuses webhook credentials after unbind when rebinding in the same channel", async () => {
778
+ const manager = createTestThreadBindingManager({
779
+ accountId: "default",
780
+ persist: false,
781
+ enableSweeper: false,
782
+ idleTimeoutMs: 24 * 60 * 60 * 1000,
783
+ maxAgeMs: 0,
784
+ });
785
+
786
+ const first = await manager.bindTarget({
787
+ threadId: "thread-1",
788
+ channelId: "parent-1",
789
+ targetKind: "subagent",
790
+ targetSessionKey: "agent:main:subagent:child-1",
791
+ agentId: "main",
792
+ });
793
+ expectFields(first, "first binding", {
794
+ threadId: "thread-1",
795
+ targetSessionKey: "agent:main:subagent:child-1",
796
+ });
797
+ expect(hoisted.restPost).toHaveBeenCalledTimes(1);
798
+
799
+ manager.unbindThread({
800
+ threadId: "thread-1",
801
+ sendFarewell: false,
802
+ });
803
+
804
+ const second = await manager.bindTarget({
805
+ threadId: "thread-2",
806
+ channelId: "parent-1",
807
+ targetKind: "subagent",
808
+ targetSessionKey: "agent:main:subagent:child-2",
809
+ agentId: "main",
810
+ });
811
+ expectFields(second, "second binding", {
812
+ webhookId: "wh-created",
813
+ webhookToken: "tok-created",
814
+ });
815
+ expect(hoisted.restPost).toHaveBeenCalledTimes(1);
816
+ });
817
+
818
+ it("creates a new thread when spawning from an already bound thread", async () => {
819
+ const manager = createTestThreadBindingManager({
820
+ accountId: "default",
821
+ persist: false,
822
+ enableSweeper: false,
823
+ idleTimeoutMs: 24 * 60 * 60 * 1000,
824
+ maxAgeMs: 0,
825
+ });
826
+
827
+ await manager.bindTarget({
828
+ threadId: "thread-1",
829
+ channelId: "parent-1",
830
+ targetKind: "subagent",
831
+ targetSessionKey: "agent:main:subagent:parent",
832
+ agentId: "main",
833
+ });
834
+ hoisted.createThreadDiscord.mockClear();
835
+ hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-2" });
836
+
837
+ const childBinding = await autoBindSpawnedDiscordSubagent({
838
+ cfg: EMPTY_DISCORD_TEST_CONFIG,
839
+ accountId: "default",
840
+ channel: "discord",
841
+ to: "channel:thread-1",
842
+ threadId: "thread-1",
843
+ childSessionKey: "agent:main:subagent:child-2",
844
+ agentId: "main",
845
+ });
846
+
847
+ expectFields(childBinding, "child binding", {
848
+ threadId: "thread-created-2",
849
+ targetSessionKey: "agent:main:subagent:child-2",
850
+ });
851
+ expect(hoisted.createThreadDiscord).toHaveBeenCalledTimes(1);
852
+ expect(mockCallArg(hoisted.createThreadDiscord, 0, 0, "createThreadDiscord")).toBe("parent-1");
853
+ expectFields(
854
+ mockCallArg(hoisted.createThreadDiscord, 0, 1, "createThreadDiscord"),
855
+ "thread options",
856
+ {
857
+ autoArchiveMinutes: 60,
858
+ },
859
+ );
860
+ expectFields(
861
+ mockCallArg(hoisted.createThreadDiscord, 0, 2, "createThreadDiscord"),
862
+ "thread context",
863
+ {
864
+ accountId: "default",
865
+ },
866
+ );
867
+ expect(manager.getByThreadId("thread-1")?.targetSessionKey).toBe("agent:main:subagent:parent");
868
+ expect(manager.getByThreadId("thread-created-2")?.targetSessionKey).toBe(
869
+ "agent:main:subagent:child-2",
870
+ );
871
+ });
872
+
873
+ it("resolves parent channel when thread target is passed via to without threadId", async () => {
874
+ createTestThreadBindingManager({
875
+ accountId: "default",
876
+ persist: false,
877
+ enableSweeper: false,
878
+ idleTimeoutMs: 24 * 60 * 60 * 1000,
879
+ maxAgeMs: 0,
880
+ });
881
+
882
+ hoisted.restGet.mockClear();
883
+ hoisted.restGet.mockResolvedValueOnce({
884
+ id: "thread-lookup",
885
+ type: 11,
886
+ parent_id: "parent-1",
887
+ });
888
+ hoisted.createThreadDiscord.mockClear();
889
+ hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-lookup" });
890
+
891
+ const childBinding = await autoBindSpawnedDiscordSubagent({
892
+ cfg: EMPTY_DISCORD_TEST_CONFIG,
893
+ accountId: "default",
894
+ channel: "discord",
895
+ to: "channel:thread-lookup",
896
+ childSessionKey: "agent:main:subagent:child-lookup",
897
+ agentId: "main",
898
+ });
899
+
900
+ expectFields(childBinding, "child binding", { channelId: "parent-1" });
901
+ expect(hoisted.restGet).toHaveBeenCalledTimes(1);
902
+ expect(mockCallArg(hoisted.createThreadDiscord, 0, 0, "createThreadDiscord")).toBe("parent-1");
903
+ expectFields(
904
+ mockCallArg(hoisted.createThreadDiscord, 0, 1, "createThreadDiscord"),
905
+ "thread options",
906
+ {
907
+ autoArchiveMinutes: 60,
908
+ },
909
+ );
910
+ expectFields(
911
+ mockCallArg(hoisted.createThreadDiscord, 0, 2, "createThreadDiscord"),
912
+ "thread context",
913
+ {
914
+ accountId: "default",
915
+ },
916
+ );
917
+ });
918
+
919
+ it("passes manager token when resolving parent channels for auto-bind", async () => {
920
+ const cfg = {
921
+ channels: { discord: { token: "tok" } },
922
+ } as KlawConfig;
923
+ createTestThreadBindingManager({
924
+ accountId: "runtime",
925
+ token: "runtime-token",
926
+ cfg,
927
+ persist: false,
928
+ enableSweeper: false,
929
+ idleTimeoutMs: 24 * 60 * 60 * 1000,
930
+ maxAgeMs: 0,
931
+ });
932
+
933
+ hoisted.createDiscordRestClient.mockClear();
934
+ hoisted.restGet.mockClear();
935
+ hoisted.restGet.mockResolvedValueOnce({
936
+ id: "thread-runtime",
937
+ type: 11,
938
+ parent_id: "parent-runtime",
939
+ });
940
+ hoisted.createThreadDiscord.mockClear();
941
+ hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-runtime" });
942
+
943
+ const childBinding = await autoBindSpawnedDiscordSubagent({
944
+ cfg,
945
+ accountId: "runtime",
946
+ channel: "discord",
947
+ to: "channel:thread-runtime",
948
+ childSessionKey: "agent:main:subagent:child-runtime",
949
+ agentId: "main",
950
+ });
951
+
952
+ expectFields(childBinding, "child binding", {
953
+ threadId: "thread-created-runtime",
954
+ targetSessionKey: "agent:main:subagent:child-runtime",
955
+ });
956
+ const firstClientArgs = mockCallArg(
957
+ hoisted.createDiscordRestClient,
958
+ 0,
959
+ 0,
960
+ "createDiscordRestClient",
961
+ ) as { accountId?: string; token?: string } | undefined;
962
+ expectFields(firstClientArgs, "first client args", {
963
+ accountId: "runtime",
964
+ token: "runtime-token",
965
+ });
966
+ const usedCfg = hoisted.createDiscordRestClient.mock.calls.some((call) => {
967
+ if (call?.[1] === cfg) {
968
+ return true;
969
+ }
970
+ const first = call?.[0];
971
+ return (
972
+ typeof first === "object" && first !== null && (first as { cfg?: unknown }).cfg === cfg
973
+ );
974
+ });
975
+ expect(usedCfg).toBe(true);
976
+ });
977
+
978
+ it("uses the active runtime snapshot cfg for manager operations", async () => {
979
+ const startupCfg = {
980
+ channels: { discord: { token: "startup-token" } },
981
+ } as KlawConfig;
982
+ const refreshedCfg = {
983
+ channels: { discord: { token: "refreshed-token" } },
984
+ } as KlawConfig;
985
+ const manager = createTestThreadBindingManager({
986
+ accountId: "runtime",
987
+ token: "runtime-token",
988
+ cfg: startupCfg,
989
+ persist: false,
990
+ enableSweeper: false,
991
+ idleTimeoutMs: 24 * 60 * 60 * 1000,
992
+ maxAgeMs: 0,
993
+ });
994
+
995
+ setRuntimeConfigSnapshot(refreshedCfg);
996
+ hoisted.createDiscordRestClient.mockClear();
997
+ hoisted.createThreadDiscord.mockClear();
998
+ hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-runtime-cfg" });
999
+
1000
+ const bound = await manager.bindTarget({
1001
+ createThread: true,
1002
+ channelId: "parent-runtime",
1003
+ targetKind: "subagent",
1004
+ targetSessionKey: "agent:main:subagent:runtime-cfg",
1005
+ agentId: "main",
1006
+ });
1007
+
1008
+ expectFields(bound, "bound thread", {
1009
+ threadId: "thread-created-runtime-cfg",
1010
+ targetSessionKey: "agent:main:subagent:runtime-cfg",
1011
+ });
1012
+ const usedRefreshedCfg = hoisted.createDiscordRestClient.mock.calls.some((call) => {
1013
+ if (call?.[1] === refreshedCfg) {
1014
+ return true;
1015
+ }
1016
+ const first = call?.[0];
1017
+ return (
1018
+ typeof first === "object" &&
1019
+ first !== null &&
1020
+ (first as { cfg?: unknown }).cfg === refreshedCfg
1021
+ );
1022
+ });
1023
+ expect(usedRefreshedCfg).toBe(true);
1024
+ const usedStartupCfg = hoisted.createDiscordRestClient.mock.calls.some((call) => {
1025
+ if (call?.[1] === startupCfg) {
1026
+ return true;
1027
+ }
1028
+ const first = call?.[0];
1029
+ return (
1030
+ typeof first === "object" &&
1031
+ first !== null &&
1032
+ (first as { cfg?: unknown }).cfg === startupCfg
1033
+ );
1034
+ });
1035
+ expect(usedStartupCfg).toBe(false);
1036
+ });
1037
+
1038
+ it("refreshes manager token when an existing manager is reused", async () => {
1039
+ createTestThreadBindingManager({
1040
+ accountId: "runtime",
1041
+ token: "token-old",
1042
+ persist: false,
1043
+ enableSweeper: false,
1044
+ idleTimeoutMs: 24 * 60 * 60 * 1000,
1045
+ maxAgeMs: 0,
1046
+ });
1047
+ const manager = createTestThreadBindingManager({
1048
+ accountId: "runtime",
1049
+ token: "token-new",
1050
+ persist: false,
1051
+ enableSweeper: false,
1052
+ idleTimeoutMs: 24 * 60 * 60 * 1000,
1053
+ maxAgeMs: 0,
1054
+ });
1055
+
1056
+ hoisted.createThreadDiscord.mockClear();
1057
+ hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-token-refresh" });
1058
+ hoisted.createDiscordRestClient.mockClear();
1059
+
1060
+ const bound = await manager.bindTarget({
1061
+ createThread: true,
1062
+ channelId: "parent-runtime",
1063
+ targetKind: "subagent",
1064
+ targetSessionKey: "agent:main:subagent:token-refresh",
1065
+ agentId: "main",
1066
+ });
1067
+
1068
+ expectFields(bound, "bound thread", {
1069
+ threadId: "thread-created-token-refresh",
1070
+ targetSessionKey: "agent:main:subagent:token-refresh",
1071
+ });
1072
+ expect(mockCallArg(hoisted.createThreadDiscord, 0, 0, "createThreadDiscord")).toBe(
1073
+ "parent-runtime",
1074
+ );
1075
+ expectFields(
1076
+ mockCallArg(hoisted.createThreadDiscord, 0, 1, "createThreadDiscord"),
1077
+ "thread options",
1078
+ {
1079
+ autoArchiveMinutes: 60,
1080
+ },
1081
+ );
1082
+ expectFields(
1083
+ mockCallArg(hoisted.createThreadDiscord, 0, 2, "createThreadDiscord"),
1084
+ "thread context",
1085
+ {
1086
+ accountId: "runtime",
1087
+ token: "token-new",
1088
+ },
1089
+ );
1090
+ const usedTokenNew = hoisted.createDiscordRestClient.mock.calls.some(
1091
+ (call) => (call?.[0] as { token?: string } | undefined)?.token === "token-new",
1092
+ );
1093
+ expect(usedTokenNew).toBe(true);
1094
+ });
1095
+
1096
+ it("normalizes prefixed parentConversationId before creating child thread bindings", async () => {
1097
+ createTestThreadBindingManager({
1098
+ accountId: "default",
1099
+ persist: false,
1100
+ enableSweeper: false,
1101
+ idleTimeoutMs: 24 * 60 * 60 * 1000,
1102
+ maxAgeMs: 0,
1103
+ });
1104
+
1105
+ hoisted.restGet.mockClear();
1106
+ hoisted.createThreadDiscord.mockClear();
1107
+ hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-parent-normalized" });
1108
+
1109
+ const bound = await getSessionBindingService().bind({
1110
+ targetSessionKey: "agent:codex:acp:test-parent-normalized",
1111
+ targetKind: "session",
1112
+ conversation: {
1113
+ channel: "discord",
1114
+ accountId: "default",
1115
+ conversationId: "channel:1491611525914558668",
1116
+ parentConversationId: "channel:1491611525914558667",
1117
+ },
1118
+ placement: "child",
1119
+ metadata: {
1120
+ agentId: "codex",
1121
+ label: "Codex ACP bind test",
1122
+ threadName: "Codex ACP bind test",
1123
+ },
1124
+ });
1125
+
1126
+ const boundConversation = requireRecord(
1127
+ requireRecord(bound, "bound session").conversation,
1128
+ "bound conversation",
1129
+ );
1130
+ expectFields(boundConversation, "bound conversation", {
1131
+ channel: "discord",
1132
+ accountId: "default",
1133
+ conversationId: "thread-created-parent-normalized",
1134
+ });
1135
+ expect(mockCallArg(hoisted.createThreadDiscord, 0, 0, "createThreadDiscord")).toBe(
1136
+ "1491611525914558667",
1137
+ );
1138
+ expectFields(
1139
+ mockCallArg(hoisted.createThreadDiscord, 0, 1, "createThreadDiscord"),
1140
+ "thread options",
1141
+ {
1142
+ autoArchiveMinutes: 60,
1143
+ },
1144
+ );
1145
+ expectFields(
1146
+ mockCallArg(hoisted.createThreadDiscord, 0, 2, "createThreadDiscord"),
1147
+ "thread context",
1148
+ {
1149
+ accountId: "default",
1150
+ },
1151
+ );
1152
+ expect(hoisted.restGet).not.toHaveBeenCalled();
1153
+ });
1154
+
1155
+ it("preserves prefixed current channel conversation ids as binding keys", async () => {
1156
+ createTestThreadBindingManager({
1157
+ accountId: "default",
1158
+ persist: false,
1159
+ enableSweeper: false,
1160
+ idleTimeoutMs: 24 * 60 * 60 * 1000,
1161
+ maxAgeMs: 0,
1162
+ });
1163
+
1164
+ hoisted.restGet.mockClear();
1165
+ hoisted.restPost.mockClear();
1166
+
1167
+ const service = getSessionBindingService();
1168
+ const bound = await service.bind({
1169
+ targetSessionKey: "agent:codex:acp:current-channel",
1170
+ targetKind: "session",
1171
+ conversation: {
1172
+ channel: "discord",
1173
+ accountId: "default",
1174
+ conversationId: "channel:1491611525914558667",
1175
+ },
1176
+ placement: "current",
1177
+ metadata: {
1178
+ agentId: "codex",
1179
+ },
1180
+ });
1181
+
1182
+ const boundConversation = requireRecord(
1183
+ requireRecord(bound, "bound session").conversation,
1184
+ "bound conversation",
1185
+ );
1186
+ expectFields(boundConversation, "bound conversation", {
1187
+ channel: "discord",
1188
+ accountId: "default",
1189
+ conversationId: "channel:1491611525914558667",
1190
+ });
1191
+ expectFields(
1192
+ service.resolveByConversation({
1193
+ channel: "discord",
1194
+ accountId: "default",
1195
+ conversationId: "channel:1491611525914558667",
1196
+ }),
1197
+ "resolved binding",
1198
+ {
1199
+ targetSessionKey: "agent:codex:acp:current-channel",
1200
+ },
1201
+ );
1202
+ expect(
1203
+ service.resolveByConversation({
1204
+ channel: "discord",
1205
+ accountId: "default",
1206
+ conversationId: "1491611525914558667",
1207
+ }),
1208
+ ).toBeNull();
1209
+ expect(hoisted.restGet).not.toHaveBeenCalled();
1210
+ expect(hoisted.restPost).not.toHaveBeenCalled();
1211
+ });
1212
+
1213
+ it("binds current Discord DMs as direct conversation bindings", async () => {
1214
+ createTestThreadBindingManager({
1215
+ accountId: "default",
1216
+ persist: false,
1217
+ enableSweeper: false,
1218
+ idleTimeoutMs: 24 * 60 * 60 * 1000,
1219
+ maxAgeMs: 0,
1220
+ });
1221
+
1222
+ hoisted.restGet.mockClear();
1223
+ hoisted.restPost.mockClear();
1224
+
1225
+ const bound = await getSessionBindingService().bind({
1226
+ targetSessionKey: "plugin-binding:klaw-codex-app-server:dm",
1227
+ targetKind: "session",
1228
+ conversation: {
1229
+ channel: "discord",
1230
+ accountId: "default",
1231
+ conversationId: "user:1177378744822943744",
1232
+ },
1233
+ placement: "current",
1234
+ metadata: {
1235
+ pluginBindingOwner: "plugin",
1236
+ pluginId: "klaw-codex-app-server",
1237
+ pluginRoot: "/Users/huntharo/github/klaw-app-server",
1238
+ },
1239
+ });
1240
+
1241
+ const boundConversation = requireRecord(
1242
+ requireRecord(bound, "bound session").conversation,
1243
+ "bound conversation",
1244
+ );
1245
+ expectFields(boundConversation, "bound conversation", {
1246
+ channel: "discord",
1247
+ accountId: "default",
1248
+ conversationId: "user:1177378744822943744",
1249
+ parentConversationId: "user:1177378744822943744",
1250
+ });
1251
+ const resolved = requireRecord(
1252
+ getSessionBindingService().resolveByConversation({
1253
+ channel: "discord",
1254
+ accountId: "default",
1255
+ conversationId: "user:1177378744822943744",
1256
+ }),
1257
+ "resolved binding",
1258
+ );
1259
+ expect(requireRecord(resolved.conversation, "resolved conversation").conversationId).toBe(
1260
+ "user:1177378744822943744",
1261
+ );
1262
+ expect(hoisted.restGet).not.toHaveBeenCalled();
1263
+ expect(hoisted.restPost).not.toHaveBeenCalled();
1264
+ });
1265
+
1266
+ it("preserves direct-binding metadata when rebinding the same conversation", async () => {
1267
+ createTestThreadBindingManager({
1268
+ accountId: "default",
1269
+ persist: false,
1270
+ enableSweeper: false,
1271
+ idleTimeoutMs: 24 * 60 * 60 * 1000,
1272
+ maxAgeMs: 0,
1273
+ });
1274
+
1275
+ await getSessionBindingService().bind({
1276
+ targetSessionKey: "plugin-binding:klaw-codex-app-server:dm",
1277
+ targetKind: "session",
1278
+ conversation: {
1279
+ channel: "discord",
1280
+ accountId: "default",
1281
+ conversationId: "user:1177378744822943744",
1282
+ },
1283
+ placement: "current",
1284
+ metadata: {
1285
+ pluginBindingOwner: "plugin",
1286
+ pluginId: "klaw-codex-app-server",
1287
+ pluginRoot: "/Users/huntharo/github/klaw-app-server",
1288
+ agentId: "codex",
1289
+ boundBy: "system",
1290
+ },
1291
+ });
1292
+
1293
+ await getSessionBindingService().bind({
1294
+ targetSessionKey: "plugin-binding:klaw-codex-app-server:dm",
1295
+ targetKind: "session",
1296
+ conversation: {
1297
+ channel: "discord",
1298
+ accountId: "default",
1299
+ conversationId: "user:1177378744822943744",
1300
+ },
1301
+ placement: "current",
1302
+ metadata: {
1303
+ label: "codex-dm",
1304
+ },
1305
+ });
1306
+
1307
+ const resolved = requireRecord(
1308
+ getSessionBindingService().resolveByConversation({
1309
+ channel: "discord",
1310
+ accountId: "default",
1311
+ conversationId: "user:1177378744822943744",
1312
+ }),
1313
+ "resolved binding",
1314
+ );
1315
+ expectFields(requireRecord(resolved.metadata, "resolved metadata"), "resolved metadata", {
1316
+ pluginBindingOwner: "plugin",
1317
+ pluginId: "klaw-codex-app-server",
1318
+ pluginRoot: "/Users/huntharo/github/klaw-app-server",
1319
+ agentId: "codex",
1320
+ boundBy: "system",
1321
+ label: "codex-dm",
1322
+ });
1323
+ expect(hoisted.restGet).not.toHaveBeenCalled();
1324
+ expect(hoisted.restPost).not.toHaveBeenCalled();
1325
+ });
1326
+
1327
+ it("keeps overlapping thread ids isolated per account", async () => {
1328
+ const a = createTestThreadBindingManager({
1329
+ accountId: "a",
1330
+ persist: false,
1331
+ enableSweeper: false,
1332
+ idleTimeoutMs: 24 * 60 * 60 * 1000,
1333
+ maxAgeMs: 0,
1334
+ });
1335
+ const b = createTestThreadBindingManager({
1336
+ accountId: "b",
1337
+ persist: false,
1338
+ enableSweeper: false,
1339
+ idleTimeoutMs: 24 * 60 * 60 * 1000,
1340
+ maxAgeMs: 0,
1341
+ });
1342
+
1343
+ const aBinding = await a.bindTarget({
1344
+ threadId: "thread-1",
1345
+ channelId: "parent-1",
1346
+ targetKind: "subagent",
1347
+ targetSessionKey: "agent:main:subagent:a",
1348
+ agentId: "main",
1349
+ });
1350
+ const bBinding = await b.bindTarget({
1351
+ threadId: "thread-1",
1352
+ channelId: "parent-1",
1353
+ targetKind: "subagent",
1354
+ targetSessionKey: "agent:main:subagent:b",
1355
+ agentId: "main",
1356
+ });
1357
+
1358
+ expect(aBinding?.accountId).toBe("a");
1359
+ expect(bBinding?.accountId).toBe("b");
1360
+ expect(a.getByThreadId("thread-1")?.targetSessionKey).toBe("agent:main:subagent:a");
1361
+ expect(b.getByThreadId("thread-1")?.targetSessionKey).toBe("agent:main:subagent:b");
1362
+
1363
+ const removedA = a.unbindBySessionKey({
1364
+ targetSessionKey: "agent:main:subagent:a",
1365
+ sendFarewell: false,
1366
+ });
1367
+ expect(removedA).toHaveLength(1);
1368
+ expect(a.getByThreadId("thread-1")).toBeUndefined();
1369
+ expect(b.getByThreadId("thread-1")?.targetSessionKey).toBe("agent:main:subagent:b");
1370
+ });
1371
+
1372
+ it("removes stale ACP bindings during startup reconciliation", async () => {
1373
+ const manager = createTestThreadBindingManager({
1374
+ accountId: "default",
1375
+ persist: false,
1376
+ enableSweeper: false,
1377
+ idleTimeoutMs: 24 * 60 * 60 * 1000,
1378
+ maxAgeMs: 0,
1379
+ });
1380
+
1381
+ await manager.bindTarget({
1382
+ threadId: "thread-acp-healthy",
1383
+ channelId: "parent-1",
1384
+ targetKind: "acp",
1385
+ targetSessionKey: "agent:codex:acp:healthy",
1386
+ agentId: "codex",
1387
+ webhookId: "wh-1",
1388
+ webhookToken: "tok-1",
1389
+ });
1390
+ await manager.bindTarget({
1391
+ threadId: "thread-acp-stale",
1392
+ channelId: "parent-1",
1393
+ targetKind: "acp",
1394
+ targetSessionKey: "agent:codex:acp:stale",
1395
+ agentId: "codex",
1396
+ webhookId: "wh-1",
1397
+ webhookToken: "tok-1",
1398
+ });
1399
+ await manager.bindTarget({
1400
+ threadId: "thread-subagent",
1401
+ channelId: "parent-1",
1402
+ targetKind: "subagent",
1403
+ targetSessionKey: "agent:main:subagent:child",
1404
+ agentId: "main",
1405
+ webhookId: "wh-1",
1406
+ webhookToken: "tok-1",
1407
+ });
1408
+
1409
+ hoisted.readAcpSessionEntry.mockImplementation((paramsUnknown: unknown) => {
1410
+ const sessionKey = (paramsUnknown as { sessionKey?: string }).sessionKey ?? "";
1411
+ if (sessionKey === "agent:codex:acp:healthy") {
1412
+ return {
1413
+ sessionKey,
1414
+ storeSessionKey: sessionKey,
1415
+ acp: {
1416
+ backend: "acpx",
1417
+ agent: "codex",
1418
+ runtimeSessionName: "runtime:healthy",
1419
+ mode: "persistent",
1420
+ state: "idle",
1421
+ lastActivityAt: Date.now(),
1422
+ },
1423
+ };
1424
+ }
1425
+ return {
1426
+ sessionKey,
1427
+ storeSessionKey: sessionKey,
1428
+ acp: undefined,
1429
+ };
1430
+ });
1431
+
1432
+ const result = await reconcileAcpThreadBindingsOnStartup({
1433
+ cfg: EMPTY_DISCORD_TEST_CONFIG,
1434
+ accountId: "default",
1435
+ });
1436
+
1437
+ expect(result.checked).toBe(2);
1438
+ expect(result.removed).toBe(1);
1439
+ expect(result.staleSessionKeys).toContain("agent:codex:acp:stale");
1440
+ expectFields(requireBinding(manager, "thread-acp-healthy"), "healthy binding", {
1441
+ threadId: "thread-acp-healthy",
1442
+ targetKind: "acp",
1443
+ targetSessionKey: "agent:codex:acp:healthy",
1444
+ });
1445
+ expect(manager.getByThreadId("thread-acp-stale")).toBeUndefined();
1446
+ expectFields(requireBinding(manager, "thread-subagent"), "subagent binding", {
1447
+ threadId: "thread-subagent",
1448
+ targetKind: "subagent",
1449
+ targetSessionKey: "agent:main:subagent:child",
1450
+ });
1451
+ expect(hoisted.sendMessageDiscord).not.toHaveBeenCalled();
1452
+ expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled();
1453
+ });
1454
+
1455
+ it("keeps ACP bindings when session store reads fail during startup reconciliation", async () => {
1456
+ const manager = createTestThreadBindingManager({
1457
+ accountId: "default",
1458
+ persist: false,
1459
+ enableSweeper: false,
1460
+ idleTimeoutMs: 24 * 60 * 60 * 1000,
1461
+ maxAgeMs: 0,
1462
+ });
1463
+
1464
+ await manager.bindTarget({
1465
+ threadId: "thread-acp-uncertain",
1466
+ channelId: "parent-1",
1467
+ targetKind: "acp",
1468
+ targetSessionKey: "agent:codex:acp:uncertain",
1469
+ agentId: "codex",
1470
+ webhookId: "wh-1",
1471
+ webhookToken: "tok-1",
1472
+ });
1473
+
1474
+ hoisted.readAcpSessionEntry.mockReturnValue({
1475
+ sessionKey: "agent:codex:acp:uncertain",
1476
+ storeSessionKey: "agent:codex:acp:uncertain",
1477
+ cfg: EMPTY_DISCORD_TEST_CONFIG,
1478
+ storePath: "/tmp/mock-sessions.json",
1479
+ storeReadFailed: true,
1480
+ entry: undefined,
1481
+ acp: undefined,
1482
+ });
1483
+
1484
+ const result = await reconcileAcpThreadBindingsOnStartup({
1485
+ cfg: EMPTY_DISCORD_TEST_CONFIG,
1486
+ accountId: "default",
1487
+ });
1488
+
1489
+ expect(result.checked).toBe(1);
1490
+ expect(result.removed).toBe(0);
1491
+ expect(result.staleSessionKeys).toStrictEqual([]);
1492
+ expectFields(requireBinding(manager, "thread-acp-uncertain"), "uncertain binding", {
1493
+ threadId: "thread-acp-uncertain",
1494
+ targetKind: "acp",
1495
+ targetSessionKey: "agent:codex:acp:uncertain",
1496
+ });
1497
+ });
1498
+
1499
+ it("does not reconcile plugin-owned direct bindings as stale ACP sessions", async () => {
1500
+ const manager = createTestThreadBindingManager({
1501
+ accountId: "default",
1502
+ persist: false,
1503
+ enableSweeper: false,
1504
+ idleTimeoutMs: 24 * 60 * 60 * 1000,
1505
+ maxAgeMs: 0,
1506
+ });
1507
+
1508
+ await manager.bindTarget({
1509
+ threadId: "user:1177378744822943744",
1510
+ channelId: "user:1177378744822943744",
1511
+ targetKind: "acp",
1512
+ targetSessionKey: "plugin-binding:klaw-codex-app-server:dm",
1513
+ agentId: "codex",
1514
+ metadata: {
1515
+ pluginBindingOwner: "plugin",
1516
+ pluginId: "klaw-codex-app-server",
1517
+ pluginRoot: "/Users/huntharo/github/klaw-app-server",
1518
+ },
1519
+ });
1520
+
1521
+ hoisted.readAcpSessionEntry.mockReturnValue(null);
1522
+
1523
+ const result = await reconcileAcpThreadBindingsOnStartup({
1524
+ cfg: EMPTY_DISCORD_TEST_CONFIG,
1525
+ accountId: "default",
1526
+ });
1527
+
1528
+ expect(result.checked).toBe(0);
1529
+ expect(result.removed).toBe(0);
1530
+ expect(result.staleSessionKeys).toStrictEqual([]);
1531
+ const binding = expectFields(
1532
+ manager.getByThreadId("user:1177378744822943744"),
1533
+ "plugin direct binding",
1534
+ {
1535
+ threadId: "user:1177378744822943744",
1536
+ },
1537
+ );
1538
+ expectFields(requireRecord(binding.metadata, "binding metadata"), "binding metadata", {
1539
+ pluginBindingOwner: "plugin",
1540
+ pluginId: "klaw-codex-app-server",
1541
+ });
1542
+ });
1543
+
1544
+ it("removes ACP bindings when health probe marks running session as stale", async () => {
1545
+ const manager = createTestThreadBindingManager({
1546
+ accountId: "default",
1547
+ persist: false,
1548
+ enableSweeper: false,
1549
+ idleTimeoutMs: 24 * 60 * 60 * 1000,
1550
+ maxAgeMs: 0,
1551
+ });
1552
+
1553
+ await manager.bindTarget({
1554
+ threadId: "thread-acp-running",
1555
+ channelId: "parent-1",
1556
+ targetKind: "acp",
1557
+ targetSessionKey: "agent:codex:acp:running",
1558
+ agentId: "codex",
1559
+ webhookId: "wh-1",
1560
+ webhookToken: "tok-1",
1561
+ });
1562
+
1563
+ hoisted.readAcpSessionEntry.mockReturnValue({
1564
+ sessionKey: "agent:codex:acp:running",
1565
+ storeSessionKey: "agent:codex:acp:running",
1566
+ acp: {
1567
+ backend: "acpx",
1568
+ agent: "codex",
1569
+ runtimeSessionName: "runtime:running",
1570
+ mode: "persistent",
1571
+ state: "running",
1572
+ lastActivityAt: Date.now() - 5 * 60 * 1000,
1573
+ },
1574
+ });
1575
+
1576
+ const result = await reconcileAcpThreadBindingsOnStartup({
1577
+ cfg: EMPTY_DISCORD_TEST_CONFIG,
1578
+ accountId: "default",
1579
+ healthProbe: async () => ({ status: "stale", reason: "status-timeout-running-stale" }),
1580
+ });
1581
+
1582
+ expect(result.checked).toBe(1);
1583
+ expect(result.removed).toBe(1);
1584
+ expect(result.staleSessionKeys).toContain("agent:codex:acp:running");
1585
+ expect(manager.getByThreadId("thread-acp-running")).toBeUndefined();
1586
+ });
1587
+
1588
+ it("keeps running ACP bindings when health probe is uncertain", async () => {
1589
+ const manager = createTestThreadBindingManager({
1590
+ accountId: "default",
1591
+ persist: false,
1592
+ enableSweeper: false,
1593
+ idleTimeoutMs: 24 * 60 * 60 * 1000,
1594
+ maxAgeMs: 0,
1595
+ });
1596
+
1597
+ await manager.bindTarget({
1598
+ threadId: "thread-acp-running-uncertain",
1599
+ channelId: "parent-1",
1600
+ targetKind: "acp",
1601
+ targetSessionKey: "agent:codex:acp:running-uncertain",
1602
+ agentId: "codex",
1603
+ webhookId: "wh-1",
1604
+ webhookToken: "tok-1",
1605
+ });
1606
+
1607
+ hoisted.readAcpSessionEntry.mockReturnValue({
1608
+ sessionKey: "agent:codex:acp:running-uncertain",
1609
+ storeSessionKey: "agent:codex:acp:running-uncertain",
1610
+ acp: {
1611
+ backend: "acpx",
1612
+ agent: "codex",
1613
+ runtimeSessionName: "runtime:running-uncertain",
1614
+ mode: "persistent",
1615
+ state: "running",
1616
+ lastActivityAt: Date.now(),
1617
+ },
1618
+ });
1619
+
1620
+ const result = await reconcileAcpThreadBindingsOnStartup({
1621
+ cfg: EMPTY_DISCORD_TEST_CONFIG,
1622
+ accountId: "default",
1623
+ healthProbe: async () => ({ status: "uncertain", reason: "status-timeout" }),
1624
+ });
1625
+
1626
+ expect(result.checked).toBe(1);
1627
+ expect(result.removed).toBe(0);
1628
+ expect(result.staleSessionKeys).toStrictEqual([]);
1629
+ expectFields(
1630
+ requireBinding(manager, "thread-acp-running-uncertain"),
1631
+ "running uncertain binding",
1632
+ {
1633
+ threadId: "thread-acp-running-uncertain",
1634
+ targetKind: "acp",
1635
+ targetSessionKey: "agent:codex:acp:running-uncertain",
1636
+ },
1637
+ );
1638
+ });
1639
+
1640
+ it("keeps ACP bindings in stored error state when no explicit stale probe verdict exists", async () => {
1641
+ const manager = createTestThreadBindingManager({
1642
+ accountId: "default",
1643
+ persist: false,
1644
+ enableSweeper: false,
1645
+ idleTimeoutMs: 24 * 60 * 60 * 1000,
1646
+ maxAgeMs: 0,
1647
+ });
1648
+
1649
+ await manager.bindTarget({
1650
+ threadId: "thread-acp-error",
1651
+ channelId: "parent-1",
1652
+ targetKind: "acp",
1653
+ targetSessionKey: "agent:codex:acp:error",
1654
+ agentId: "codex",
1655
+ webhookId: "wh-1",
1656
+ webhookToken: "tok-1",
1657
+ });
1658
+
1659
+ hoisted.readAcpSessionEntry.mockReturnValue({
1660
+ sessionKey: "agent:codex:acp:error",
1661
+ storeSessionKey: "agent:codex:acp:error",
1662
+ acp: {
1663
+ backend: "acpx",
1664
+ agent: "codex",
1665
+ runtimeSessionName: "runtime:error",
1666
+ mode: "persistent",
1667
+ state: "error",
1668
+ lastActivityAt: Date.now(),
1669
+ },
1670
+ });
1671
+
1672
+ const result = await reconcileAcpThreadBindingsOnStartup({
1673
+ cfg: EMPTY_DISCORD_TEST_CONFIG,
1674
+ accountId: "default",
1675
+ });
1676
+
1677
+ expect(result.checked).toBe(1);
1678
+ expect(result.removed).toBe(0);
1679
+ expect(result.staleSessionKeys).toStrictEqual([]);
1680
+ expectFields(requireBinding(manager, "thread-acp-error"), "error binding", {
1681
+ threadId: "thread-acp-error",
1682
+ targetKind: "acp",
1683
+ targetSessionKey: "agent:codex:acp:error",
1684
+ });
1685
+ });
1686
+
1687
+ it("starts ACP health probes in parallel during startup reconciliation", async () => {
1688
+ const manager = createTestThreadBindingManager({
1689
+ accountId: "default",
1690
+ persist: false,
1691
+ enableSweeper: false,
1692
+ idleTimeoutMs: 24 * 60 * 60 * 1000,
1693
+ maxAgeMs: 0,
1694
+ });
1695
+
1696
+ await manager.bindTarget({
1697
+ threadId: "thread-acp-probe-1",
1698
+ channelId: "parent-1",
1699
+ targetKind: "acp",
1700
+ targetSessionKey: "agent:codex:acp:probe-1",
1701
+ agentId: "codex",
1702
+ webhookId: "wh-1",
1703
+ webhookToken: "tok-1",
1704
+ });
1705
+ await manager.bindTarget({
1706
+ threadId: "thread-acp-probe-2",
1707
+ channelId: "parent-1",
1708
+ targetKind: "acp",
1709
+ targetSessionKey: "agent:codex:acp:probe-2",
1710
+ agentId: "codex",
1711
+ webhookId: "wh-1",
1712
+ webhookToken: "tok-1",
1713
+ });
1714
+
1715
+ hoisted.readAcpSessionEntry.mockImplementation((paramsUnknown: unknown) => {
1716
+ const sessionKey = (paramsUnknown as { sessionKey?: string }).sessionKey ?? "";
1717
+ return {
1718
+ sessionKey,
1719
+ storeSessionKey: sessionKey,
1720
+ acp: {
1721
+ backend: "acpx",
1722
+ agent: "codex",
1723
+ runtimeSessionName: `runtime:${sessionKey}`,
1724
+ mode: "persistent",
1725
+ state: "running",
1726
+ lastActivityAt: Date.now(),
1727
+ },
1728
+ };
1729
+ });
1730
+
1731
+ let resolveFirstProbe: ((value: { status: "healthy" }) => void) | undefined;
1732
+ const firstProbe = new Promise<{ status: "healthy" }>((resolve) => {
1733
+ resolveFirstProbe = resolve;
1734
+ });
1735
+ let probeCallCount = 0;
1736
+ let secondProbeStartedBeforeFirstResolved = false;
1737
+
1738
+ const reconcilePromise = reconcileAcpThreadBindingsOnStartup({
1739
+ cfg: EMPTY_DISCORD_TEST_CONFIG,
1740
+ accountId: "default",
1741
+ healthProbe: async () => {
1742
+ probeCallCount += 1;
1743
+ if (probeCallCount === 1) {
1744
+ return await firstProbe;
1745
+ }
1746
+ secondProbeStartedBeforeFirstResolved = true;
1747
+ return { status: "healthy" as const };
1748
+ },
1749
+ });
1750
+
1751
+ await Promise.resolve();
1752
+ await Promise.resolve();
1753
+ const observedParallelStart = secondProbeStartedBeforeFirstResolved;
1754
+
1755
+ resolveFirstProbe?.({ status: "healthy" });
1756
+ const result = await reconcilePromise;
1757
+
1758
+ expect(observedParallelStart).toBe(true);
1759
+ expect(result.checked).toBe(2);
1760
+ expect(result.removed).toBe(0);
1761
+ });
1762
+
1763
+ it("caps ACP startup health probe concurrency", async () => {
1764
+ const manager = createTestThreadBindingManager({
1765
+ accountId: "default",
1766
+ persist: false,
1767
+ enableSweeper: false,
1768
+ idleTimeoutMs: 24 * 60 * 60 * 1000,
1769
+ maxAgeMs: 0,
1770
+ });
1771
+
1772
+ for (let index = 0; index < 12; index += 1) {
1773
+ const key = `agent:codex:acp:cap-${index}`;
1774
+ await manager.bindTarget({
1775
+ threadId: `thread-acp-cap-${index}`,
1776
+ channelId: "parent-1",
1777
+ targetKind: "acp",
1778
+ targetSessionKey: key,
1779
+ agentId: "codex",
1780
+ webhookId: "wh-1",
1781
+ webhookToken: "tok-1",
1782
+ });
1783
+ }
1784
+
1785
+ hoisted.readAcpSessionEntry.mockImplementation((paramsUnknown: unknown) => {
1786
+ const sessionKey = (paramsUnknown as { sessionKey?: string }).sessionKey ?? "";
1787
+ return {
1788
+ sessionKey,
1789
+ storeSessionKey: sessionKey,
1790
+ acp: {
1791
+ backend: "acpx",
1792
+ agent: "codex",
1793
+ runtimeSessionName: `runtime:${sessionKey}`,
1794
+ mode: "persistent",
1795
+ state: "running",
1796
+ lastActivityAt: Date.now(),
1797
+ },
1798
+ };
1799
+ });
1800
+
1801
+ const PROBE_LIMIT = 8;
1802
+ let probeCalls = 0;
1803
+ let inFlight = 0;
1804
+ let maxInFlight = 0;
1805
+ let releaseFirstWave: (() => void) | undefined;
1806
+ const firstWaveGate = new Promise<void>((resolve) => {
1807
+ releaseFirstWave = resolve;
1808
+ });
1809
+
1810
+ const reconcilePromise = reconcileAcpThreadBindingsOnStartup({
1811
+ cfg: EMPTY_DISCORD_TEST_CONFIG,
1812
+ accountId: "default",
1813
+ healthProbe: async () => {
1814
+ probeCalls += 1;
1815
+ inFlight += 1;
1816
+ maxInFlight = Math.max(maxInFlight, inFlight);
1817
+ if (probeCalls <= PROBE_LIMIT) {
1818
+ await firstWaveGate;
1819
+ }
1820
+ inFlight -= 1;
1821
+ return { status: "healthy" as const };
1822
+ },
1823
+ });
1824
+
1825
+ await vi.waitFor(() => {
1826
+ expect(probeCalls).toBe(PROBE_LIMIT);
1827
+ });
1828
+ expect(maxInFlight).toBe(PROBE_LIMIT);
1829
+
1830
+ releaseFirstWave?.();
1831
+ const result = await reconcilePromise;
1832
+ expect(result.checked).toBe(12);
1833
+ expect(result.removed).toBe(0);
1834
+ expect(maxInFlight).toBeLessThanOrEqual(PROBE_LIMIT);
1835
+ });
1836
+
1837
+ it("migrates legacy expiresAt bindings to idle/max-age semantics", () => {
1838
+ const previousStateDir = process.env.KLAW_STATE_DIR;
1839
+ const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "klaw-thread-bindings-"));
1840
+ process.env.KLAW_STATE_DIR = stateDir;
1841
+ try {
1842
+ testing.resetThreadBindingsForTests();
1843
+ const bindingsPath = testing.resolveThreadBindingsPath();
1844
+ fs.mkdirSync(path.dirname(bindingsPath), { recursive: true });
1845
+ const boundAt = Date.now() - 10_000;
1846
+ const expiresAt = boundAt + 60_000;
1847
+ fs.writeFileSync(
1848
+ bindingsPath,
1849
+ JSON.stringify(
1850
+ {
1851
+ version: 1,
1852
+ bindings: {
1853
+ "thread-legacy-active": {
1854
+ accountId: "default",
1855
+ channelId: "parent-1",
1856
+ threadId: "thread-legacy-active",
1857
+ targetKind: "subagent",
1858
+ targetSessionKey: "agent:main:subagent:legacy-active",
1859
+ agentId: "main",
1860
+ boundBy: "system",
1861
+ boundAt,
1862
+ expiresAt,
1863
+ },
1864
+ "thread-legacy-disabled": {
1865
+ accountId: "default",
1866
+ channelId: "parent-1",
1867
+ threadId: "thread-legacy-disabled",
1868
+ targetKind: "subagent",
1869
+ targetSessionKey: "agent:main:subagent:legacy-disabled",
1870
+ agentId: "main",
1871
+ boundBy: "system",
1872
+ boundAt,
1873
+ expiresAt: 0,
1874
+ },
1875
+ },
1876
+ },
1877
+ null,
1878
+ 2,
1879
+ ),
1880
+ "utf-8",
1881
+ );
1882
+
1883
+ const manager = createTestThreadBindingManager({
1884
+ accountId: "default",
1885
+ persist: false,
1886
+ enableSweeper: false,
1887
+ idleTimeoutMs: 24 * 60 * 60 * 1000,
1888
+ maxAgeMs: 0,
1889
+ });
1890
+
1891
+ const active = manager.getByThreadId("thread-legacy-active");
1892
+ if (!active) {
1893
+ throw new Error("missing migrated legacy active thread binding");
1894
+ }
1895
+ expect(active.idleTimeoutMs).toBe(0);
1896
+ expect(active.maxAgeMs).toBe(expiresAt - boundAt);
1897
+ expect(
1898
+ resolveThreadBindingMaxAgeExpiresAt({
1899
+ record: active,
1900
+ defaultMaxAgeMs: manager.getMaxAgeMs(),
1901
+ }),
1902
+ ).toBe(expiresAt);
1903
+ expect(
1904
+ resolveThreadBindingInactivityExpiresAt({
1905
+ record: active,
1906
+ defaultIdleTimeoutMs: manager.getIdleTimeoutMs(),
1907
+ }),
1908
+ ).toBeUndefined();
1909
+
1910
+ const disabled = manager.getByThreadId("thread-legacy-disabled");
1911
+ if (!disabled) {
1912
+ throw new Error("missing migrated legacy disabled thread binding");
1913
+ }
1914
+ expect(disabled.idleTimeoutMs).toBe(0);
1915
+ expect(disabled.maxAgeMs).toBe(0);
1916
+ expect(
1917
+ resolveThreadBindingMaxAgeExpiresAt({
1918
+ record: disabled,
1919
+ defaultMaxAgeMs: manager.getMaxAgeMs(),
1920
+ }),
1921
+ ).toBeUndefined();
1922
+ expect(
1923
+ resolveThreadBindingInactivityExpiresAt({
1924
+ record: disabled,
1925
+ defaultIdleTimeoutMs: manager.getIdleTimeoutMs(),
1926
+ }),
1927
+ ).toBeUndefined();
1928
+ } finally {
1929
+ testing.resetThreadBindingsForTests();
1930
+ if (previousStateDir === undefined) {
1931
+ delete process.env.KLAW_STATE_DIR;
1932
+ } else {
1933
+ process.env.KLAW_STATE_DIR = previousStateDir;
1934
+ }
1935
+ fs.rmSync(stateDir, { recursive: true, force: true });
1936
+ }
1937
+ });
1938
+
1939
+ it("persists unbinds even when no manager is active", () => {
1940
+ const previousStateDir = process.env.KLAW_STATE_DIR;
1941
+ const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "klaw-thread-bindings-"));
1942
+ process.env.KLAW_STATE_DIR = stateDir;
1943
+ try {
1944
+ testing.resetThreadBindingsForTests();
1945
+ const bindingsPath = testing.resolveThreadBindingsPath();
1946
+ fs.mkdirSync(path.dirname(bindingsPath), { recursive: true });
1947
+ const now = Date.now();
1948
+ fs.writeFileSync(
1949
+ bindingsPath,
1950
+ JSON.stringify(
1951
+ {
1952
+ version: 1,
1953
+ bindings: {
1954
+ "thread-1": {
1955
+ accountId: "default",
1956
+ channelId: "parent-1",
1957
+ threadId: "thread-1",
1958
+ targetKind: "subagent",
1959
+ targetSessionKey: "agent:main:subagent:child",
1960
+ agentId: "main",
1961
+ boundBy: "system",
1962
+ boundAt: now,
1963
+ lastActivityAt: now,
1964
+ idleTimeoutMs: 60_000,
1965
+ maxAgeMs: 0,
1966
+ },
1967
+ },
1968
+ },
1969
+ null,
1970
+ 2,
1971
+ ),
1972
+ "utf-8",
1973
+ );
1974
+
1975
+ const removed = unbindThreadBindingsBySessionKey({
1976
+ targetSessionKey: "agent:main:subagent:child",
1977
+ });
1978
+ expect(removed).toHaveLength(1);
1979
+
1980
+ const payload = JSON.parse(fs.readFileSync(bindingsPath, "utf-8")) as {
1981
+ bindings?: Record<string, unknown>;
1982
+ };
1983
+ expect(Object.keys(payload.bindings ?? {})).toStrictEqual([]);
1984
+ } finally {
1985
+ testing.resetThreadBindingsForTests();
1986
+ if (previousStateDir === undefined) {
1987
+ delete process.env.KLAW_STATE_DIR;
1988
+ } else {
1989
+ process.env.KLAW_STATE_DIR = previousStateDir;
1990
+ }
1991
+ fs.rmSync(stateDir, { recursive: true, force: true });
1992
+ }
1993
+ });
1994
+ });