onbuzz 4.9.13 → 4.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (451) hide show
  1. package/node_modules/glob/README.md +31 -5
  2. package/node_modules/glob/dist/commonjs/glob.d.ts +8 -0
  3. package/node_modules/glob/dist/commonjs/glob.d.ts.map +1 -1
  4. package/node_modules/glob/dist/commonjs/glob.js +2 -1
  5. package/node_modules/glob/dist/commonjs/glob.js.map +1 -1
  6. package/node_modules/glob/dist/commonjs/index.min.js +3 -3
  7. package/node_modules/glob/dist/commonjs/index.min.js.map +4 -4
  8. package/node_modules/glob/dist/commonjs/pattern.d.ts +3 -0
  9. package/node_modules/glob/dist/commonjs/pattern.d.ts.map +1 -1
  10. package/node_modules/glob/dist/commonjs/pattern.js +4 -0
  11. package/node_modules/glob/dist/commonjs/pattern.js.map +1 -1
  12. package/node_modules/glob/dist/esm/glob.d.ts +8 -0
  13. package/node_modules/glob/dist/esm/glob.d.ts.map +1 -1
  14. package/node_modules/glob/dist/esm/glob.js +2 -1
  15. package/node_modules/glob/dist/esm/glob.js.map +1 -1
  16. package/node_modules/glob/dist/esm/index.min.js +3 -3
  17. package/node_modules/glob/dist/esm/index.min.js.map +4 -4
  18. package/node_modules/glob/dist/esm/pattern.d.ts +3 -0
  19. package/node_modules/glob/dist/esm/pattern.d.ts.map +1 -1
  20. package/node_modules/glob/dist/esm/pattern.js +4 -0
  21. package/node_modules/glob/dist/esm/pattern.js.map +1 -1
  22. package/node_modules/{@isaacs → glob/node_modules}/balanced-match/README.md +7 -10
  23. package/node_modules/{@isaacs → glob/node_modules}/balanced-match/package.json +7 -18
  24. package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/README.md +3 -6
  25. package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/index.js +6 -4
  26. package/node_modules/glob/node_modules/brace-expansion/dist/commonjs/index.js.map +1 -0
  27. package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/index.js +6 -4
  28. package/node_modules/glob/node_modules/brace-expansion/dist/esm/index.js.map +1 -0
  29. package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/package.json +11 -7
  30. package/node_modules/glob/node_modules/minimatch/README.md +76 -1
  31. package/node_modules/glob/node_modules/minimatch/dist/commonjs/assert-valid-pattern.d.ts +1 -1
  32. package/node_modules/glob/node_modules/minimatch/dist/commonjs/assert-valid-pattern.d.ts.map +1 -1
  33. package/node_modules/glob/node_modules/minimatch/dist/commonjs/assert-valid-pattern.js.map +1 -1
  34. package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.d.ts +4 -2
  35. package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.d.ts.map +1 -1
  36. package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.js +309 -55
  37. package/node_modules/glob/node_modules/minimatch/dist/commonjs/ast.js.map +1 -1
  38. package/node_modules/glob/node_modules/minimatch/dist/commonjs/brace-expressions.d.ts.map +1 -1
  39. package/node_modules/glob/node_modules/minimatch/dist/commonjs/brace-expressions.js +2 -4
  40. package/node_modules/glob/node_modules/minimatch/dist/commonjs/brace-expressions.js.map +1 -1
  41. package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.d.ts +1 -1
  42. package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.d.ts.map +1 -1
  43. package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.js +4 -4
  44. package/node_modules/glob/node_modules/minimatch/dist/commonjs/escape.js.map +1 -1
  45. package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.d.ts +81 -1
  46. package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.d.ts.map +1 -1
  47. package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.js +232 -134
  48. package/node_modules/glob/node_modules/minimatch/dist/commonjs/index.js.map +1 -1
  49. package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.d.ts +1 -1
  50. package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.d.ts.map +1 -1
  51. package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.js +8 -8
  52. package/node_modules/glob/node_modules/minimatch/dist/commonjs/unescape.js.map +1 -1
  53. package/node_modules/glob/node_modules/minimatch/dist/esm/assert-valid-pattern.d.ts +1 -1
  54. package/node_modules/glob/node_modules/minimatch/dist/esm/assert-valid-pattern.d.ts.map +1 -1
  55. package/node_modules/glob/node_modules/minimatch/dist/esm/assert-valid-pattern.js.map +1 -1
  56. package/node_modules/glob/node_modules/minimatch/dist/esm/ast.d.ts +4 -2
  57. package/node_modules/glob/node_modules/minimatch/dist/esm/ast.d.ts.map +1 -1
  58. package/node_modules/glob/node_modules/minimatch/dist/esm/ast.js +309 -55
  59. package/node_modules/glob/node_modules/minimatch/dist/esm/ast.js.map +1 -1
  60. package/node_modules/glob/node_modules/minimatch/dist/esm/brace-expressions.d.ts.map +1 -1
  61. package/node_modules/glob/node_modules/minimatch/dist/esm/brace-expressions.js +2 -4
  62. package/node_modules/glob/node_modules/minimatch/dist/esm/brace-expressions.js.map +1 -1
  63. package/node_modules/glob/node_modules/minimatch/dist/esm/escape.d.ts +1 -1
  64. package/node_modules/glob/node_modules/minimatch/dist/esm/escape.d.ts.map +1 -1
  65. package/node_modules/glob/node_modules/minimatch/dist/esm/escape.js +4 -4
  66. package/node_modules/glob/node_modules/minimatch/dist/esm/escape.js.map +1 -1
  67. package/node_modules/glob/node_modules/minimatch/dist/esm/index.d.ts +81 -1
  68. package/node_modules/glob/node_modules/minimatch/dist/esm/index.d.ts.map +1 -1
  69. package/node_modules/glob/node_modules/minimatch/dist/esm/index.js +232 -134
  70. package/node_modules/glob/node_modules/minimatch/dist/esm/index.js.map +1 -1
  71. package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.d.ts +1 -1
  72. package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.d.ts.map +1 -1
  73. package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.js +8 -8
  74. package/node_modules/glob/node_modules/minimatch/dist/esm/unescape.js.map +1 -1
  75. package/node_modules/glob/node_modules/minimatch/package.json +17 -11
  76. package/node_modules/glob/package.json +10 -13
  77. package/node_modules/minipass/LICENSE.md +55 -0
  78. package/node_modules/minipass/dist/commonjs/index.d.ts +12 -16
  79. package/node_modules/minipass/dist/commonjs/index.d.ts.map +1 -1
  80. package/node_modules/minipass/dist/commonjs/index.js +13 -3
  81. package/node_modules/minipass/dist/commonjs/index.js.map +1 -1
  82. package/node_modules/minipass/dist/esm/index.d.ts +12 -16
  83. package/node_modules/minipass/dist/esm/index.d.ts.map +1 -1
  84. package/node_modules/minipass/dist/esm/index.js +3 -1
  85. package/node_modules/minipass/dist/esm/index.js.map +1 -1
  86. package/node_modules/minipass/package.json +9 -14
  87. package/node_modules/path-scurry/node_modules/lru-cache/README.md +96 -10
  88. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel-browser.d.ts.map +1 -0
  89. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel-browser.js.map +1 -0
  90. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel.d.ts +5 -0
  91. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/diagnostics-channel.js +7 -0
  92. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.d.ts +1400 -0
  93. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.d.ts.map +1 -0
  94. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.js +1726 -0
  95. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.js.map +1 -0
  96. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.min.js +2 -0
  97. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/index.min.js.map +7 -0
  98. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.d.ts +12 -0
  99. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.d.ts.map +1 -0
  100. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.js +10 -0
  101. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/browser/perf.js.map +1 -0
  102. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel-cjs.cjs.map +1 -0
  103. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel-cjs.d.cts.map +1 -0
  104. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel.d.ts +5 -0
  105. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/diagnostics-channel.js +7 -0
  106. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.d.ts +109 -32
  107. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.d.ts.map +1 -1
  108. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.js +334 -197
  109. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.js.map +1 -1
  110. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.min.js +1 -1
  111. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/index.min.js.map +4 -4
  112. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel-node.d.ts.map +1 -0
  113. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel-node.js.map +1 -0
  114. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel.d.ts +5 -0
  115. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/diagnostics-channel.js +9 -0
  116. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.d.ts +1400 -0
  117. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.d.ts.map +1 -0
  118. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.js +1726 -0
  119. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.js.map +1 -0
  120. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.min.js +2 -0
  121. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/index.min.js.map +7 -0
  122. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.d.ts +12 -0
  123. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.d.ts.map +1 -0
  124. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.js +10 -0
  125. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/node/perf.js.map +1 -0
  126. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.d.ts +12 -0
  127. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.d.ts.map +1 -0
  128. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.js +10 -0
  129. package/node_modules/path-scurry/node_modules/lru-cache/dist/commonjs/perf.js.map +1 -0
  130. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel-browser.d.ts.map +1 -0
  131. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel-browser.js.map +1 -0
  132. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel.d.ts +5 -0
  133. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/diagnostics-channel.js +4 -0
  134. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.d.ts +1400 -0
  135. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.d.ts.map +1 -0
  136. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.js +1722 -0
  137. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.js.map +1 -0
  138. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.min.js +2 -0
  139. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/index.min.js.map +7 -0
  140. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.d.ts +12 -0
  141. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.d.ts.map +1 -0
  142. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.js +7 -0
  143. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/browser/perf.js.map +1 -0
  144. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel-esm.d.mts.map +1 -0
  145. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel-esm.mjs.map +1 -0
  146. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel.d.ts +5 -0
  147. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/diagnostics-channel.js +19 -0
  148. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.d.ts +109 -32
  149. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.d.ts.map +1 -1
  150. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.js +333 -196
  151. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.js.map +1 -1
  152. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.min.js +1 -1
  153. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/index.min.js.map +4 -4
  154. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel-node.d.ts.map +1 -0
  155. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel-node.js.map +1 -0
  156. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel.d.ts +5 -0
  157. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/diagnostics-channel.js +6 -0
  158. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.d.ts +1400 -0
  159. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.d.ts.map +1 -0
  160. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.js +1722 -0
  161. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.js.map +1 -0
  162. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.min.js +2 -0
  163. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/index.min.js.map +7 -0
  164. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.d.ts +12 -0
  165. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.d.ts.map +1 -0
  166. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.js +7 -0
  167. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/node/perf.js.map +1 -0
  168. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.d.ts +12 -0
  169. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.d.ts.map +1 -0
  170. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.js +7 -0
  171. package/node_modules/path-scurry/node_modules/lru-cache/dist/esm/perf.js.map +1 -0
  172. package/node_modules/path-scurry/node_modules/lru-cache/package.json +71 -18
  173. package/node_modules/path-scurry/package.json +8 -24
  174. package/package.json +1 -1
  175. package/scripts/debug-balance-probe.mjs +35 -35
  176. package/scripts/push-image.sh +43 -43
  177. package/scripts/setup-acr.sh +65 -65
  178. package/scripts/verify-optional-deps.js +96 -1
  179. package/src/__tests__/composioCliFlags.test.js +239 -239
  180. package/src/analyzers/CSSAnalyzer.js +298 -297
  181. package/src/analyzers/ConfigValidator.js +691 -690
  182. package/src/analyzers/ESLintAnalyzer.js +320 -320
  183. package/src/analyzers/JavaScriptAnalyzer.js +260 -261
  184. package/src/analyzers/PrettierFormatter.js +246 -247
  185. package/src/analyzers/PythonAnalyzer.js +283 -283
  186. package/src/analyzers/SecurityAnalyzer.js +729 -729
  187. package/src/analyzers/SparrowAnalyzer.js +341 -341
  188. package/src/analyzers/TypeScriptAnalyzer.js +247 -247
  189. package/src/analyzers/__tests__/CSSAnalyzer.test.js +41 -41
  190. package/src/analyzers/__tests__/ConfigValidator.test.js +362 -362
  191. package/src/analyzers/__tests__/JavaScriptAnalyzer.test.js +40 -40
  192. package/src/analyzers/__tests__/PythonAnalyzer.test.js +205 -208
  193. package/src/analyzers/__tests__/SecurityAnalyzer.test.js +303 -303
  194. package/src/analyzers/__tests__/TypeScriptAnalyzer.test.js +187 -187
  195. package/src/analyzers/codeCloneDetector/analyzer.js +344 -344
  196. package/src/analyzers/codeCloneDetector/detector.js +250 -250
  197. package/src/analyzers/codeCloneDetector/index.js +194 -192
  198. package/src/analyzers/codeCloneDetector/parser.js +199 -199
  199. package/src/core/__tests__/agentPool.test.js +866 -866
  200. package/src/core/__tests__/agentPoolAutoResume.test.js +209 -209
  201. package/src/core/__tests__/agentPoolWakeOnMessage.test.js +315 -315
  202. package/src/core/__tests__/agentScheduler.emptyResponseChatStall.test.js +213 -213
  203. package/src/core/__tests__/agentScheduler.errorCategorisation.test.js +246 -246
  204. package/src/core/__tests__/agentScheduler.firstChunkTimeout.test.js +138 -138
  205. package/src/core/__tests__/agentScheduler.modeTransitions.test.js +233 -233
  206. package/src/core/__tests__/agentScheduler.nativePromptPick.test.js +319 -319
  207. package/src/core/__tests__/agentScheduler.taskLifecycleInstruction.test.js +78 -78
  208. package/src/core/__tests__/agentScheduler.visualizer.test.js +258 -258
  209. package/src/core/__tests__/flowCheckpointStore.test.js +140 -140
  210. package/src/core/__tests__/flowEndToEnd.test.js +565 -565
  211. package/src/core/__tests__/flowFieldMapping.test.js +188 -189
  212. package/src/core/__tests__/flowLintClientMirror.test.js +96 -98
  213. package/src/core/__tests__/flowSavePayload.test.js +170 -169
  214. package/src/core/__tests__/flowTemplates.test.js +311 -311
  215. package/src/core/__tests__/flowVersionStore.test.js +123 -123
  216. package/src/core/__tests__/messageProcessor.test.js +669 -669
  217. package/src/core/__tests__/stateManager.test.js +0 -1
  218. package/src/core/agentPool.js +2474 -2475
  219. package/src/core/agentScheduler.js +1 -4
  220. package/src/core/contextManager.js +708 -708
  221. package/src/core/flowExecutor.js +1510 -1510
  222. package/src/core/flowFieldMapping.js +136 -138
  223. package/src/core/messageProcessor.js +953 -954
  224. package/src/core/orchestrator.js +593 -595
  225. package/src/core/stateManager.js +1765 -1752
  226. package/src/index.js +1221 -1221
  227. package/src/interfaces/__tests__/archivedAgentDelete.test.js +207 -207
  228. package/src/interfaces/__tests__/bulkAgentRoute.test.js +361 -361
  229. package/src/interfaces/__tests__/imageServing.test.js +228 -228
  230. package/src/interfaces/__tests__/remoteSessionAuth.test.js +308 -308
  231. package/src/interfaces/__tests__/videoJobsRoutes.test.js +178 -179
  232. package/src/interfaces/__tests__/webServer.marketplace.test.js +629 -629
  233. package/src/interfaces/schedulerRoutes.js +50 -50
  234. package/src/interfaces/terminal/__tests__/smoke/connection.test.js +341 -350
  235. package/src/interfaces/terminal/__tests__/smoke/enhancements.test.js +156 -156
  236. package/src/interfaces/terminal/__tests__/smoke/imports.test.js +325 -330
  237. package/src/interfaces/terminal/__tests__/smoke/tools.test.js +385 -388
  238. package/src/interfaces/terminal/api/session.js +265 -266
  239. package/src/interfaces/terminal/api/websocket.js +496 -497
  240. package/src/interfaces/terminal/components/AgentCreator.js +691 -705
  241. package/src/interfaces/terminal/components/AgentEditor.js +676 -678
  242. package/src/interfaces/terminal/components/AgentSwitcher.js +331 -330
  243. package/src/interfaces/terminal/components/ErrorPanel.js +263 -264
  244. package/src/interfaces/terminal/components/Header.js +28 -28
  245. package/src/interfaces/terminal/components/Layout.js +598 -603
  246. package/src/interfaces/terminal/components/MessageList.js +280 -281
  247. package/src/interfaces/terminal/components/SettingsPanel.js +410 -415
  248. package/src/interfaces/terminal/components/StatusBar.js +2 -0
  249. package/src/interfaces/terminal/index.js +168 -168
  250. package/src/interfaces/terminal/state/useAgentControl.js +496 -496
  251. package/src/interfaces/terminal/state/useAgents.js +537 -537
  252. package/src/interfaces/terminal/state/useMessages.js +629 -630
  253. package/src/interfaces/terminal/state/useTools.js +554 -554
  254. package/src/interfaces/terminal/utils/debugLogger.js +44 -44
  255. package/src/interfaces/terminal/utils/settingsStorage.js +232 -232
  256. package/src/interfaces/webServer.js +7578 -7579
  257. package/src/interfaces/webServer.js.bak +7046 -7046
  258. package/src/modules/fileExplorer/__tests__/zipDownload.test.js +237 -237
  259. package/src/modules/fileExplorer/controller.js +470 -469
  260. package/src/modules/fileExplorer/routes.js +285 -286
  261. package/src/modules/widget/__tests__/isDisabled.test.js +41 -41
  262. package/src/modules/widget/__tests__/routes.test.js +677 -678
  263. package/src/modules/widget/__tests__/runtime.test.js +401 -401
  264. package/src/modules/widget/__tests__/versioning.test.js +309 -309
  265. package/src/modules/widget/__tests__/webComponentRuntime.test.js +565 -565
  266. package/src/modules/widget/__tests__/widgetTool.test.js +316 -316
  267. package/src/modules/widget/routes.js +435 -435
  268. package/src/modules/widget/runtime/bundle.js +640 -640
  269. package/src/modules/widget/runtime/webComponentBundle.js +470 -470
  270. package/src/modules/widget/schema.js +182 -181
  271. package/src/modules/widget/widgetTool.js +1389 -1389
  272. package/src/services/__tests__/agentActivityService.test.js +401 -402
  273. package/src/services/__tests__/benchmarkService.test.js +184 -184
  274. package/src/services/__tests__/contextInjectionService.test.js +246 -246
  275. package/src/services/__tests__/conversationQuery.test.js +721 -723
  276. package/src/services/__tests__/credentialVault.test.js +469 -469
  277. package/src/services/__tests__/discordService.integration.test.js +638 -639
  278. package/src/services/__tests__/flowContextService.test.js +590 -590
  279. package/src/services/__tests__/memoryService.test.js +1 -1
  280. package/src/services/__tests__/messageSource.test.js +380 -380
  281. package/src/services/__tests__/modelRouterNaming.test.js +111 -111
  282. package/src/services/__tests__/projectDetector.test.js +34 -34
  283. package/src/services/__tests__/promptService.test.js +242 -242
  284. package/src/services/__tests__/telegramService.test.js +941 -941
  285. package/src/services/__tests__/tokenCountingService.test.js +48 -48
  286. package/src/services/agentActivityService.js +419 -420
  287. package/src/services/aiService.js +2997 -3001
  288. package/src/services/apiKeyManager.js +359 -359
  289. package/src/services/benchmarkService.js +196 -196
  290. package/src/services/codebaseKnowledgeService.js +2 -2
  291. package/src/services/composioService.js +738 -738
  292. package/src/services/conversationCompactionService.js +1258 -1257
  293. package/src/services/credentialVault.js +685 -685
  294. package/src/services/discordService.js +792 -793
  295. package/src/services/embeddings/__tests__/azureCustomProvider.test.js +232 -232
  296. package/src/services/embeddings/__tests__/embeddingService.test.js +417 -417
  297. package/src/services/embeddings/__tests__/localProvider.test.js +263 -263
  298. package/src/services/embeddings/autoRecall.js +218 -219
  299. package/src/services/embeddings/indexers/__tests__/agentIndexer.test.js +232 -232
  300. package/src/services/embeddings/indexers/__tests__/memoryIndexer.test.js +418 -418
  301. package/src/services/embeddings/indexers/__tests__/reminisceIndexer.test.js +356 -357
  302. package/src/services/embeddings/indexers/__tests__/skillsIndexer.test.js +145 -145
  303. package/src/services/embeddings/indexers/__tests__/taskIndexer.test.js +146 -146
  304. package/src/services/embeddings/indexers/composioIndexer.js +279 -279
  305. package/src/services/embeddings/providerInterface.js +206 -206
  306. package/src/services/embeddings/providers/localProvider.js +11 -7
  307. package/src/services/embeddings/providers/openaiProvider.js +101 -101
  308. package/src/services/embeddings/vectorStore/inMemoryJsonStore.js +356 -356
  309. package/src/services/errorHandler.js +809 -809
  310. package/src/services/flowContextService.js +586 -586
  311. package/src/services/grounding/MockAdapter.js +125 -125
  312. package/src/services/modelRouterService.js +26 -31
  313. package/src/services/modelsService.js +322 -322
  314. package/src/services/ollamaService.js +452 -452
  315. package/src/services/projectDetector.js +403 -404
  316. package/src/services/promptService.js +418 -418
  317. package/src/services/qualityInspector.js +795 -795
  318. package/src/services/scheduleService.js +726 -726
  319. package/src/services/serviceRegistry.js +386 -386
  320. package/src/services/telegrafBot.js +174 -174
  321. package/src/services/telegramService.js +1972 -1972
  322. package/src/services/visualEditorBridge.js +1033 -1033
  323. package/src/services/visualEditorServer.js +1769 -1774
  324. package/src/services/whatsappService.js +667 -668
  325. package/src/tools/__tests__/agentCommunicationTool.findAgent.test.js +226 -226
  326. package/src/tools/__tests__/agentCommunicationTool.test.js +3 -3
  327. package/src/tools/__tests__/agentDelayTool.test.js +342 -342
  328. package/src/tools/__tests__/baseTool.test.js +3 -3
  329. package/src/tools/__tests__/codeMapTool.test.js +915 -915
  330. package/src/tools/__tests__/fileContentReplaceTool.test.js +309 -309
  331. package/src/tools/__tests__/fileTreeTool.test.js +274 -274
  332. package/src/tools/__tests__/filesystemTool.test.js +815 -815
  333. package/src/tools/__tests__/foundryWebSearchTool.test.js +252 -252
  334. package/src/tools/__tests__/imageTool.validator.test.js +194 -194
  335. package/src/tools/__tests__/jobDoneTool.test.js +580 -581
  336. package/src/tools/__tests__/memoryTool.forgetStale.test.js +272 -272
  337. package/src/tools/__tests__/memoryTool.reminisce.test.js +2 -2
  338. package/src/tools/__tests__/memoryTool.reminisceSemanticSearch.test.js +301 -301
  339. package/src/tools/__tests__/memoryTool.semanticSearch.test.js +405 -405
  340. package/src/tools/__tests__/memoryTool.teamPool.test.js +293 -293
  341. package/src/tools/__tests__/memoryTool.test.js +1 -1
  342. package/src/tools/__tests__/seekTool.test.js +282 -282
  343. package/src/tools/__tests__/skillsTool.search.test.js +164 -164
  344. package/src/tools/__tests__/skillsTool.test.js +226 -226
  345. package/src/tools/__tests__/staticAnalysisTool.test.js +509 -509
  346. package/src/tools/__tests__/taskManagerTool.discipline.test.js +137 -137
  347. package/src/tools/__tests__/taskManagerTool.search.test.js +143 -143
  348. package/src/tools/__tests__/taskManagerTool.test.js +866 -866
  349. package/src/tools/__tests__/terminalTool.test.js +448 -448
  350. package/src/tools/__tests__/toolShapeForgiveness.test.js +259 -260
  351. package/src/tools/__tests__/userPromptTool.test.js +297 -297
  352. package/src/tools/__tests__/videoTool.jobs.test.js +147 -147
  353. package/src/tools/__tests__/webTool.e2e.test.js +609 -603
  354. package/src/tools/__tests__/webTool.unit.test.js +195 -195
  355. package/src/tools/__tests__/webTool.visionModel.test.js +75 -75
  356. package/src/tools/agentCommunicationTool.js +8 -10
  357. package/src/tools/agentDelayTool.js +496 -497
  358. package/src/tools/asyncToolManager.js +602 -603
  359. package/src/tools/baseTool.js +12 -11
  360. package/src/tools/cloneDetectionTool.js +576 -581
  361. package/src/tools/codeMapTool.js +0 -6
  362. package/src/tools/composioTool.js +617 -617
  363. package/src/tools/dependencyResolverTool.js +1211 -1212
  364. package/src/tools/desktop/DesktopTool.js +629 -638
  365. package/src/tools/desktop/__tests__/DesktopTool.e2e.test.js +306 -306
  366. package/src/tools/desktop/__tests__/DesktopTool.test.js +507 -507
  367. package/src/tools/desktop/__tests__/osController.test.js +364 -364
  368. package/src/tools/desktop/osController.js +491 -491
  369. package/src/tools/docxTool.js +623 -623
  370. package/src/tools/excelTool.js +636 -636
  371. package/src/tools/fileContentReplaceTool.js +5 -7
  372. package/src/tools/fileSystemTool.js +12 -19
  373. package/src/tools/fileTreeTool.js +840 -840
  374. package/src/tools/foundryWebSearchTool.js +273 -273
  375. package/src/tools/helpTool.js +198 -198
  376. package/src/tools/imageTool.js +1397 -1397
  377. package/src/tools/importAnalyzerTool.js +1056 -1056
  378. package/src/tools/jobDoneTool.js +495 -495
  379. package/src/tools/memoryTool.js +1 -1
  380. package/src/tools/office/pres/__tests__/presSystem.test.js +365 -365
  381. package/src/tools/office/pres/archetypes/agenda.js +61 -61
  382. package/src/tools/office/pres/archetypes/bentoGrid.js +218 -219
  383. package/src/tools/office/pres/archetypes/bigStat.js +140 -142
  384. package/src/tools/office/pres/archetypes/closing.js +70 -70
  385. package/src/tools/office/pres/archetypes/hero.js +70 -70
  386. package/src/tools/office/pres/archetypes/productHero.js +93 -94
  387. package/src/tools/office/pres/archetypes/table.js +73 -74
  388. package/src/tools/office/pres/backgrounds/orb.js +66 -66
  389. package/src/tools/office/pres/components.js +422 -423
  390. package/src/tools/officeTool.js +441 -441
  391. package/src/tools/pdfTool.js +625 -627
  392. package/src/tools/platformControlTool.js +1081 -1081
  393. package/src/tools/seekTool.js +917 -918
  394. package/src/tools/skillsTool.js +1 -1
  395. package/src/tools/staticAnalysisTool.js +2143 -2146
  396. package/src/tools/taskManagerTool.js +3324 -3324
  397. package/src/tools/terminalTool.js +2615 -2618
  398. package/src/tools/videoTool.js +1303 -1303
  399. package/src/tools/visionTool.js +508 -508
  400. package/src/tools/visualEditorTool.js +1289 -1290
  401. package/src/tools/webTool.js +3368 -3368
  402. package/src/tools/whatsappTool.js +464 -464
  403. package/src/types/__tests__/agent.test.js +499 -499
  404. package/src/types/__tests__/contextReference.test.js +606 -606
  405. package/src/types/__tests__/conversation.test.js +555 -555
  406. package/src/types/__tests__/toolCommand.test.js +584 -584
  407. package/src/types/contextReference.js +974 -971
  408. package/src/types/conversation.js +729 -729
  409. package/src/types/toolCommand.js +746 -746
  410. package/src/utilities/__tests__/attachmentValidator.test.js +80 -80
  411. package/src/utilities/__tests__/auditReport.test.js +328 -328
  412. package/src/utilities/__tests__/directoryAccessManager.test.js +388 -388
  413. package/src/utilities/__tests__/jsonRepair.test.js +103 -104
  414. package/src/utilities/__tests__/modeTransitionReasons.test.js +105 -105
  415. package/src/utilities/__tests__/platformUtils.test.js +80 -87
  416. package/src/utilities/__tests__/structuredFileValidator.test.js +261 -263
  417. package/src/utilities/__tests__/toolConstants.test.js +92 -94
  418. package/src/utilities/__tests__/useIsTouchDevice.detect.test.js +114 -114
  419. package/src/utilities/__tests__/webUiUtilSync.test.js +117 -117
  420. package/src/utilities/attachmentValidator.js +284 -288
  421. package/src/utilities/authCache.js.backup-1779570472481 +121 -121
  422. package/src/utilities/browserStealth.js +631 -630
  423. package/src/utilities/configManager.js +616 -617
  424. package/src/utilities/directoryAccessManager.js +564 -565
  425. package/src/utilities/fileProcessor.js +308 -307
  426. package/src/utilities/humanBehavior.js +454 -453
  427. package/src/utilities/logger.js +479 -479
  428. package/src/utilities/structuredFileValidator.js +696 -699
  429. package/src/utilities/tagParser.js +5 -10
  430. package/src/utilities/userDataDir.js +308 -308
  431. package/node_modules/@isaacs/brace-expansion/dist/commonjs/index.js.map +0 -1
  432. package/node_modules/@isaacs/brace-expansion/dist/esm/index.js.map +0 -1
  433. package/node_modules/minipass/LICENSE +0 -15
  434. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/LICENSE.md +0 -0
  435. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.d.ts +0 -0
  436. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.d.ts.map +0 -0
  437. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.js +0 -0
  438. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/index.js.map +0 -0
  439. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/commonjs/package.json +0 -0
  440. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.d.ts +0 -0
  441. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.d.ts.map +0 -0
  442. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.js +0 -0
  443. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/index.js.map +0 -0
  444. /package/node_modules/{@isaacs → glob/node_modules}/balanced-match/dist/esm/package.json +0 -0
  445. /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/LICENSE +0 -0
  446. /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/index.d.ts +0 -0
  447. /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/index.d.ts.map +0 -0
  448. /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/commonjs/package.json +0 -0
  449. /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/index.d.ts +0 -0
  450. /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/index.d.ts.map +0 -0
  451. /package/node_modules/{@isaacs → glob/node_modules}/brace-expansion/dist/esm/package.json +0 -0
@@ -1,941 +1,941 @@
1
- /**
2
- * Tests for the rewritten TelegramService.
3
- *
4
- * Covers the user-experience-critical surfaces:
5
- * • Multi-chat /start (no global lock)
6
- * • Group chat @mention gating
7
- * • Voice → backend transcribe → text dispatch
8
- * • Thinking placeholder edit-on-completion
9
- * • <actions> / <image> / <attachment> block dispatch
10
- * • Always-relay error broadcasts vs watch-gated completions
11
- * • /reset, /new, /logout commands
12
- * • Pagination of /agents
13
- * • Markdown vs HTML vs document selection by content shape
14
- * • Config migration (legacy `chatId` scalar)
15
- *
16
- * The mock TelegramBot records every call and exposes them via
17
- * `bot.calls.<method>` arrays. node-telegram-bot-api is fully mocked
18
- * so no network and no dependency on the real bot library.
19
- */
20
-
21
- import { jest, describe, test, expect, beforeEach } from '@jest/globals';
22
-
23
- // ─── Module mocks ─────────────────────────────────────────────────────
24
-
25
- // In-memory virtual fs for the config file. Each beforeEach resets it.
26
- const virtualFs = { content: null };
27
-
28
- jest.unstable_mockModule('fs', () => ({
29
- promises: {
30
- mkdir: jest.fn().mockResolvedValue(undefined),
31
- readFile: jest.fn().mockImplementation(async () => {
32
- if (virtualFs.content === null) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
33
- return virtualFs.content;
34
- }),
35
- writeFile: jest.fn().mockImplementation(async (_p, data) => {
36
- virtualFs.content = data;
37
- }),
38
- },
39
- }));
40
-
41
- jest.unstable_mockModule('../../utilities/userDataDir.js', () => ({
42
- getUserDataPaths: () => ({ base: '/mock/data' }),
43
- ensureUserDataDirs: jest.fn().mockResolvedValue(undefined),
44
- }));
45
-
46
- // MockBot — a deterministic stand-in for node-telegram-bot-api. Records
47
- // every call, exposes hooks so tests can simulate incoming messages.
48
- class MockBot {
49
- constructor() {
50
- this._textHandlers = []; // [{regex, fn}]
51
- this._messageHandlers = [];
52
- this._callbackHandlers = [];
53
- this.calls = {
54
- sendMessage: [], sendPhoto: [], sendDocument: [],
55
- sendChatAction: [], editMessageText: [], deleteMessage: [],
56
- answerCallbackQuery: [],
57
- };
58
- this.nextMessageId = 100;
59
- this.stopPolling = jest.fn().mockResolvedValue(undefined);
60
- }
61
- async getMe() { return { username: 'loxia_bot', id: 1 }; }
62
- onText(regex, fn) { this._textHandlers.push({ regex, fn }); }
63
- on(event, fn) {
64
- if (event === 'message') this._messageHandlers.push(fn);
65
- else if (event === 'callback_query') this._callbackHandlers.push(fn);
66
- }
67
- async sendMessage(chatId, text, opts) {
68
- const id = this.nextMessageId++;
69
- this.calls.sendMessage.push({ chatId, text, opts, message_id: id });
70
- return { message_id: id, chat: { id: chatId } };
71
- }
72
- async editMessageText(text, opts) { this.calls.editMessageText.push({ text, opts }); return { ok: true }; }
73
- async deleteMessage(chatId, message_id) { this.calls.deleteMessage.push({ chatId, message_id }); }
74
- async sendChatAction(chatId, action) { this.calls.sendChatAction.push({ chatId, action }); }
75
- async sendPhoto(chatId, photo, opts) { this.calls.sendPhoto.push({ chatId, photo, opts }); }
76
- async sendDocument(chatId, doc, opts) { this.calls.sendDocument.push({ chatId, doc, opts }); }
77
- async answerCallbackQuery(id) { this.calls.answerCallbackQuery.push(id); }
78
- async getFileLink(fileId) { return `https://t.example/file/${fileId}`; }
79
-
80
- // Test helpers: simulate an incoming message / callback.
81
- async simulateMessage(msg) {
82
- // Telegram fires onText first when text matches a regex, then `message` listeners.
83
- if (typeof msg.text === 'string') {
84
- for (const { regex, fn } of this._textHandlers) {
85
- const m = msg.text.match(regex);
86
- if (m) await fn(msg, m);
87
- }
88
- }
89
- for (const fn of this._messageHandlers) await fn(msg);
90
- }
91
- async simulateCallback(query) {
92
- for (const fn of this._callbackHandlers) await fn(query);
93
- }
94
- }
95
-
96
- let currentBot;
97
- // We migrated off node-telegram-bot-api to telegraf via a thin compat
98
- // wrapper at src/services/telegrafBot.js. The MockBot above implements
99
- // exactly the wrapper's surface (sendMessage, editMessageText,
100
- // deleteMessage, sendChatAction, sendPhoto, sendDocument,
101
- // answerCallbackQuery, getFileLink, getMe, stopPolling, onText, on) so
102
- // retargeting the mock to the wrapper module is the entire test-side
103
- // change. telegramService.js calls `createTelegrafBot(token, opts)` —
104
- // match that shape (async factory returning the wrapper).
105
- jest.unstable_mockModule('../telegrafBot.js', () => ({
106
- createTelegrafBot: jest.fn(async () => {
107
- currentBot = new MockBot();
108
- return currentBot;
109
- }),
110
- default: jest.fn(async () => {
111
- currentBot = new MockBot();
112
- return currentBot;
113
- }),
114
- }));
115
-
116
- // ─── Imports after mocks ──────────────────────────────────────────────
117
-
118
- const { TelegramService, TELEGRAM_STATUS, _resetTelegramSingletonForTests } = await import('../telegramService.js');
119
-
120
- // ─── Test helpers ─────────────────────────────────────────────────────
121
-
122
- const SAMPLE_AGENTS = [
123
- { id: 'a1', name: 'Cody', mode: 'chat', status: 'idle', currentModel: 'gpt-4.1' },
124
- { id: 'a2', name: 'Lina', mode: 'auto', status: 'active', currentModel: 'claude-sonnet-4-5' },
125
- ];
126
-
127
- function makeService({ agents = SAMPLE_AGENTS, withOrchestrator = true } = {}) {
128
- const svc = new TelegramService(null);
129
- svc.setAgentPool({
130
- getAllAgents: jest.fn().mockResolvedValue(agents),
131
- getAgent: jest.fn((id) => Promise.resolve(agents.find(a => a.id === id) || null)),
132
- });
133
- if (withOrchestrator) {
134
- svc.setOrchestrator({
135
- config: { project: { directory: '/proj' } },
136
- processRequest: jest.fn().mockResolvedValue(undefined),
137
- messageProcessor: {
138
- stopAutonomousExecution: jest.fn().mockResolvedValue(undefined),
139
- resetAgentConversation: jest.fn().mockResolvedValue(undefined),
140
- },
141
- stateManager: {
142
- loadFlowIndex: jest.fn().mockResolvedValue({}),
143
- },
144
- });
145
- }
146
- return svc;
147
- }
148
-
149
- function userMsg(chatId, text, { chatType = 'private', from = { username: 'alice' }, title } = {}) {
150
- return {
151
- chat: { id: chatId, type: chatType, title },
152
- from,
153
- text,
154
- message_id: 1,
155
- };
156
- }
157
-
158
- beforeEach(() => {
159
- virtualFs.content = null;
160
- _resetTelegramSingletonForTests();
161
- currentBot = null;
162
- });
163
-
164
- async function connected(svc) {
165
- await svc.connect('TEST_TOKEN');
166
- return currentBot;
167
- }
168
-
169
- // ─────────────────────────────────────────────────────────────────────
170
- // @-prefix resolution (names with spaces, slug form, ambiguity)
171
- // ─────────────────────────────────────────────────────────────────────
172
-
173
- describe('_resolveAtPrefix — @agent-name parsing', () => {
174
- const agents = [
175
- { id: 'a1', name: 'Cody' },
176
- { id: 'a2', name: 'John Smith' },
177
- { id: 'a3', name: 'John' }, // prefix-conflict with #a2
178
- { id: 'a4', name: 'Multi Word Long Name' },
179
- ];
180
-
181
- test('no @prefix → agentName null, messageText unchanged', () => {
182
- const svc = makeService({ agents });
183
- const r = svc._resolveAtPrefix('just a message', agents);
184
- expect(r.agentName).toBeNull();
185
- expect(r.matchedAgent).toBeNull();
186
- expect(r.messageText).toBe('just a message');
187
- });
188
-
189
- test('single-word name (legacy path)', () => {
190
- const svc = makeService({ agents });
191
- const r = svc._resolveAtPrefix('@Cody hello', agents);
192
- expect(r.matchedAgent?.id).toBe('a1');
193
- expect(r.messageText).toBe('hello');
194
- });
195
-
196
- test('multi-word name with literal spaces works', () => {
197
- const svc = makeService({ agents });
198
- const r = svc._resolveAtPrefix('@John Smith write the report', agents);
199
- expect(r.matchedAgent?.id).toBe('a2');
200
- expect(r.agentName).toBe('John Smith');
201
- expect(r.messageText).toBe('write the report');
202
- });
203
-
204
- test('slug form @John_Smith resolves to "John Smith"', () => {
205
- const svc = makeService({ agents });
206
- const r = svc._resolveAtPrefix('@John_Smith write the report', agents);
207
- expect(r.matchedAgent?.id).toBe('a2');
208
- expect(r.messageText).toBe('write the report');
209
- });
210
-
211
- test('dash form @John-Smith also works', () => {
212
- const svc = makeService({ agents });
213
- const r = svc._resolveAtPrefix('@John-Smith hi', agents);
214
- expect(r.matchedAgent?.id).toBe('a2');
215
- expect(r.messageText).toBe('hi');
216
- });
217
-
218
- test('longest-match wins when one name is a prefix of another', () => {
219
- // "@John Smith hello" must NOT resolve to the "John" agent.
220
- const svc = makeService({ agents });
221
- const r = svc._resolveAtPrefix('@John Smith hello', agents);
222
- expect(r.matchedAgent?.id).toBe('a2');
223
- });
224
-
225
- test('shorter name still wins when the longer name doesn\'t match', () => {
226
- const svc = makeService({ agents });
227
- const r = svc._resolveAtPrefix('@John hello', agents);
228
- expect(r.matchedAgent?.id).toBe('a3');
229
- });
230
-
231
- test('case-insensitive match', () => {
232
- const svc = makeService({ agents });
233
- const r = svc._resolveAtPrefix('@JOHN SMITH hi', agents);
234
- expect(r.matchedAgent?.id).toBe('a2');
235
- });
236
-
237
- test('extra whitespace between the name and the message is collapsed', () => {
238
- const svc = makeService({ agents });
239
- const r = svc._resolveAtPrefix('@Cody \t hello', agents);
240
- expect(r.messageText).toBe('hello');
241
- });
242
-
243
- test('partial-word collision does NOT false-match (Johnson should not match John)', () => {
244
- const svc = makeService({ agents });
245
- const r = svc._resolveAtPrefix('@Johnson hi', agents);
246
- // Falls back to legacy capture so caller can show "not found"
247
- expect(r.matchedAgent).toBeNull();
248
- expect(r.agentName).toBe('Johnson');
249
- });
250
-
251
- test('unknown @name → legacy capture for "not found" error', () => {
252
- const svc = makeService({ agents });
253
- const r = svc._resolveAtPrefix('@Stranger hi', agents);
254
- expect(r.matchedAgent).toBeNull();
255
- expect(r.agentName).toBe('Stranger');
256
- expect(r.messageText).toBe('hi');
257
- });
258
-
259
- test('handles regex-sensitive characters in agent names safely', () => {
260
- const dangerous = [{ id: 'a1', name: 'C++.parser' }];
261
- const svc = makeService({ agents: dangerous });
262
- const r = svc._resolveAtPrefix('@C++.parser go', dangerous);
263
- expect(r.matchedAgent?.id).toBe('a1');
264
- expect(r.messageText).toBe('go');
265
- });
266
-
267
- test('many-word names ("Multi Word Long Name") match end-to-end', () => {
268
- const svc = makeService({ agents });
269
- const r = svc._resolveAtPrefix('@Multi Word Long Name run the audit', agents);
270
- expect(r.matchedAgent?.id).toBe('a4');
271
- expect(r.messageText).toBe('run the audit');
272
- });
273
- });
274
-
275
- // ─────────────────────────────────────────────────────────────────────
276
- // Multi-chat /start
277
- // ─────────────────────────────────────────────────────────────────────
278
-
279
- describe('multi-chat /start', () => {
280
- test('first chat /start registers; second chat /start ALSO registers (no global lock)', async () => {
281
- const svc = makeService();
282
- const bot = await connected(svc);
283
- await bot.simulateMessage(userMsg(111, '/start'));
284
- await bot.simulateMessage(userMsg(222, '/start'));
285
- expect(svc.chats.has('111')).toBe(true);
286
- expect(svc.chats.has('222')).toBe(true);
287
- expect(svc.getStatus().chatCount).toBe(2);
288
- });
289
-
290
- test('same chat /start twice does NOT duplicate state', async () => {
291
- const svc = makeService();
292
- const bot = await connected(svc);
293
- await bot.simulateMessage(userMsg(111, '/start'));
294
- await bot.simulateMessage(userMsg(111, '/start'));
295
- expect(svc.chats.size).toBe(1);
296
- // Second invocation should reply "Already connected".
297
- const greetings = bot.calls.sendMessage.filter(c => String(c.chatId) === '111');
298
- expect(greetings.length).toBeGreaterThanOrEqual(2);
299
- expect(greetings[1].text).toMatch(/Already connected/);
300
- });
301
- });
302
-
303
- // ─────────────────────────────────────────────────────────────────────
304
- // /logout, /reset, /new
305
- // ─────────────────────────────────────────────────────────────────────
306
-
307
- describe('/logout', () => {
308
- test('removes chat from registered set', async () => {
309
- const svc = makeService();
310
- const bot = await connected(svc);
311
- await bot.simulateMessage(userMsg(111, '/start'));
312
- await bot.simulateMessage(userMsg(111, '/logout'));
313
- expect(svc.chats.has('111')).toBe(false);
314
- });
315
- });
316
-
317
- describe('/reset', () => {
318
- test('calls resetAgentConversation on the sticky agent', async () => {
319
- const svc = makeService();
320
- const bot = await connected(svc);
321
- await bot.simulateMessage(userMsg(111, '/start'));
322
- svc.chats.get('111').lastAgentId = 'a1';
323
- svc.chats.get('111').activeAgentIds.add('a1');
324
-
325
- await bot.simulateMessage(userMsg(111, '/reset'));
326
- expect(svc.orchestrator.messageProcessor.resetAgentConversation).toHaveBeenCalledWith('a1');
327
- });
328
-
329
- test('returns friendly message when no sticky agent is set', async () => {
330
- const svc = makeService();
331
- const bot = await connected(svc);
332
- await bot.simulateMessage(userMsg(111, '/start'));
333
- bot.calls.sendMessage = [];
334
- await bot.simulateMessage(userMsg(111, '/reset'));
335
- const last = bot.calls.sendMessage[bot.calls.sendMessage.length - 1];
336
- expect(last.text).toMatch(/No active agent/);
337
- expect(svc.orchestrator.messageProcessor.resetAgentConversation).not.toHaveBeenCalled();
338
- });
339
- });
340
-
341
- describe('/new <agent>', () => {
342
- test('switches sticky agent + resets the new one', async () => {
343
- const svc = makeService();
344
- const bot = await connected(svc);
345
- await bot.simulateMessage(userMsg(111, '/start'));
346
- await bot.simulateMessage(userMsg(111, '/new Lina'));
347
- expect(svc.chats.get('111').lastAgentId).toBe('a2');
348
- expect(svc.orchestrator.messageProcessor.resetAgentConversation).toHaveBeenCalledWith('a2');
349
- });
350
-
351
- test('unknown agent returns error', async () => {
352
- const svc = makeService();
353
- const bot = await connected(svc);
354
- await bot.simulateMessage(userMsg(111, '/start'));
355
- bot.calls.sendMessage = [];
356
- await bot.simulateMessage(userMsg(111, '/new Nobody'));
357
- expect(bot.calls.sendMessage[0].text).toMatch(/not found/);
358
- });
359
- });
360
-
361
- // ─────────────────────────────────────────────────────────────────────
362
- // End-to-end @-prefix with spaced name
363
- // ─────────────────────────────────────────────────────────────────────
364
-
365
- describe('@-prefix dispatch — name with spaces', () => {
366
- test('"@John Smith write the report" dispatches to the right agent with the right message', async () => {
367
- const agents = [
368
- { id: 'js-1', name: 'John Smith', mode: 'chat', status: 'idle', currentModel: 'gpt-4.1' },
369
- { id: 'cody', name: 'Cody', mode: 'chat', status: 'idle', currentModel: 'gpt-4.1' },
370
- ];
371
- const svc = makeService({ agents });
372
- const bot = await connected(svc);
373
- await bot.simulateMessage(userMsg(111, '/start'));
374
-
375
- await bot.simulateMessage(userMsg(111, '@John Smith write the report'));
376
-
377
- expect(svc.orchestrator.processRequest).toHaveBeenCalled();
378
- const payload = svc.orchestrator.processRequest.mock.calls.slice(-1)[0][0].payload;
379
- expect(payload.agentId).toBe('js-1');
380
- expect(payload.message).toBe('write the report');
381
- });
382
-
383
- test('unknown @name surfaces a helpful "not found" reply, no orchestrator call', async () => {
384
- const svc = makeService();
385
- const bot = await connected(svc);
386
- await bot.simulateMessage(userMsg(111, '/start'));
387
- bot.calls.sendMessage = [];
388
-
389
- await bot.simulateMessage(userMsg(111, '@Nobody hello'));
390
-
391
- expect(svc.orchestrator.processRequest).not.toHaveBeenCalled();
392
- const reply = bot.calls.sendMessage[0];
393
- expect(reply.text).toMatch(/not found/);
394
- // The friendly tip explaining slug syntax must be present so users
395
- // who typed an underscore version learn the syntax. Underscores
396
- // are MarkdownV2-escaped on the wire (`John\_Smith`), so allow
397
- // any single character between the two halves.
398
- expect(reply.text).toMatch(/John.Smith/);
399
- });
400
- });
401
-
402
- // ─────────────────────────────────────────────────────────────────────
403
- // Group chat @mention gating
404
- // ─────────────────────────────────────────────────────────────────────
405
-
406
- describe('group chat — @mention required', () => {
407
- test('plain message in group is IGNORED (no orchestrator call)', async () => {
408
- const svc = makeService();
409
- const bot = await connected(svc);
410
- await bot.simulateMessage(userMsg(-100, '/start', { chatType: 'supergroup', title: 'Ops' }));
411
- svc.chats.get('-100').lastAgentId = 'a1';
412
- svc.chats.get('-100').activeAgentIds.add('a1');
413
-
414
- await bot.simulateMessage(userMsg(-100, 'hello team', { chatType: 'supergroup' }));
415
- expect(svc.orchestrator.processRequest).not.toHaveBeenCalled();
416
- });
417
-
418
- test('@bot message in group strips mention and dispatches', async () => {
419
- const svc = makeService();
420
- const bot = await connected(svc);
421
- await bot.simulateMessage(userMsg(-100, '/start', { chatType: 'supergroup', title: 'Ops' }));
422
- svc.chats.get('-100').lastAgentId = 'a1';
423
- svc.chats.get('-100').activeAgentIds.add('a1');
424
-
425
- await bot.simulateMessage(userMsg(-100, '@loxia_bot summarize last meeting', { chatType: 'supergroup' }));
426
- expect(svc.orchestrator.processRequest).toHaveBeenCalled();
427
- const payload = svc.orchestrator.processRequest.mock.calls[0][0].payload;
428
- expect(payload.message).toBe('summarize last meeting');
429
- });
430
-
431
- test('private chat does NOT require @mention', async () => {
432
- const svc = makeService();
433
- const bot = await connected(svc);
434
- await bot.simulateMessage(userMsg(111, '/start'));
435
- svc.chats.get('111').lastAgentId = 'a1';
436
- svc.chats.get('111').activeAgentIds.add('a1');
437
-
438
- await bot.simulateMessage(userMsg(111, 'hi there'));
439
- expect(svc.orchestrator.processRequest).toHaveBeenCalled();
440
- });
441
- });
442
-
443
- // ─────────────────────────────────────────────────────────────────────
444
- // Thinking placeholder & edit
445
- // ─────────────────────────────────────────────────────────────────────
446
-
447
- describe('thinking placeholder', () => {
448
- test('typing action + placeholder sent before orchestrator call', async () => {
449
- const svc = makeService();
450
- const bot = await connected(svc);
451
- await bot.simulateMessage(userMsg(111, '/start'));
452
- await bot.simulateMessage(userMsg(111, '@Cody help me'));
453
-
454
- expect(bot.calls.sendChatAction).toContainEqual({ chatId: 111, action: 'typing' });
455
- const thinking = bot.calls.sendMessage.find(c => c.text.includes('thinking'));
456
- expect(thinking).toBeDefined();
457
- expect(thinking.chatId).toBe(111);
458
- });
459
-
460
- test('placeholder is replaced (edit) when agent emits stream_complete', async () => {
461
- const svc = makeService();
462
- const bot = await connected(svc);
463
- await bot.simulateMessage(userMsg(111, '/start'));
464
- await bot.simulateMessage(userMsg(111, '@Cody hello'));
465
-
466
- // Simulate broadcast.
467
- await svc._handleBroadcastEvent({
468
- type: 'stream_complete',
469
- agentId: 'a1',
470
- content: '<external>Sure — here you go.</external>',
471
- role: 'assistant',
472
- });
473
-
474
- expect(bot.calls.editMessageText).toHaveLength(1);
475
- expect(bot.calls.editMessageText[0].text).toMatch(/Cody/);
476
- expect(bot.calls.editMessageText[0].text).toMatch(/Sure/);
477
- });
478
- });
479
-
480
- // ─────────────────────────────────────────────────────────────────────
481
- // <actions> / <image> / <attachment> blocks
482
- // ─────────────────────────────────────────────────────────────────────
483
-
484
- describe('rich reply blocks', () => {
485
- test('<actions> block becomes inline keyboard with act:agentId:value callbacks', async () => {
486
- const svc = makeService();
487
- const bot = await connected(svc);
488
- await bot.simulateMessage(userMsg(111, '/start'));
489
- await bot.simulateMessage(userMsg(111, '@Cody what now'));
490
-
491
- await svc._handleBroadcastEvent({
492
- type: 'stream_complete', agentId: 'a1', role: 'assistant',
493
- content:
494
- '<external>Pick one:\n' +
495
- '<actions>[{"label":"Continue","value":"go"},{"label":"Restart","value":"reset"}]</actions>' +
496
- '</external>',
497
- });
498
-
499
- expect(bot.calls.editMessageText).toHaveLength(1);
500
- const opts = bot.calls.editMessageText[0].opts;
501
- const kb = opts.reply_markup.inline_keyboard;
502
- expect(kb.map(row => row[0].text)).toEqual(['Continue', 'Restart']);
503
- expect(kb[0][0].callback_data).toBe('act:a1:go');
504
- expect(kb[1][0].callback_data).toBe('act:a1:reset');
505
- });
506
-
507
- test('button tap dispatches a synthetic agent message', async () => {
508
- const svc = makeService();
509
- const bot = await connected(svc);
510
- await bot.simulateMessage(userMsg(111, '/start'));
511
- svc.chats.get('111').activeAgentIds.add('a1');
512
- svc.chats.get('111').lastAgentId = 'a1';
513
-
514
- await bot.simulateCallback({
515
- id: 'cb-1',
516
- data: 'act:a1:keep going',
517
- message: { chat: { id: 111, type: 'private' }, message_id: 999 },
518
- from: { username: 'alice' },
519
- });
520
- expect(svc.orchestrator.processRequest).toHaveBeenCalled();
521
- const payload = svc.orchestrator.processRequest.mock.calls.slice(-1)[0][0].payload;
522
- expect(payload.message).toBe('keep going');
523
- });
524
-
525
- test('<image> block triggers sendPhoto', async () => {
526
- const svc = makeService();
527
- const bot = await connected(svc);
528
- await bot.simulateMessage(userMsg(111, '/start'));
529
- await bot.simulateMessage(userMsg(111, '@Cody chart'));
530
-
531
- await svc._handleBroadcastEvent({
532
- type: 'stream_complete', agentId: 'a1', role: 'assistant',
533
- content: '<external>Here it is:\n<image src="https://example.com/chart.png">Q4 chart</image></external>',
534
- });
535
- expect(bot.calls.sendPhoto).toHaveLength(1);
536
- expect(String(bot.calls.sendPhoto[0].chatId)).toBe('111');
537
- expect(bot.calls.sendPhoto[0].photo).toBe('https://example.com/chart.png');
538
- });
539
-
540
- test('<attachment> block triggers sendDocument', async () => {
541
- const svc = makeService();
542
- const bot = await connected(svc);
543
- await bot.simulateMessage(userMsg(111, '/start'));
544
- await bot.simulateMessage(userMsg(111, '@Cody report'));
545
-
546
- await svc._handleBroadcastEvent({
547
- type: 'stream_complete', agentId: 'a1', role: 'assistant',
548
- content: '<external>See report:\n<attachment src="/tmp/r.pdf" type="application/pdf">Report</attachment></external>',
549
- });
550
- expect(bot.calls.sendDocument).toHaveLength(1);
551
- expect(String(bot.calls.sendDocument[0].chatId)).toBe('111');
552
- expect(bot.calls.sendDocument[0].doc).toBe('/tmp/r.pdf');
553
- });
554
- });
555
-
556
- // ─────────────────────────────────────────────────────────────────────
557
- // Always-relay errors / watch-gated completions
558
- // ─────────────────────────────────────────────────────────────────────
559
-
560
- describe('error fan-out vs watch-gating', () => {
561
- test('agent_error relays to a chat that has NOT subscribed via /watch', async () => {
562
- const svc = makeService();
563
- const bot = await connected(svc);
564
- await bot.simulateMessage(userMsg(111, '/start'));
565
- expect(svc.chats.get('111').watchEnabled).toBe(false);
566
-
567
- await svc._handleBroadcastEvent({
568
- type: 'agent_error', agentId: 'a1', agentName: 'Cody', message: 'boom',
569
- });
570
- const sent = bot.calls.sendMessage.find(c => c.text.includes('Agent Error'));
571
- expect(sent).toBeDefined();
572
- });
573
-
574
- test('agent_error dedup: identical errors within the window only send once', async () => {
575
- const svc = makeService();
576
- const bot = await connected(svc);
577
- await bot.simulateMessage(userMsg(111, '/start'));
578
-
579
- const payload = { type: 'agent_error', agentId: 'a1', agentName: 'Cody', message: 'boom' };
580
- await svc._handleBroadcastEvent(payload);
581
- await svc._handleBroadcastEvent(payload);
582
- await svc._handleBroadcastEvent(payload);
583
- await svc._handleBroadcastEvent(payload);
584
-
585
- const errorMessages = bot.calls.sendMessage.filter(c => c.text.includes('Agent Error'));
586
- // Only the first one should have made it through; the next three are
587
- // counted but suppressed.
588
- expect(errorMessages).toHaveLength(1);
589
- // Dedup state confirms three were absorbed.
590
- const entry = [...svc._errorBroadcastDedup.values()][0];
591
- expect(entry?.count).toBe(4);
592
- });
593
-
594
- test('agent_error dedup: different agents do NOT collide', async () => {
595
- const svc = makeService();
596
- const bot = await connected(svc);
597
- await bot.simulateMessage(userMsg(111, '/start'));
598
-
599
- await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', agentName: 'Cody', message: 'same' });
600
- await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a2', agentName: 'Carol', message: 'same' });
601
-
602
- const errorMessages = bot.calls.sendMessage.filter(c => c.text.includes('Agent Error'));
603
- expect(errorMessages).toHaveLength(2);
604
- });
605
-
606
- test('agent_error dedup: textual variations of same error coalesce', async () => {
607
- const svc = makeService();
608
- const bot = await connected(svc);
609
- await bot.simulateMessage(userMsg(111, '/start'));
610
-
611
- // Same error, formatted slightly differently — should dedup.
612
- await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', message: 'Connection refused' });
613
- await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', message: 'connection refused' });
614
- await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', message: ' Connection refused ' });
615
-
616
- const errorMessages = bot.calls.sendMessage.filter(c => c.text.includes('Agent Error'));
617
- expect(errorMessages).toHaveLength(1);
618
- });
619
-
620
- test('agent_error dedup: window close emits a "still happening" summary when count > 1', async () => {
621
- jest.useFakeTimers();
622
- try {
623
- const svc = makeService();
624
- const bot = await connected(svc);
625
- await bot.simulateMessage(userMsg(111, '/start'));
626
-
627
- // First fires immediately, plus two suppressed.
628
- await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', agentName: 'Cody', message: 'boom' });
629
- await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', agentName: 'Cody', message: 'boom' });
630
- await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', agentName: 'Cody', message: 'boom' });
631
-
632
- // Advance past the 5-minute dedup window.
633
- jest.advanceTimersByTime(5 * 60 * 1000 + 100);
634
- // Let the timer callback's microtasks resolve.
635
- await Promise.resolve();
636
- await Promise.resolve();
637
-
638
- const summary = bot.calls.sendMessage.find(c => c.text.includes('still happening'));
639
- expect(summary).toBeDefined();
640
- expect(summary.text).toMatch(/2 more/);
641
- } finally {
642
- jest.useRealTimers();
643
- }
644
- });
645
-
646
- test('agent_error dedup: a single error does NOT trigger a summary', async () => {
647
- jest.useFakeTimers();
648
- try {
649
- const svc = makeService();
650
- const bot = await connected(svc);
651
- await bot.simulateMessage(userMsg(111, '/start'));
652
-
653
- await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', message: 'one-off' });
654
- jest.advanceTimersByTime(5 * 60 * 1000 + 100);
655
- await Promise.resolve();
656
-
657
- // The original message went out. No follow-up summary.
658
- const summary = bot.calls.sendMessage.find(c => c.text.includes('still happening'));
659
- expect(summary).toBeUndefined();
660
- } finally {
661
- jest.useRealTimers();
662
- }
663
- });
664
-
665
- test('execution_stopped does NOT relay unless /watch is on', async () => {
666
- const svc = makeService();
667
- const bot = await connected(svc);
668
- await bot.simulateMessage(userMsg(111, '/start'));
669
-
670
- await svc._handleBroadcastEvent({
671
- type: 'execution_stopped', agentId: 'a1', agentName: 'Cody', message: 'done',
672
- });
673
- // Notifications are batched on a timer, so we just assert nothing
674
- // queued at the immediate level.
675
- expect(bot.calls.sendMessage.find(c => c.text.includes('Agent Finished'))).toBeUndefined();
676
- });
677
-
678
- test('execution_stopped relays when /watch is on', async () => {
679
- jest.useFakeTimers();
680
- try {
681
- const svc = makeService();
682
- const bot = await connected(svc);
683
- await bot.simulateMessage(userMsg(111, '/start'));
684
- await bot.simulateMessage(userMsg(111, '/watch'));
685
-
686
- await svc._handleBroadcastEvent({
687
- type: 'execution_stopped', agentId: 'a1', agentName: 'Cody', message: 'done',
688
- });
689
- jest.advanceTimersByTime(11000); // flush batch interval (10s)
690
- await Promise.resolve(); // microtask drain
691
- // Sent messages are async — check that the queue WOULD have been flushed.
692
- expect(svc.notificationQueue.size === 0 || bot.calls.sendMessage.some(c => c.text.includes('Agent Finished')))
693
- .toBe(true);
694
- } finally {
695
- jest.useRealTimers();
696
- }
697
- });
698
- });
699
-
700
- // ─────────────────────────────────────────────────────────────────────
701
- // Pagination
702
- // ─────────────────────────────────────────────────────────────────────
703
-
704
- describe('paginated /agents', () => {
705
- test('first page only shows PAGE_SIZE entries when many agents exist', async () => {
706
- const many = Array.from({ length: 20 }, (_, i) => ({
707
- id: `a${i}`, name: `Agent${i}`, mode: 'chat', status: 'idle',
708
- }));
709
- const svc = makeService({ agents: many });
710
- const bot = await connected(svc);
711
- await bot.simulateMessage(userMsg(111, '/start'));
712
- bot.calls.sendMessage = [];
713
- await bot.simulateMessage(userMsg(111, '/agents'));
714
-
715
- const listMsg = bot.calls.sendMessage.find(c => c.text.includes('Agents (20)'));
716
- expect(listMsg).toBeDefined();
717
- // Page 1/3 of 20 with PAGE_SIZE=8 → 8 entries shown.
718
- const buttons = listMsg.opts.reply_markup.inline_keyboard;
719
- expect(buttons.filter(row => row[0].callback_data?.startsWith('agent_detail:'))).toHaveLength(8);
720
- // Last row is the pagination nav.
721
- const nav = buttons[buttons.length - 1];
722
- expect(nav.some(b => b.callback_data?.startsWith('agents_page:'))).toBe(true);
723
- });
724
- });
725
-
726
- // ─────────────────────────────────────────────────────────────────────
727
- // Markdown vs HTML vs document selection
728
- // ─────────────────────────────────────────────────────────────────────
729
-
730
- describe('text formatting selection', () => {
731
- test('plain text uses MarkdownV2', () => {
732
- const svc = makeService();
733
- const out = svc._formatAgentBody('hello world.');
734
- expect(out.parseMode).toBe('MarkdownV2');
735
- expect(out.body).toContain('hello world\\.'); // dot escaped
736
- });
737
-
738
- test('short fenced code uses HTML', () => {
739
- const svc = makeService();
740
- const out = svc._formatAgentBody('See:\n```js\nconst x = 1;\n```');
741
- expect(out.parseMode).toBe('HTML');
742
- expect(out.body).toMatch(/<pre>const x = 1;/);
743
- });
744
-
745
- test('long fenced code (> threshold) is sent as document', () => {
746
- const svc = makeService();
747
- const big = 'x = 1;\n'.repeat(800);
748
- const out = svc._formatAgentBody('```py\n' + big + '\n```');
749
- expect(out.kind).toBe('document');
750
- });
751
- });
752
-
753
- // ─────────────────────────────────────────────────────────────────────
754
- // Config migration
755
- // ─────────────────────────────────────────────────────────────────────
756
-
757
- describe('config migration', () => {
758
- test('legacy { chatId: "x", watchEnabled: true } loads as a single-chat entry', async () => {
759
- virtualFs.content = JSON.stringify({ chatId: 'X1', watchEnabled: true, botToken: 'T' });
760
- const svc = makeService();
761
- await svc._loadConfig();
762
- expect(svc.chats.has('X1')).toBe(true);
763
- expect(svc.chats.get('X1').watchEnabled).toBe(true);
764
- });
765
-
766
- test('legacy fields are dropped on next save', async () => {
767
- virtualFs.content = JSON.stringify({ chatId: 'X1', watchEnabled: true, botToken: 'T' });
768
- const svc = makeService();
769
- await svc._loadConfig();
770
- await svc._saveConfig();
771
- const persisted = JSON.parse(virtualFs.content);
772
- expect(persisted.chatId).toBeUndefined();
773
- expect(persisted.watchEnabled).toBeUndefined();
774
- expect(persisted.chats).toHaveLength(1);
775
- expect(persisted.chats[0].chatId).toBe('X1');
776
- });
777
- });
778
-
779
- // ─────────────────────────────────────────────────────────────────────
780
- // Bridged channels per-chat
781
- // ─────────────────────────────────────────────────────────────────────
782
-
783
- describe('getBridgedChannels', () => {
784
- test('returns one alias per chat the agent is active in, plus a bare "telegram" alias', async () => {
785
- const svc = makeService();
786
- const bot = await connected(svc);
787
- await bot.simulateMessage(userMsg(111, '/start'));
788
- await bot.simulateMessage(userMsg(222, '/start'));
789
- svc.chats.get('111').activeAgentIds.add('a1');
790
- svc.chats.get('222').activeAgentIds.add('a1');
791
-
792
- const bridged = svc.getBridgedChannels('a1');
793
- const aliases = bridged.map(c => c.alias);
794
- expect(aliases).toEqual(expect.arrayContaining([
795
- 'telegram:chat-111',
796
- 'telegram:chat-222',
797
- 'telegram',
798
- ]));
799
- });
800
-
801
- test('returns empty array when agent has no chat', async () => {
802
- const svc = makeService();
803
- await connected(svc);
804
- expect(svc.getBridgedChannels('a1')).toEqual([]);
805
- });
806
- });
807
-
808
- // ─────────────────────────────────────────────────────────────────────
809
- // imageGenerated → Telegram relay
810
- //
811
- // Background: the imageTool emits a separate broadcast (`imageGenerated`)
812
- // alongside the agent's textual `stream_complete`. Before this fix the
813
- // `imageGenerated` event was silently dropped and the user — who'd
814
- // asked the bot to "draw me X" — saw no image come back to the chat.
815
- //
816
- // Behavior we lock in:
817
- // - imageGenerated is buffered per agent, flushed on the next
818
- // stream_complete for the same agent into every active chat that
819
- // has that agent linked.
820
- // - dedupes against images the agent already embedded as
821
- // `<image src="…">` markup so we don't double-post.
822
- // - works even when the agent's text reply has no `<external>` block
823
- // at all (image-only turn).
824
- // - skips chats where the agent isn't active (multi-chat isolation).
825
- // ─────────────────────────────────────────────────────────────────────
826
-
827
- describe('imageGenerated relay', () => {
828
- test('imageGenerated buffered, flushed on stream_complete into the active chat', async () => {
829
- const svc = makeService();
830
- const bot = await connected(svc);
831
- await bot.simulateMessage(userMsg(111, '/start'));
832
- await bot.simulateMessage(userMsg(111, '@Cody draw a sunset'));
833
-
834
- // Image arrives from the tool first…
835
- await svc._handleBroadcastEvent({
836
- type: 'imageGenerated', agentId: 'a1',
837
- imageUrl: 'https://cdn.example/sunset.png',
838
- prompt: 'a sunset over Paris',
839
- success: true,
840
- });
841
- // Bot should not have posted the photo yet — we wait for the
842
- // agent's text completion to keep the order stable.
843
- expect(bot.calls.sendPhoto).toHaveLength(0);
844
-
845
- // Then the agent's text completion arrives.
846
- await svc._handleBroadcastEvent({
847
- type: 'stream_complete', agentId: 'a1', role: 'assistant',
848
- content: '<external>Here is your sunset.</external>',
849
- });
850
-
851
- expect(bot.calls.sendPhoto).toHaveLength(1);
852
- expect(bot.calls.sendPhoto[0].photo).toBe('https://cdn.example/sunset.png');
853
- expect(String(bot.calls.sendPhoto[0].chatId)).toBe('111');
854
- });
855
-
856
- test('still flushes even when stream_complete has no <external> block', async () => {
857
- const svc = makeService();
858
- const bot = await connected(svc);
859
- await bot.simulateMessage(userMsg(111, '/start'));
860
- await bot.simulateMessage(userMsg(111, '@Cody draw'));
861
-
862
- await svc._handleBroadcastEvent({
863
- type: 'imageGenerated', agentId: 'a1',
864
- imageUrl: 'https://cdn.example/only.png',
865
- success: true,
866
- });
867
- await svc._handleBroadcastEvent({
868
- type: 'stream_complete', agentId: 'a1', role: 'assistant',
869
- content: 'internal-only narration, no external block',
870
- });
871
- expect(bot.calls.sendPhoto).toHaveLength(1);
872
- expect(bot.calls.sendPhoto[0].photo).toBe('https://cdn.example/only.png');
873
- });
874
-
875
- test('deduplicates against images the agent already embedded in <external>', async () => {
876
- const svc = makeService();
877
- const bot = await connected(svc);
878
- await bot.simulateMessage(userMsg(111, '/start'));
879
- await bot.simulateMessage(userMsg(111, '@Cody draw'));
880
-
881
- const url = 'https://cdn.example/dup.png';
882
- await svc._handleBroadcastEvent({
883
- type: 'imageGenerated', agentId: 'a1', imageUrl: url, success: true,
884
- });
885
- await svc._handleBroadcastEvent({
886
- type: 'stream_complete', agentId: 'a1', role: 'assistant',
887
- content: `<external>See:\n<image src="${url}">my pic</image></external>`,
888
- });
889
- expect(bot.calls.sendPhoto).toHaveLength(1);
890
- });
891
-
892
- test('does NOT leak the image to a chat where the agent isn\'t active', async () => {
893
- const svc = makeService();
894
- const bot = await connected(svc);
895
- await bot.simulateMessage(userMsg(111, '/start'));
896
- await bot.simulateMessage(userMsg(222, '/start'));
897
- await bot.simulateMessage(userMsg(111, '@Cody draw'));
898
- // Chat 222 never @-mentioned the agent → activeAgentIds is empty.
899
- expect(svc.chats.get('222').activeAgentIds.has('a1')).toBe(false);
900
-
901
- await svc._handleBroadcastEvent({
902
- type: 'imageGenerated', agentId: 'a1',
903
- imageUrl: 'https://cdn.example/private.png',
904
- success: true,
905
- });
906
- await svc._handleBroadcastEvent({
907
- type: 'stream_complete', agentId: 'a1', role: 'assistant',
908
- content: '<external>done</external>',
909
- });
910
-
911
- const chats = bot.calls.sendPhoto.map(c => String(c.chatId));
912
- expect(chats).toEqual(['111']);
913
- });
914
-
915
- test('buffer drops stale entries (>5 min) on next insert', async () => {
916
- // Direct unit test of the buffer helper — bypasses the
917
- // `_handleBroadcastEvent` early-return guard (which requires a
918
- // connected bot + a /start'ed chat) so we can focus on the TTL.
919
- const svc = makeService();
920
- const original = Date.now;
921
- try {
922
- let now = 1_000_000_000_000;
923
- Date.now = () => now;
924
-
925
- svc._bufferGeneratedImage({ agentId: 'a1', imageUrl: 'https://cdn.example/stale.png' });
926
- now += 6 * 60 * 1000; // 6 min later
927
- svc._bufferGeneratedImage({ agentId: 'a1', imageUrl: 'https://cdn.example/fresh.png' });
928
- const remaining = svc._pendingImagesByAgent.get('a1');
929
- expect(remaining.map(e => e.imageUrl)).toEqual(['https://cdn.example/fresh.png']);
930
- } finally {
931
- Date.now = original;
932
- }
933
- });
934
-
935
- test('imageGenerated with no imageUrl is ignored (no crash)', () => {
936
- const svc = makeService();
937
- svc._bufferGeneratedImage({ agentId: 'a1' });
938
- expect(svc._pendingImagesByAgent.has('a1')).toBe(false);
939
- });
940
- });
941
-
1
+ /**
2
+ * Tests for the rewritten TelegramService.
3
+ *
4
+ * Covers the user-experience-critical surfaces:
5
+ * • Multi-chat /start (no global lock)
6
+ * • Group chat @mention gating
7
+ * • Voice → backend transcribe → text dispatch
8
+ * • Thinking placeholder edit-on-completion
9
+ * • <actions> / <image> / <attachment> block dispatch
10
+ * • Always-relay error broadcasts vs watch-gated completions
11
+ * • /reset, /new, /logout commands
12
+ * • Pagination of /agents
13
+ * • Markdown vs HTML vs document selection by content shape
14
+ * • Config migration (legacy `chatId` scalar)
15
+ *
16
+ * The mock TelegramBot records every call and exposes them via
17
+ * `bot.calls.<method>` arrays. node-telegram-bot-api is fully mocked
18
+ * so no network and no dependency on the real bot library.
19
+ */
20
+
21
+ import { jest, describe, test, expect, beforeEach } from '@jest/globals';
22
+
23
+ // ─── Module mocks ─────────────────────────────────────────────────────
24
+
25
+ // In-memory virtual fs for the config file. Each beforeEach resets it.
26
+ const virtualFs = { content: null };
27
+
28
+ jest.unstable_mockModule('fs', () => ({
29
+ promises: {
30
+ mkdir: jest.fn().mockResolvedValue(undefined),
31
+ readFile: jest.fn().mockImplementation(async () => {
32
+ if (virtualFs.content === null) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
33
+ return virtualFs.content;
34
+ }),
35
+ writeFile: jest.fn().mockImplementation(async (_p, data) => {
36
+ virtualFs.content = data;
37
+ }),
38
+ },
39
+ }));
40
+
41
+ jest.unstable_mockModule('../../utilities/userDataDir.js', () => ({
42
+ getUserDataPaths: () => ({ base: '/mock/data' }),
43
+ ensureUserDataDirs: jest.fn().mockResolvedValue(undefined),
44
+ }));
45
+
46
+ // MockBot — a deterministic stand-in for node-telegram-bot-api. Records
47
+ // every call, exposes hooks so tests can simulate incoming messages.
48
+ class MockBot {
49
+ constructor() {
50
+ this._textHandlers = []; // [{regex, fn}]
51
+ this._messageHandlers = [];
52
+ this._callbackHandlers = [];
53
+ this.calls = {
54
+ sendMessage: [], sendPhoto: [], sendDocument: [],
55
+ sendChatAction: [], editMessageText: [], deleteMessage: [],
56
+ answerCallbackQuery: [],
57
+ };
58
+ this.nextMessageId = 100;
59
+ this.stopPolling = jest.fn().mockResolvedValue(undefined);
60
+ }
61
+ async getMe() { return { username: 'loxia_bot', id: 1 }; }
62
+ onText(regex, fn) { this._textHandlers.push({ regex, fn }); }
63
+ on(event, fn) {
64
+ if (event === 'message') this._messageHandlers.push(fn);
65
+ else if (event === 'callback_query') this._callbackHandlers.push(fn);
66
+ }
67
+ async sendMessage(chatId, text, opts) {
68
+ const id = this.nextMessageId++;
69
+ this.calls.sendMessage.push({ chatId, text, opts, message_id: id });
70
+ return { message_id: id, chat: { id: chatId } };
71
+ }
72
+ async editMessageText(text, opts) { this.calls.editMessageText.push({ text, opts }); return { ok: true }; }
73
+ async deleteMessage(chatId, message_id) { this.calls.deleteMessage.push({ chatId, message_id }); }
74
+ async sendChatAction(chatId, action) { this.calls.sendChatAction.push({ chatId, action }); }
75
+ async sendPhoto(chatId, photo, opts) { this.calls.sendPhoto.push({ chatId, photo, opts }); }
76
+ async sendDocument(chatId, doc, opts) { this.calls.sendDocument.push({ chatId, doc, opts }); }
77
+ async answerCallbackQuery(id) { this.calls.answerCallbackQuery.push(id); }
78
+ async getFileLink(fileId) { return `https://t.example/file/${fileId}`; }
79
+
80
+ // Test helpers: simulate an incoming message / callback.
81
+ async simulateMessage(msg) {
82
+ // Telegram fires onText first when text matches a regex, then `message` listeners.
83
+ if (typeof msg.text === 'string') {
84
+ for (const { regex, fn } of this._textHandlers) {
85
+ const m = msg.text.match(regex);
86
+ if (m) await fn(msg, m);
87
+ }
88
+ }
89
+ for (const fn of this._messageHandlers) await fn(msg);
90
+ }
91
+ async simulateCallback(query) {
92
+ for (const fn of this._callbackHandlers) await fn(query);
93
+ }
94
+ }
95
+
96
+ let currentBot;
97
+ // We migrated off node-telegram-bot-api to telegraf via a thin compat
98
+ // wrapper at src/services/telegrafBot.js. The MockBot above implements
99
+ // exactly the wrapper's surface (sendMessage, editMessageText,
100
+ // deleteMessage, sendChatAction, sendPhoto, sendDocument,
101
+ // answerCallbackQuery, getFileLink, getMe, stopPolling, onText, on) so
102
+ // retargeting the mock to the wrapper module is the entire test-side
103
+ // change. telegramService.js calls `createTelegrafBot(token, opts)` —
104
+ // match that shape (async factory returning the wrapper).
105
+ jest.unstable_mockModule('../telegrafBot.js', () => ({
106
+ createTelegrafBot: jest.fn(async () => {
107
+ currentBot = new MockBot();
108
+ return currentBot;
109
+ }),
110
+ default: jest.fn(async () => {
111
+ currentBot = new MockBot();
112
+ return currentBot;
113
+ }),
114
+ }));
115
+
116
+ // ─── Imports after mocks ──────────────────────────────────────────────
117
+
118
+ const { TelegramService, _resetTelegramSingletonForTests } = await import('../telegramService.js');
119
+
120
+ // ─── Test helpers ─────────────────────────────────────────────────────
121
+
122
+ const SAMPLE_AGENTS = [
123
+ { id: 'a1', name: 'Cody', mode: 'chat', status: 'idle', currentModel: 'gpt-4.1' },
124
+ { id: 'a2', name: 'Lina', mode: 'auto', status: 'active', currentModel: 'claude-sonnet-4-5' },
125
+ ];
126
+
127
+ function makeService({ agents = SAMPLE_AGENTS, withOrchestrator = true } = {}) {
128
+ const svc = new TelegramService(null);
129
+ svc.setAgentPool({
130
+ getAllAgents: jest.fn().mockResolvedValue(agents),
131
+ getAgent: jest.fn((id) => Promise.resolve(agents.find(a => a.id === id) || null)),
132
+ });
133
+ if (withOrchestrator) {
134
+ svc.setOrchestrator({
135
+ config: { project: { directory: '/proj' } },
136
+ processRequest: jest.fn().mockResolvedValue(undefined),
137
+ messageProcessor: {
138
+ stopAutonomousExecution: jest.fn().mockResolvedValue(undefined),
139
+ resetAgentConversation: jest.fn().mockResolvedValue(undefined),
140
+ },
141
+ stateManager: {
142
+ loadFlowIndex: jest.fn().mockResolvedValue({}),
143
+ },
144
+ });
145
+ }
146
+ return svc;
147
+ }
148
+
149
+ function userMsg(chatId, text, { chatType = 'private', from = { username: 'alice' }, title } = {}) {
150
+ return {
151
+ chat: { id: chatId, type: chatType, title },
152
+ from,
153
+ text,
154
+ message_id: 1,
155
+ };
156
+ }
157
+
158
+ beforeEach(() => {
159
+ virtualFs.content = null;
160
+ _resetTelegramSingletonForTests();
161
+ currentBot = null;
162
+ });
163
+
164
+ async function connected(svc) {
165
+ await svc.connect('TEST_TOKEN');
166
+ return currentBot;
167
+ }
168
+
169
+ // ─────────────────────────────────────────────────────────────────────
170
+ // @-prefix resolution (names with spaces, slug form, ambiguity)
171
+ // ─────────────────────────────────────────────────────────────────────
172
+
173
+ describe('_resolveAtPrefix — @agent-name parsing', () => {
174
+ const agents = [
175
+ { id: 'a1', name: 'Cody' },
176
+ { id: 'a2', name: 'John Smith' },
177
+ { id: 'a3', name: 'John' }, // prefix-conflict with #a2
178
+ { id: 'a4', name: 'Multi Word Long Name' },
179
+ ];
180
+
181
+ test('no @prefix → agentName null, messageText unchanged', () => {
182
+ const svc = makeService({ agents });
183
+ const r = svc._resolveAtPrefix('just a message', agents);
184
+ expect(r.agentName).toBeNull();
185
+ expect(r.matchedAgent).toBeNull();
186
+ expect(r.messageText).toBe('just a message');
187
+ });
188
+
189
+ test('single-word name (legacy path)', () => {
190
+ const svc = makeService({ agents });
191
+ const r = svc._resolveAtPrefix('@Cody hello', agents);
192
+ expect(r.matchedAgent?.id).toBe('a1');
193
+ expect(r.messageText).toBe('hello');
194
+ });
195
+
196
+ test('multi-word name with literal spaces works', () => {
197
+ const svc = makeService({ agents });
198
+ const r = svc._resolveAtPrefix('@John Smith write the report', agents);
199
+ expect(r.matchedAgent?.id).toBe('a2');
200
+ expect(r.agentName).toBe('John Smith');
201
+ expect(r.messageText).toBe('write the report');
202
+ });
203
+
204
+ test('slug form @John_Smith resolves to "John Smith"', () => {
205
+ const svc = makeService({ agents });
206
+ const r = svc._resolveAtPrefix('@John_Smith write the report', agents);
207
+ expect(r.matchedAgent?.id).toBe('a2');
208
+ expect(r.messageText).toBe('write the report');
209
+ });
210
+
211
+ test('dash form @John-Smith also works', () => {
212
+ const svc = makeService({ agents });
213
+ const r = svc._resolveAtPrefix('@John-Smith hi', agents);
214
+ expect(r.matchedAgent?.id).toBe('a2');
215
+ expect(r.messageText).toBe('hi');
216
+ });
217
+
218
+ test('longest-match wins when one name is a prefix of another', () => {
219
+ // "@John Smith hello" must NOT resolve to the "John" agent.
220
+ const svc = makeService({ agents });
221
+ const r = svc._resolveAtPrefix('@John Smith hello', agents);
222
+ expect(r.matchedAgent?.id).toBe('a2');
223
+ });
224
+
225
+ test('shorter name still wins when the longer name doesn\'t match', () => {
226
+ const svc = makeService({ agents });
227
+ const r = svc._resolveAtPrefix('@John hello', agents);
228
+ expect(r.matchedAgent?.id).toBe('a3');
229
+ });
230
+
231
+ test('case-insensitive match', () => {
232
+ const svc = makeService({ agents });
233
+ const r = svc._resolveAtPrefix('@JOHN SMITH hi', agents);
234
+ expect(r.matchedAgent?.id).toBe('a2');
235
+ });
236
+
237
+ test('extra whitespace between the name and the message is collapsed', () => {
238
+ const svc = makeService({ agents });
239
+ const r = svc._resolveAtPrefix('@Cody \t hello', agents);
240
+ expect(r.messageText).toBe('hello');
241
+ });
242
+
243
+ test('partial-word collision does NOT false-match (Johnson should not match John)', () => {
244
+ const svc = makeService({ agents });
245
+ const r = svc._resolveAtPrefix('@Johnson hi', agents);
246
+ // Falls back to legacy capture so caller can show "not found"
247
+ expect(r.matchedAgent).toBeNull();
248
+ expect(r.agentName).toBe('Johnson');
249
+ });
250
+
251
+ test('unknown @name → legacy capture for "not found" error', () => {
252
+ const svc = makeService({ agents });
253
+ const r = svc._resolveAtPrefix('@Stranger hi', agents);
254
+ expect(r.matchedAgent).toBeNull();
255
+ expect(r.agentName).toBe('Stranger');
256
+ expect(r.messageText).toBe('hi');
257
+ });
258
+
259
+ test('handles regex-sensitive characters in agent names safely', () => {
260
+ const dangerous = [{ id: 'a1', name: 'C++.parser' }];
261
+ const svc = makeService({ agents: dangerous });
262
+ const r = svc._resolveAtPrefix('@C++.parser go', dangerous);
263
+ expect(r.matchedAgent?.id).toBe('a1');
264
+ expect(r.messageText).toBe('go');
265
+ });
266
+
267
+ test('many-word names ("Multi Word Long Name") match end-to-end', () => {
268
+ const svc = makeService({ agents });
269
+ const r = svc._resolveAtPrefix('@Multi Word Long Name run the audit', agents);
270
+ expect(r.matchedAgent?.id).toBe('a4');
271
+ expect(r.messageText).toBe('run the audit');
272
+ });
273
+ });
274
+
275
+ // ─────────────────────────────────────────────────────────────────────
276
+ // Multi-chat /start
277
+ // ─────────────────────────────────────────────────────────────────────
278
+
279
+ describe('multi-chat /start', () => {
280
+ test('first chat /start registers; second chat /start ALSO registers (no global lock)', async () => {
281
+ const svc = makeService();
282
+ const bot = await connected(svc);
283
+ await bot.simulateMessage(userMsg(111, '/start'));
284
+ await bot.simulateMessage(userMsg(222, '/start'));
285
+ expect(svc.chats.has('111')).toBe(true);
286
+ expect(svc.chats.has('222')).toBe(true);
287
+ expect(svc.getStatus().chatCount).toBe(2);
288
+ });
289
+
290
+ test('same chat /start twice does NOT duplicate state', async () => {
291
+ const svc = makeService();
292
+ const bot = await connected(svc);
293
+ await bot.simulateMessage(userMsg(111, '/start'));
294
+ await bot.simulateMessage(userMsg(111, '/start'));
295
+ expect(svc.chats.size).toBe(1);
296
+ // Second invocation should reply "Already connected".
297
+ const greetings = bot.calls.sendMessage.filter(c => String(c.chatId) === '111');
298
+ expect(greetings.length).toBeGreaterThanOrEqual(2);
299
+ expect(greetings[1].text).toMatch(/Already connected/);
300
+ });
301
+ });
302
+
303
+ // ─────────────────────────────────────────────────────────────────────
304
+ // /logout, /reset, /new
305
+ // ─────────────────────────────────────────────────────────────────────
306
+
307
+ describe('/logout', () => {
308
+ test('removes chat from registered set', async () => {
309
+ const svc = makeService();
310
+ const bot = await connected(svc);
311
+ await bot.simulateMessage(userMsg(111, '/start'));
312
+ await bot.simulateMessage(userMsg(111, '/logout'));
313
+ expect(svc.chats.has('111')).toBe(false);
314
+ });
315
+ });
316
+
317
+ describe('/reset', () => {
318
+ test('calls resetAgentConversation on the sticky agent', async () => {
319
+ const svc = makeService();
320
+ const bot = await connected(svc);
321
+ await bot.simulateMessage(userMsg(111, '/start'));
322
+ svc.chats.get('111').lastAgentId = 'a1';
323
+ svc.chats.get('111').activeAgentIds.add('a1');
324
+
325
+ await bot.simulateMessage(userMsg(111, '/reset'));
326
+ expect(svc.orchestrator.messageProcessor.resetAgentConversation).toHaveBeenCalledWith('a1');
327
+ });
328
+
329
+ test('returns friendly message when no sticky agent is set', async () => {
330
+ const svc = makeService();
331
+ const bot = await connected(svc);
332
+ await bot.simulateMessage(userMsg(111, '/start'));
333
+ bot.calls.sendMessage = [];
334
+ await bot.simulateMessage(userMsg(111, '/reset'));
335
+ const last = bot.calls.sendMessage[bot.calls.sendMessage.length - 1];
336
+ expect(last.text).toMatch(/No active agent/);
337
+ expect(svc.orchestrator.messageProcessor.resetAgentConversation).not.toHaveBeenCalled();
338
+ });
339
+ });
340
+
341
+ describe('/new <agent>', () => {
342
+ test('switches sticky agent + resets the new one', async () => {
343
+ const svc = makeService();
344
+ const bot = await connected(svc);
345
+ await bot.simulateMessage(userMsg(111, '/start'));
346
+ await bot.simulateMessage(userMsg(111, '/new Lina'));
347
+ expect(svc.chats.get('111').lastAgentId).toBe('a2');
348
+ expect(svc.orchestrator.messageProcessor.resetAgentConversation).toHaveBeenCalledWith('a2');
349
+ });
350
+
351
+ test('unknown agent returns error', async () => {
352
+ const svc = makeService();
353
+ const bot = await connected(svc);
354
+ await bot.simulateMessage(userMsg(111, '/start'));
355
+ bot.calls.sendMessage = [];
356
+ await bot.simulateMessage(userMsg(111, '/new Nobody'));
357
+ expect(bot.calls.sendMessage[0].text).toMatch(/not found/);
358
+ });
359
+ });
360
+
361
+ // ─────────────────────────────────────────────────────────────────────
362
+ // End-to-end @-prefix with spaced name
363
+ // ─────────────────────────────────────────────────────────────────────
364
+
365
+ describe('@-prefix dispatch — name with spaces', () => {
366
+ test('"@John Smith write the report" dispatches to the right agent with the right message', async () => {
367
+ const agents = [
368
+ { id: 'js-1', name: 'John Smith', mode: 'chat', status: 'idle', currentModel: 'gpt-4.1' },
369
+ { id: 'cody', name: 'Cody', mode: 'chat', status: 'idle', currentModel: 'gpt-4.1' },
370
+ ];
371
+ const svc = makeService({ agents });
372
+ const bot = await connected(svc);
373
+ await bot.simulateMessage(userMsg(111, '/start'));
374
+
375
+ await bot.simulateMessage(userMsg(111, '@John Smith write the report'));
376
+
377
+ expect(svc.orchestrator.processRequest).toHaveBeenCalled();
378
+ const payload = svc.orchestrator.processRequest.mock.calls.slice(-1)[0][0].payload;
379
+ expect(payload.agentId).toBe('js-1');
380
+ expect(payload.message).toBe('write the report');
381
+ });
382
+
383
+ test('unknown @name surfaces a helpful "not found" reply, no orchestrator call', async () => {
384
+ const svc = makeService();
385
+ const bot = await connected(svc);
386
+ await bot.simulateMessage(userMsg(111, '/start'));
387
+ bot.calls.sendMessage = [];
388
+
389
+ await bot.simulateMessage(userMsg(111, '@Nobody hello'));
390
+
391
+ expect(svc.orchestrator.processRequest).not.toHaveBeenCalled();
392
+ const reply = bot.calls.sendMessage[0];
393
+ expect(reply.text).toMatch(/not found/);
394
+ // The friendly tip explaining slug syntax must be present so users
395
+ // who typed an underscore version learn the syntax. Underscores
396
+ // are MarkdownV2-escaped on the wire (`John\_Smith`), so allow
397
+ // any single character between the two halves.
398
+ expect(reply.text).toMatch(/John.Smith/);
399
+ });
400
+ });
401
+
402
+ // ─────────────────────────────────────────────────────────────────────
403
+ // Group chat @mention gating
404
+ // ─────────────────────────────────────────────────────────────────────
405
+
406
+ describe('group chat — @mention required', () => {
407
+ test('plain message in group is IGNORED (no orchestrator call)', async () => {
408
+ const svc = makeService();
409
+ const bot = await connected(svc);
410
+ await bot.simulateMessage(userMsg(-100, '/start', { chatType: 'supergroup', title: 'Ops' }));
411
+ svc.chats.get('-100').lastAgentId = 'a1';
412
+ svc.chats.get('-100').activeAgentIds.add('a1');
413
+
414
+ await bot.simulateMessage(userMsg(-100, 'hello team', { chatType: 'supergroup' }));
415
+ expect(svc.orchestrator.processRequest).not.toHaveBeenCalled();
416
+ });
417
+
418
+ test('@bot message in group strips mention and dispatches', async () => {
419
+ const svc = makeService();
420
+ const bot = await connected(svc);
421
+ await bot.simulateMessage(userMsg(-100, '/start', { chatType: 'supergroup', title: 'Ops' }));
422
+ svc.chats.get('-100').lastAgentId = 'a1';
423
+ svc.chats.get('-100').activeAgentIds.add('a1');
424
+
425
+ await bot.simulateMessage(userMsg(-100, '@loxia_bot summarize last meeting', { chatType: 'supergroup' }));
426
+ expect(svc.orchestrator.processRequest).toHaveBeenCalled();
427
+ const payload = svc.orchestrator.processRequest.mock.calls[0][0].payload;
428
+ expect(payload.message).toBe('summarize last meeting');
429
+ });
430
+
431
+ test('private chat does NOT require @mention', async () => {
432
+ const svc = makeService();
433
+ const bot = await connected(svc);
434
+ await bot.simulateMessage(userMsg(111, '/start'));
435
+ svc.chats.get('111').lastAgentId = 'a1';
436
+ svc.chats.get('111').activeAgentIds.add('a1');
437
+
438
+ await bot.simulateMessage(userMsg(111, 'hi there'));
439
+ expect(svc.orchestrator.processRequest).toHaveBeenCalled();
440
+ });
441
+ });
442
+
443
+ // ─────────────────────────────────────────────────────────────────────
444
+ // Thinking placeholder & edit
445
+ // ─────────────────────────────────────────────────────────────────────
446
+
447
+ describe('thinking placeholder', () => {
448
+ test('typing action + placeholder sent before orchestrator call', async () => {
449
+ const svc = makeService();
450
+ const bot = await connected(svc);
451
+ await bot.simulateMessage(userMsg(111, '/start'));
452
+ await bot.simulateMessage(userMsg(111, '@Cody help me'));
453
+
454
+ expect(bot.calls.sendChatAction).toContainEqual({ chatId: 111, action: 'typing' });
455
+ const thinking = bot.calls.sendMessage.find(c => c.text.includes('thinking'));
456
+ expect(thinking).toBeDefined();
457
+ expect(thinking.chatId).toBe(111);
458
+ });
459
+
460
+ test('placeholder is replaced (edit) when agent emits stream_complete', async () => {
461
+ const svc = makeService();
462
+ const bot = await connected(svc);
463
+ await bot.simulateMessage(userMsg(111, '/start'));
464
+ await bot.simulateMessage(userMsg(111, '@Cody hello'));
465
+
466
+ // Simulate broadcast.
467
+ await svc._handleBroadcastEvent({
468
+ type: 'stream_complete',
469
+ agentId: 'a1',
470
+ content: '<external>Sure — here you go.</external>',
471
+ role: 'assistant',
472
+ });
473
+
474
+ expect(bot.calls.editMessageText).toHaveLength(1);
475
+ expect(bot.calls.editMessageText[0].text).toMatch(/Cody/);
476
+ expect(bot.calls.editMessageText[0].text).toMatch(/Sure/);
477
+ });
478
+ });
479
+
480
+ // ─────────────────────────────────────────────────────────────────────
481
+ // <actions> / <image> / <attachment> blocks
482
+ // ─────────────────────────────────────────────────────────────────────
483
+
484
+ describe('rich reply blocks', () => {
485
+ test('<actions> block becomes inline keyboard with act:agentId:value callbacks', async () => {
486
+ const svc = makeService();
487
+ const bot = await connected(svc);
488
+ await bot.simulateMessage(userMsg(111, '/start'));
489
+ await bot.simulateMessage(userMsg(111, '@Cody what now'));
490
+
491
+ await svc._handleBroadcastEvent({
492
+ type: 'stream_complete', agentId: 'a1', role: 'assistant',
493
+ content:
494
+ '<external>Pick one:\n' +
495
+ '<actions>[{"label":"Continue","value":"go"},{"label":"Restart","value":"reset"}]</actions>' +
496
+ '</external>',
497
+ });
498
+
499
+ expect(bot.calls.editMessageText).toHaveLength(1);
500
+ const opts = bot.calls.editMessageText[0].opts;
501
+ const kb = opts.reply_markup.inline_keyboard;
502
+ expect(kb.map(row => row[0].text)).toEqual(['Continue', 'Restart']);
503
+ expect(kb[0][0].callback_data).toBe('act:a1:go');
504
+ expect(kb[1][0].callback_data).toBe('act:a1:reset');
505
+ });
506
+
507
+ test('button tap dispatches a synthetic agent message', async () => {
508
+ const svc = makeService();
509
+ const bot = await connected(svc);
510
+ await bot.simulateMessage(userMsg(111, '/start'));
511
+ svc.chats.get('111').activeAgentIds.add('a1');
512
+ svc.chats.get('111').lastAgentId = 'a1';
513
+
514
+ await bot.simulateCallback({
515
+ id: 'cb-1',
516
+ data: 'act:a1:keep going',
517
+ message: { chat: { id: 111, type: 'private' }, message_id: 999 },
518
+ from: { username: 'alice' },
519
+ });
520
+ expect(svc.orchestrator.processRequest).toHaveBeenCalled();
521
+ const payload = svc.orchestrator.processRequest.mock.calls.slice(-1)[0][0].payload;
522
+ expect(payload.message).toBe('keep going');
523
+ });
524
+
525
+ test('<image> block triggers sendPhoto', async () => {
526
+ const svc = makeService();
527
+ const bot = await connected(svc);
528
+ await bot.simulateMessage(userMsg(111, '/start'));
529
+ await bot.simulateMessage(userMsg(111, '@Cody chart'));
530
+
531
+ await svc._handleBroadcastEvent({
532
+ type: 'stream_complete', agentId: 'a1', role: 'assistant',
533
+ content: '<external>Here it is:\n<image src="https://example.com/chart.png">Q4 chart</image></external>',
534
+ });
535
+ expect(bot.calls.sendPhoto).toHaveLength(1);
536
+ expect(String(bot.calls.sendPhoto[0].chatId)).toBe('111');
537
+ expect(bot.calls.sendPhoto[0].photo).toBe('https://example.com/chart.png');
538
+ });
539
+
540
+ test('<attachment> block triggers sendDocument', async () => {
541
+ const svc = makeService();
542
+ const bot = await connected(svc);
543
+ await bot.simulateMessage(userMsg(111, '/start'));
544
+ await bot.simulateMessage(userMsg(111, '@Cody report'));
545
+
546
+ await svc._handleBroadcastEvent({
547
+ type: 'stream_complete', agentId: 'a1', role: 'assistant',
548
+ content: '<external>See report:\n<attachment src="/tmp/r.pdf" type="application/pdf">Report</attachment></external>',
549
+ });
550
+ expect(bot.calls.sendDocument).toHaveLength(1);
551
+ expect(String(bot.calls.sendDocument[0].chatId)).toBe('111');
552
+ expect(bot.calls.sendDocument[0].doc).toBe('/tmp/r.pdf');
553
+ });
554
+ });
555
+
556
+ // ─────────────────────────────────────────────────────────────────────
557
+ // Always-relay errors / watch-gated completions
558
+ // ─────────────────────────────────────────────────────────────────────
559
+
560
+ describe('error fan-out vs watch-gating', () => {
561
+ test('agent_error relays to a chat that has NOT subscribed via /watch', async () => {
562
+ const svc = makeService();
563
+ const bot = await connected(svc);
564
+ await bot.simulateMessage(userMsg(111, '/start'));
565
+ expect(svc.chats.get('111').watchEnabled).toBe(false);
566
+
567
+ await svc._handleBroadcastEvent({
568
+ type: 'agent_error', agentId: 'a1', agentName: 'Cody', message: 'boom',
569
+ });
570
+ const sent = bot.calls.sendMessage.find(c => c.text.includes('Agent Error'));
571
+ expect(sent).toBeDefined();
572
+ });
573
+
574
+ test('agent_error dedup: identical errors within the window only send once', async () => {
575
+ const svc = makeService();
576
+ const bot = await connected(svc);
577
+ await bot.simulateMessage(userMsg(111, '/start'));
578
+
579
+ const payload = { type: 'agent_error', agentId: 'a1', agentName: 'Cody', message: 'boom' };
580
+ await svc._handleBroadcastEvent(payload);
581
+ await svc._handleBroadcastEvent(payload);
582
+ await svc._handleBroadcastEvent(payload);
583
+ await svc._handleBroadcastEvent(payload);
584
+
585
+ const errorMessages = bot.calls.sendMessage.filter(c => c.text.includes('Agent Error'));
586
+ // Only the first one should have made it through; the next three are
587
+ // counted but suppressed.
588
+ expect(errorMessages).toHaveLength(1);
589
+ // Dedup state confirms three were absorbed.
590
+ const entry = [...svc._errorBroadcastDedup.values()][0];
591
+ expect(entry?.count).toBe(4);
592
+ });
593
+
594
+ test('agent_error dedup: different agents do NOT collide', async () => {
595
+ const svc = makeService();
596
+ const bot = await connected(svc);
597
+ await bot.simulateMessage(userMsg(111, '/start'));
598
+
599
+ await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', agentName: 'Cody', message: 'same' });
600
+ await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a2', agentName: 'Carol', message: 'same' });
601
+
602
+ const errorMessages = bot.calls.sendMessage.filter(c => c.text.includes('Agent Error'));
603
+ expect(errorMessages).toHaveLength(2);
604
+ });
605
+
606
+ test('agent_error dedup: textual variations of same error coalesce', async () => {
607
+ const svc = makeService();
608
+ const bot = await connected(svc);
609
+ await bot.simulateMessage(userMsg(111, '/start'));
610
+
611
+ // Same error, formatted slightly differently — should dedup.
612
+ await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', message: 'Connection refused' });
613
+ await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', message: 'connection refused' });
614
+ await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', message: ' Connection refused ' });
615
+
616
+ const errorMessages = bot.calls.sendMessage.filter(c => c.text.includes('Agent Error'));
617
+ expect(errorMessages).toHaveLength(1);
618
+ });
619
+
620
+ test('agent_error dedup: window close emits a "still happening" summary when count > 1', async () => {
621
+ jest.useFakeTimers();
622
+ try {
623
+ const svc = makeService();
624
+ const bot = await connected(svc);
625
+ await bot.simulateMessage(userMsg(111, '/start'));
626
+
627
+ // First fires immediately, plus two suppressed.
628
+ await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', agentName: 'Cody', message: 'boom' });
629
+ await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', agentName: 'Cody', message: 'boom' });
630
+ await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', agentName: 'Cody', message: 'boom' });
631
+
632
+ // Advance past the 5-minute dedup window.
633
+ jest.advanceTimersByTime(5 * 60 * 1000 + 100);
634
+ // Let the timer callback's microtasks resolve.
635
+ await Promise.resolve();
636
+ await Promise.resolve();
637
+
638
+ const summary = bot.calls.sendMessage.find(c => c.text.includes('still happening'));
639
+ expect(summary).toBeDefined();
640
+ expect(summary.text).toMatch(/2 more/);
641
+ } finally {
642
+ jest.useRealTimers();
643
+ }
644
+ });
645
+
646
+ test('agent_error dedup: a single error does NOT trigger a summary', async () => {
647
+ jest.useFakeTimers();
648
+ try {
649
+ const svc = makeService();
650
+ const bot = await connected(svc);
651
+ await bot.simulateMessage(userMsg(111, '/start'));
652
+
653
+ await svc._handleBroadcastEvent({ type: 'agent_error', agentId: 'a1', message: 'one-off' });
654
+ jest.advanceTimersByTime(5 * 60 * 1000 + 100);
655
+ await Promise.resolve();
656
+
657
+ // The original message went out. No follow-up summary.
658
+ const summary = bot.calls.sendMessage.find(c => c.text.includes('still happening'));
659
+ expect(summary).toBeUndefined();
660
+ } finally {
661
+ jest.useRealTimers();
662
+ }
663
+ });
664
+
665
+ test('execution_stopped does NOT relay unless /watch is on', async () => {
666
+ const svc = makeService();
667
+ const bot = await connected(svc);
668
+ await bot.simulateMessage(userMsg(111, '/start'));
669
+
670
+ await svc._handleBroadcastEvent({
671
+ type: 'execution_stopped', agentId: 'a1', agentName: 'Cody', message: 'done',
672
+ });
673
+ // Notifications are batched on a timer, so we just assert nothing
674
+ // queued at the immediate level.
675
+ expect(bot.calls.sendMessage.find(c => c.text.includes('Agent Finished'))).toBeUndefined();
676
+ });
677
+
678
+ test('execution_stopped relays when /watch is on', async () => {
679
+ jest.useFakeTimers();
680
+ try {
681
+ const svc = makeService();
682
+ const bot = await connected(svc);
683
+ await bot.simulateMessage(userMsg(111, '/start'));
684
+ await bot.simulateMessage(userMsg(111, '/watch'));
685
+
686
+ await svc._handleBroadcastEvent({
687
+ type: 'execution_stopped', agentId: 'a1', agentName: 'Cody', message: 'done',
688
+ });
689
+ jest.advanceTimersByTime(11000); // flush batch interval (10s)
690
+ await Promise.resolve(); // microtask drain
691
+ // Sent messages are async — check that the queue WOULD have been flushed.
692
+ expect(svc.notificationQueue.size === 0 || bot.calls.sendMessage.some(c => c.text.includes('Agent Finished')))
693
+ .toBe(true);
694
+ } finally {
695
+ jest.useRealTimers();
696
+ }
697
+ });
698
+ });
699
+
700
+ // ─────────────────────────────────────────────────────────────────────
701
+ // Pagination
702
+ // ─────────────────────────────────────────────────────────────────────
703
+
704
+ describe('paginated /agents', () => {
705
+ test('first page only shows PAGE_SIZE entries when many agents exist', async () => {
706
+ const many = Array.from({ length: 20 }, (_, i) => ({
707
+ id: `a${i}`, name: `Agent${i}`, mode: 'chat', status: 'idle',
708
+ }));
709
+ const svc = makeService({ agents: many });
710
+ const bot = await connected(svc);
711
+ await bot.simulateMessage(userMsg(111, '/start'));
712
+ bot.calls.sendMessage = [];
713
+ await bot.simulateMessage(userMsg(111, '/agents'));
714
+
715
+ const listMsg = bot.calls.sendMessage.find(c => c.text.includes('Agents (20)'));
716
+ expect(listMsg).toBeDefined();
717
+ // Page 1/3 of 20 with PAGE_SIZE=8 → 8 entries shown.
718
+ const buttons = listMsg.opts.reply_markup.inline_keyboard;
719
+ expect(buttons.filter(row => row[0].callback_data?.startsWith('agent_detail:'))).toHaveLength(8);
720
+ // Last row is the pagination nav.
721
+ const nav = buttons[buttons.length - 1];
722
+ expect(nav.some(b => b.callback_data?.startsWith('agents_page:'))).toBe(true);
723
+ });
724
+ });
725
+
726
+ // ─────────────────────────────────────────────────────────────────────
727
+ // Markdown vs HTML vs document selection
728
+ // ─────────────────────────────────────────────────────────────────────
729
+
730
+ describe('text formatting selection', () => {
731
+ test('plain text uses MarkdownV2', () => {
732
+ const svc = makeService();
733
+ const out = svc._formatAgentBody('hello world.');
734
+ expect(out.parseMode).toBe('MarkdownV2');
735
+ expect(out.body).toContain('hello world\\.'); // dot escaped
736
+ });
737
+
738
+ test('short fenced code uses HTML', () => {
739
+ const svc = makeService();
740
+ const out = svc._formatAgentBody('See:\n```js\nconst x = 1;\n```');
741
+ expect(out.parseMode).toBe('HTML');
742
+ expect(out.body).toMatch(/<pre>const x = 1;/);
743
+ });
744
+
745
+ test('long fenced code (> threshold) is sent as document', () => {
746
+ const svc = makeService();
747
+ const big = 'x = 1;\n'.repeat(800);
748
+ const out = svc._formatAgentBody('```py\n' + big + '\n```');
749
+ expect(out.kind).toBe('document');
750
+ });
751
+ });
752
+
753
+ // ─────────────────────────────────────────────────────────────────────
754
+ // Config migration
755
+ // ─────────────────────────────────────────────────────────────────────
756
+
757
+ describe('config migration', () => {
758
+ test('legacy { chatId: "x", watchEnabled: true } loads as a single-chat entry', async () => {
759
+ virtualFs.content = JSON.stringify({ chatId: 'X1', watchEnabled: true, botToken: 'T' });
760
+ const svc = makeService();
761
+ await svc._loadConfig();
762
+ expect(svc.chats.has('X1')).toBe(true);
763
+ expect(svc.chats.get('X1').watchEnabled).toBe(true);
764
+ });
765
+
766
+ test('legacy fields are dropped on next save', async () => {
767
+ virtualFs.content = JSON.stringify({ chatId: 'X1', watchEnabled: true, botToken: 'T' });
768
+ const svc = makeService();
769
+ await svc._loadConfig();
770
+ await svc._saveConfig();
771
+ const persisted = JSON.parse(virtualFs.content);
772
+ expect(persisted.chatId).toBeUndefined();
773
+ expect(persisted.watchEnabled).toBeUndefined();
774
+ expect(persisted.chats).toHaveLength(1);
775
+ expect(persisted.chats[0].chatId).toBe('X1');
776
+ });
777
+ });
778
+
779
+ // ─────────────────────────────────────────────────────────────────────
780
+ // Bridged channels per-chat
781
+ // ─────────────────────────────────────────────────────────────────────
782
+
783
+ describe('getBridgedChannels', () => {
784
+ test('returns one alias per chat the agent is active in, plus a bare "telegram" alias', async () => {
785
+ const svc = makeService();
786
+ const bot = await connected(svc);
787
+ await bot.simulateMessage(userMsg(111, '/start'));
788
+ await bot.simulateMessage(userMsg(222, '/start'));
789
+ svc.chats.get('111').activeAgentIds.add('a1');
790
+ svc.chats.get('222').activeAgentIds.add('a1');
791
+
792
+ const bridged = svc.getBridgedChannels('a1');
793
+ const aliases = bridged.map(c => c.alias);
794
+ expect(aliases).toEqual(expect.arrayContaining([
795
+ 'telegram:chat-111',
796
+ 'telegram:chat-222',
797
+ 'telegram',
798
+ ]));
799
+ });
800
+
801
+ test('returns empty array when agent has no chat', async () => {
802
+ const svc = makeService();
803
+ await connected(svc);
804
+ expect(svc.getBridgedChannels('a1')).toEqual([]);
805
+ });
806
+ });
807
+
808
+ // ─────────────────────────────────────────────────────────────────────
809
+ // imageGenerated → Telegram relay
810
+ //
811
+ // Background: the imageTool emits a separate broadcast (`imageGenerated`)
812
+ // alongside the agent's textual `stream_complete`. Before this fix the
813
+ // `imageGenerated` event was silently dropped and the user — who'd
814
+ // asked the bot to "draw me X" — saw no image come back to the chat.
815
+ //
816
+ // Behavior we lock in:
817
+ // - imageGenerated is buffered per agent, flushed on the next
818
+ // stream_complete for the same agent into every active chat that
819
+ // has that agent linked.
820
+ // - dedupes against images the agent already embedded as
821
+ // `<image src="…">` markup so we don't double-post.
822
+ // - works even when the agent's text reply has no `<external>` block
823
+ // at all (image-only turn).
824
+ // - skips chats where the agent isn't active (multi-chat isolation).
825
+ // ─────────────────────────────────────────────────────────────────────
826
+
827
+ describe('imageGenerated relay', () => {
828
+ test('imageGenerated buffered, flushed on stream_complete into the active chat', async () => {
829
+ const svc = makeService();
830
+ const bot = await connected(svc);
831
+ await bot.simulateMessage(userMsg(111, '/start'));
832
+ await bot.simulateMessage(userMsg(111, '@Cody draw a sunset'));
833
+
834
+ // Image arrives from the tool first…
835
+ await svc._handleBroadcastEvent({
836
+ type: 'imageGenerated', agentId: 'a1',
837
+ imageUrl: 'https://cdn.example/sunset.png',
838
+ prompt: 'a sunset over Paris',
839
+ success: true,
840
+ });
841
+ // Bot should not have posted the photo yet — we wait for the
842
+ // agent's text completion to keep the order stable.
843
+ expect(bot.calls.sendPhoto).toHaveLength(0);
844
+
845
+ // Then the agent's text completion arrives.
846
+ await svc._handleBroadcastEvent({
847
+ type: 'stream_complete', agentId: 'a1', role: 'assistant',
848
+ content: '<external>Here is your sunset.</external>',
849
+ });
850
+
851
+ expect(bot.calls.sendPhoto).toHaveLength(1);
852
+ expect(bot.calls.sendPhoto[0].photo).toBe('https://cdn.example/sunset.png');
853
+ expect(String(bot.calls.sendPhoto[0].chatId)).toBe('111');
854
+ });
855
+
856
+ test('still flushes even when stream_complete has no <external> block', async () => {
857
+ const svc = makeService();
858
+ const bot = await connected(svc);
859
+ await bot.simulateMessage(userMsg(111, '/start'));
860
+ await bot.simulateMessage(userMsg(111, '@Cody draw'));
861
+
862
+ await svc._handleBroadcastEvent({
863
+ type: 'imageGenerated', agentId: 'a1',
864
+ imageUrl: 'https://cdn.example/only.png',
865
+ success: true,
866
+ });
867
+ await svc._handleBroadcastEvent({
868
+ type: 'stream_complete', agentId: 'a1', role: 'assistant',
869
+ content: 'internal-only narration, no external block',
870
+ });
871
+ expect(bot.calls.sendPhoto).toHaveLength(1);
872
+ expect(bot.calls.sendPhoto[0].photo).toBe('https://cdn.example/only.png');
873
+ });
874
+
875
+ test('deduplicates against images the agent already embedded in <external>', async () => {
876
+ const svc = makeService();
877
+ const bot = await connected(svc);
878
+ await bot.simulateMessage(userMsg(111, '/start'));
879
+ await bot.simulateMessage(userMsg(111, '@Cody draw'));
880
+
881
+ const url = 'https://cdn.example/dup.png';
882
+ await svc._handleBroadcastEvent({
883
+ type: 'imageGenerated', agentId: 'a1', imageUrl: url, success: true,
884
+ });
885
+ await svc._handleBroadcastEvent({
886
+ type: 'stream_complete', agentId: 'a1', role: 'assistant',
887
+ content: `<external>See:\n<image src="${url}">my pic</image></external>`,
888
+ });
889
+ expect(bot.calls.sendPhoto).toHaveLength(1);
890
+ });
891
+
892
+ test('does NOT leak the image to a chat where the agent isn\'t active', async () => {
893
+ const svc = makeService();
894
+ const bot = await connected(svc);
895
+ await bot.simulateMessage(userMsg(111, '/start'));
896
+ await bot.simulateMessage(userMsg(222, '/start'));
897
+ await bot.simulateMessage(userMsg(111, '@Cody draw'));
898
+ // Chat 222 never @-mentioned the agent → activeAgentIds is empty.
899
+ expect(svc.chats.get('222').activeAgentIds.has('a1')).toBe(false);
900
+
901
+ await svc._handleBroadcastEvent({
902
+ type: 'imageGenerated', agentId: 'a1',
903
+ imageUrl: 'https://cdn.example/private.png',
904
+ success: true,
905
+ });
906
+ await svc._handleBroadcastEvent({
907
+ type: 'stream_complete', agentId: 'a1', role: 'assistant',
908
+ content: '<external>done</external>',
909
+ });
910
+
911
+ const chats = bot.calls.sendPhoto.map(c => String(c.chatId));
912
+ expect(chats).toEqual(['111']);
913
+ });
914
+
915
+ test('buffer drops stale entries (>5 min) on next insert', async () => {
916
+ // Direct unit test of the buffer helper — bypasses the
917
+ // `_handleBroadcastEvent` early-return guard (which requires a
918
+ // connected bot + a /start'ed chat) so we can focus on the TTL.
919
+ const svc = makeService();
920
+ const original = Date.now;
921
+ try {
922
+ let now = 1_000_000_000_000;
923
+ Date.now = () => now;
924
+
925
+ svc._bufferGeneratedImage({ agentId: 'a1', imageUrl: 'https://cdn.example/stale.png' });
926
+ now += 6 * 60 * 1000; // 6 min later
927
+ svc._bufferGeneratedImage({ agentId: 'a1', imageUrl: 'https://cdn.example/fresh.png' });
928
+ const remaining = svc._pendingImagesByAgent.get('a1');
929
+ expect(remaining.map(e => e.imageUrl)).toEqual(['https://cdn.example/fresh.png']);
930
+ } finally {
931
+ Date.now = original;
932
+ }
933
+ });
934
+
935
+ test('imageGenerated with no imageUrl is ignored (no crash)', () => {
936
+ const svc = makeService();
937
+ svc._bufferGeneratedImage({ agentId: 'a1' });
938
+ expect(svc._pendingImagesByAgent.has('a1')).toBe(false);
939
+ });
940
+ });
941
+