@nyaruka/temba-components 0.129.8 → 0.129.10

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 (282) hide show
  1. package/CHANGELOG.md +37 -3
  2. package/demo/data/flows/sample-flow.json +186 -96
  3. package/demo/test-colorpicker.html +30 -0
  4. package/dist/temba-components.js +1126 -1111
  5. package/dist/temba-components.js.map +1 -1
  6. package/out-tsc/src/events.js.map +1 -1
  7. package/out-tsc/src/excellent/helpers.js +2 -2
  8. package/out-tsc/src/excellent/helpers.js.map +1 -1
  9. package/out-tsc/src/flow/CanvasNode.js +25 -7
  10. package/out-tsc/src/flow/CanvasNode.js.map +1 -1
  11. package/out-tsc/src/flow/Editor.js +11 -1
  12. package/out-tsc/src/flow/Editor.js.map +1 -1
  13. package/out-tsc/src/flow/NodeEditor.js +133 -290
  14. package/out-tsc/src/flow/NodeEditor.js.map +1 -1
  15. package/out-tsc/src/flow/actions/add_input_labels.js +40 -0
  16. package/out-tsc/src/flow/actions/add_input_labels.js.map +1 -1
  17. package/out-tsc/src/flow/actions/call_llm.js +56 -3
  18. package/out-tsc/src/flow/actions/call_llm.js.map +1 -1
  19. package/out-tsc/src/flow/actions/call_webhook.js +1 -1
  20. package/out-tsc/src/flow/actions/call_webhook.js.map +1 -1
  21. package/out-tsc/src/flow/actions/open_ticket.js +65 -3
  22. package/out-tsc/src/flow/actions/open_ticket.js.map +1 -1
  23. package/out-tsc/src/flow/actions/set_run_result.js +75 -0
  24. package/out-tsc/src/flow/actions/set_run_result.js.map +1 -1
  25. package/out-tsc/src/flow/config.js +4 -0
  26. package/out-tsc/src/flow/config.js.map +1 -1
  27. package/out-tsc/src/flow/nodes/split_by_llm_categorize.js +227 -0
  28. package/out-tsc/src/flow/nodes/split_by_llm_categorize.js.map +1 -0
  29. package/out-tsc/src/flow/nodes/split_by_ticket.js +18 -0
  30. package/out-tsc/src/flow/nodes/split_by_ticket.js.map +1 -0
  31. package/out-tsc/src/flow/nodes/wait_for_response.js +27 -1
  32. package/out-tsc/src/flow/nodes/wait_for_response.js.map +1 -1
  33. package/out-tsc/src/flow/types.js +0 -65
  34. package/out-tsc/src/flow/types.js.map +1 -1
  35. package/out-tsc/src/form/ArrayEditor.js +63 -117
  36. package/out-tsc/src/form/ArrayEditor.js.map +1 -1
  37. package/out-tsc/src/form/BaseListEditor.js +4 -3
  38. package/out-tsc/src/form/BaseListEditor.js.map +1 -1
  39. package/out-tsc/src/form/Checkbox.js +77 -24
  40. package/out-tsc/src/form/Checkbox.js.map +1 -1
  41. package/out-tsc/src/form/ColorPicker.js +28 -40
  42. package/out-tsc/src/form/ColorPicker.js.map +1 -1
  43. package/out-tsc/src/form/Completion.js +44 -53
  44. package/out-tsc/src/form/Completion.js.map +1 -1
  45. package/out-tsc/src/form/Compose.js +7 -8
  46. package/out-tsc/src/form/Compose.js.map +1 -1
  47. package/out-tsc/src/form/ContactSearch.js +3 -4
  48. package/out-tsc/src/form/ContactSearch.js.map +1 -1
  49. package/out-tsc/src/form/DatePicker.js +29 -36
  50. package/out-tsc/src/form/DatePicker.js.map +1 -1
  51. package/out-tsc/src/form/{FormField.js → FieldElement.js} +81 -53
  52. package/out-tsc/src/form/FieldElement.js.map +1 -0
  53. package/out-tsc/src/form/FieldRenderer.js +306 -0
  54. package/out-tsc/src/form/FieldRenderer.js.map +1 -0
  55. package/out-tsc/src/form/ImagePicker.js +122 -126
  56. package/out-tsc/src/form/ImagePicker.js.map +1 -1
  57. package/out-tsc/src/form/KeyValueEditor.js +41 -37
  58. package/out-tsc/src/form/KeyValueEditor.js.map +1 -1
  59. package/out-tsc/src/form/MessageEditor.js +55 -63
  60. package/out-tsc/src/form/MessageEditor.js.map +1 -1
  61. package/out-tsc/src/form/TembaSlider.js +3 -3
  62. package/out-tsc/src/form/TembaSlider.js.map +1 -1
  63. package/out-tsc/src/form/TemplateEditor.js +3 -3
  64. package/out-tsc/src/form/TemplateEditor.js.map +1 -1
  65. package/out-tsc/src/form/TextInput.js +23 -27
  66. package/out-tsc/src/form/TextInput.js.map +1 -1
  67. package/out-tsc/src/form/select/Select.js +57 -35
  68. package/out-tsc/src/form/select/Select.js.map +1 -1
  69. package/out-tsc/src/form/select/UserSelect.js +8 -9
  70. package/out-tsc/src/form/select/UserSelect.js.map +1 -1
  71. package/out-tsc/src/form/select/WorkspaceSelect.js +7 -8
  72. package/out-tsc/src/form/select/WorkspaceSelect.js.map +1 -1
  73. package/out-tsc/src/live/ContactChat.js +62 -44
  74. package/out-tsc/src/live/ContactChat.js.map +1 -1
  75. package/out-tsc/src/live/ContactFieldEditor.js.map +1 -1
  76. package/out-tsc/src/markdown.js +13 -11
  77. package/out-tsc/src/markdown.js.map +1 -1
  78. package/out-tsc/temba-modules.js +3 -2
  79. package/out-tsc/temba-modules.js.map +1 -1
  80. package/out-tsc/test/ActionHelper.js +2 -0
  81. package/out-tsc/test/ActionHelper.js.map +1 -1
  82. package/out-tsc/test/NodeHelper.js +148 -0
  83. package/out-tsc/test/NodeHelper.js.map +1 -0
  84. package/out-tsc/test/actions/call_llm.test.js +103 -0
  85. package/out-tsc/test/actions/call_llm.test.js.map +1 -0
  86. package/out-tsc/test/nodes/split_by_llm_categorize.test.js +532 -0
  87. package/out-tsc/test/nodes/split_by_llm_categorize.test.js.map +1 -0
  88. package/out-tsc/test/nodes/split_by_random.test.js +150 -0
  89. package/out-tsc/test/nodes/split_by_random.test.js.map +1 -0
  90. package/out-tsc/test/nodes/wait_for_digits.test.js +150 -0
  91. package/out-tsc/test/nodes/wait_for_digits.test.js.map +1 -0
  92. package/out-tsc/test/nodes/wait_for_response.test.js +171 -0
  93. package/out-tsc/test/nodes/wait_for_response.test.js.map +1 -0
  94. package/out-tsc/test/temba-add-input-labels.test.js +70 -0
  95. package/out-tsc/test/temba-add-input-labels.test.js.map +1 -0
  96. package/out-tsc/test/temba-checkbox.test.js +16 -0
  97. package/out-tsc/test/temba-checkbox.test.js.map +1 -1
  98. package/out-tsc/test/temba-field-renderer.test.js +296 -0
  99. package/out-tsc/test/temba-field-renderer.test.js.map +1 -0
  100. package/out-tsc/test/temba-integration-markdown.test.js +2 -4
  101. package/out-tsc/test/temba-integration-markdown.test.js.map +1 -1
  102. package/out-tsc/test/temba-markdown.test.js +1 -1
  103. package/out-tsc/test/temba-markdown.test.js.map +1 -1
  104. package/out-tsc/test/temba-node-editor.test.js +400 -0
  105. package/out-tsc/test/temba-node-editor.test.js.map +1 -1
  106. package/out-tsc/test/temba-select.test.js +6 -3
  107. package/out-tsc/test/temba-select.test.js.map +1 -1
  108. package/out-tsc/test/temba-slider.test.js +0 -1
  109. package/out-tsc/test/temba-slider.test.js.map +1 -1
  110. package/out-tsc/test/temba-webchat.test.js +1 -1
  111. package/out-tsc/test/temba-webchat.test.js.map +1 -1
  112. package/package.json +1 -1
  113. package/screenshots/truth/actions/add_contact_groups/editor/descriptive-group-names.png +0 -0
  114. package/screenshots/truth/actions/add_contact_groups/editor/long-group-names.png +0 -0
  115. package/screenshots/truth/actions/add_contact_groups/editor/many-groups.png +0 -0
  116. package/screenshots/truth/actions/call_llm/editor/information-extraction.png +0 -0
  117. package/screenshots/truth/actions/call_llm/editor/sentiment-analysis.png +0 -0
  118. package/screenshots/truth/actions/call_llm/editor/summarization.png +0 -0
  119. package/screenshots/truth/actions/call_llm/editor/translation-task.png +0 -0
  120. package/screenshots/truth/actions/call_llm/render/information-extraction.png +0 -0
  121. package/screenshots/truth/actions/call_llm/render/sentiment-analysis.png +0 -0
  122. package/screenshots/truth/actions/call_llm/render/summarization.png +0 -0
  123. package/screenshots/truth/actions/call_llm/render/translation-task.png +0 -0
  124. package/screenshots/truth/actions/remove_contact_groups/editor/cleanup-groups.png +0 -0
  125. package/screenshots/truth/actions/remove_contact_groups/editor/long-descriptive-group-names.png +0 -0
  126. package/screenshots/truth/actions/remove_contact_groups/editor/many-groups.png +0 -0
  127. package/screenshots/truth/actions/remove_contact_groups/editor/multiple-groups.png +0 -0
  128. package/screenshots/truth/actions/remove_contact_groups/editor/remove-from-all-groups.png +0 -0
  129. package/screenshots/truth/actions/remove_contact_groups/editor/single-group.png +0 -0
  130. package/screenshots/truth/actions/send_email/editor/complex-business-email.png +0 -0
  131. package/screenshots/truth/actions/send_email/editor/multiple-recipients.png +0 -0
  132. package/screenshots/truth/actions/send_msg/editor/long-quick-replies.png +0 -0
  133. package/screenshots/truth/actions/send_msg/editor/multiline-text-with-replies.png +0 -0
  134. package/screenshots/truth/actions/send_msg/editor/simple-text.png +0 -0
  135. package/screenshots/truth/actions/send_msg/editor/text-with-linebreaks.png +0 -0
  136. package/screenshots/truth/actions/send_msg/editor/text-with-many-quick-replies.png +0 -0
  137. package/screenshots/truth/actions/send_msg/editor/text-with-quick-replies.png +0 -0
  138. package/screenshots/truth/actions/send_msg/editor/text-without-quick-replies.png +0 -0
  139. package/screenshots/truth/checkbox/checkbox-label-background-hover.png +0 -0
  140. package/screenshots/truth/checkbox/checkbox-no-label-no-background-hover.png +0 -0
  141. package/screenshots/truth/checkbox/checkbox-with-help-text.png +0 -0
  142. package/screenshots/truth/checkbox/checked.png +0 -0
  143. package/screenshots/truth/checkbox/default.png +0 -0
  144. package/screenshots/truth/colorpicker/default.png +0 -0
  145. package/screenshots/truth/colorpicker/focused.png +0 -0
  146. package/screenshots/truth/colorpicker/initialized.png +0 -0
  147. package/screenshots/truth/colorpicker/selected.png +0 -0
  148. package/screenshots/truth/editor/router.png +0 -0
  149. package/screenshots/truth/editor/send_msg.png +0 -0
  150. package/screenshots/truth/editor/set_contact_language.png +0 -0
  151. package/screenshots/truth/editor/set_contact_name.png +0 -0
  152. package/screenshots/truth/editor/set_run_result.png +0 -0
  153. package/screenshots/truth/editor/wait.png +0 -0
  154. package/screenshots/truth/field-renderer/checkbox-checked.png +0 -0
  155. package/screenshots/truth/field-renderer/checkbox-unchecked.png +0 -0
  156. package/screenshots/truth/field-renderer/checkbox-with-errors.png +0 -0
  157. package/screenshots/truth/field-renderer/context-comparison.png +0 -0
  158. package/screenshots/truth/field-renderer/key-value-with-label.png +0 -0
  159. package/screenshots/truth/field-renderer/message-editor-with-label.png +0 -0
  160. package/screenshots/truth/field-renderer/select-multi.png +0 -0
  161. package/screenshots/truth/field-renderer/select-no-label.png +0 -0
  162. package/screenshots/truth/field-renderer/select-with-label.png +0 -0
  163. package/screenshots/truth/field-renderer/text-evaluated.png +0 -0
  164. package/screenshots/truth/field-renderer/text-no-label.png +0 -0
  165. package/screenshots/truth/field-renderer/text-with-errors.png +0 -0
  166. package/screenshots/truth/field-renderer/text-with-label.png +0 -0
  167. package/screenshots/truth/field-renderer/textarea-evaluated.png +0 -0
  168. package/screenshots/truth/field-renderer/textarea-with-label.png +0 -0
  169. package/screenshots/truth/integration/checkbox-markdown-errors.png +0 -0
  170. package/screenshots/truth/nodes/split_by_llm_categorize/editor/basic-categorization.png +0 -0
  171. package/screenshots/truth/nodes/split_by_llm_categorize/editor/custom-input-and-result-name.png +0 -0
  172. package/screenshots/truth/nodes/split_by_llm_categorize/editor/feedback-categorization.png +0 -0
  173. package/screenshots/truth/nodes/split_by_llm_categorize/editor/many-categories.png +0 -0
  174. package/screenshots/truth/nodes/split_by_llm_categorize/editor/minimal-categories.png +0 -0
  175. package/screenshots/truth/nodes/split_by_llm_categorize/render/basic-categorization.png +0 -0
  176. package/screenshots/truth/nodes/split_by_llm_categorize/render/custom-input-and-result-name.png +0 -0
  177. package/screenshots/truth/nodes/split_by_llm_categorize/render/feedback-categorization.png +0 -0
  178. package/screenshots/truth/nodes/split_by_llm_categorize/render/many-categories.png +0 -0
  179. package/screenshots/truth/nodes/split_by_llm_categorize/render/minimal-categories.png +0 -0
  180. package/screenshots/truth/nodes/split_by_random/editor/ab-test-multiple-variants.png +0 -0
  181. package/screenshots/truth/nodes/split_by_random/editor/sampling-split.png +0 -0
  182. package/screenshots/truth/nodes/split_by_random/editor/three-way-split.png +0 -0
  183. package/screenshots/truth/nodes/split_by_random/editor/two-bucket-split.png +0 -0
  184. package/screenshots/truth/nodes/split_by_random/render/ab-test-multiple-variants.png +0 -0
  185. package/screenshots/truth/nodes/split_by_random/render/sampling-split.png +0 -0
  186. package/screenshots/truth/nodes/split_by_random/render/three-way-split.png +0 -0
  187. package/screenshots/truth/nodes/split_by_random/render/two-bucket-split.png +0 -0
  188. package/screenshots/truth/nodes/wait_for_digits/editor/basic-digits-wait.png +0 -0
  189. package/screenshots/truth/nodes/wait_for_digits/editor/phone-number-collection.png +0 -0
  190. package/screenshots/truth/nodes/wait_for_digits/editor/single-digit-with-timeout.png +0 -0
  191. package/screenshots/truth/nodes/wait_for_digits/editor/verification-code.png +0 -0
  192. package/screenshots/truth/nodes/wait_for_digits/render/basic-digits-wait.png +0 -0
  193. package/screenshots/truth/nodes/wait_for_digits/render/phone-number-collection.png +0 -0
  194. package/screenshots/truth/nodes/wait_for_digits/render/single-digit-with-timeout.png +0 -0
  195. package/screenshots/truth/nodes/wait_for_digits/render/verification-code.png +0 -0
  196. package/screenshots/truth/nodes/wait_for_response/editor/basic-wait.png +0 -0
  197. package/screenshots/truth/nodes/wait_for_response/editor/custom-result-name.png +0 -0
  198. package/screenshots/truth/nodes/wait_for_response/editor/no-timeout.png +0 -0
  199. package/screenshots/truth/nodes/wait_for_response/editor/short-timeout.png +0 -0
  200. package/screenshots/truth/nodes/wait_for_response/render/basic-wait.png +0 -0
  201. package/screenshots/truth/nodes/wait_for_response/render/custom-result-name.png +0 -0
  202. package/screenshots/truth/nodes/wait_for_response/render/no-timeout.png +0 -0
  203. package/screenshots/truth/nodes/wait_for_response/render/short-timeout.png +0 -0
  204. package/screenshots/truth/omnibox/selected.png +0 -0
  205. package/screenshots/truth/run-list/basic.png +0 -0
  206. package/screenshots/truth/select/functions.png +0 -0
  207. package/screenshots/truth/select/multi-with-endpoint.png +0 -0
  208. package/screenshots/truth/select/search-enabled.png +0 -0
  209. package/src/events.ts +12 -6
  210. package/src/excellent/helpers.ts +2 -2
  211. package/src/flow/CanvasNode.ts +22 -1
  212. package/src/flow/Editor.ts +12 -1
  213. package/src/flow/NodeEditor.ts +186 -374
  214. package/src/flow/actions/add_input_labels.ts +45 -0
  215. package/src/flow/actions/call_llm.ts +57 -3
  216. package/src/flow/actions/call_webhook.ts +1 -1
  217. package/src/flow/actions/open_ticket.ts +74 -3
  218. package/src/flow/actions/set_run_result.ts +83 -0
  219. package/src/flow/config.ts +4 -0
  220. package/src/flow/nodes/split_by_llm_categorize.ts +277 -0
  221. package/src/flow/nodes/split_by_ticket.ts +19 -0
  222. package/src/flow/nodes/wait_for_response.ts +28 -1
  223. package/src/flow/types.ts +26 -127
  224. package/src/form/ArrayEditor.ts +79 -139
  225. package/src/form/BaseListEditor.ts +4 -4
  226. package/src/form/Checkbox.ts +81 -24
  227. package/src/form/ColorPicker.ts +31 -43
  228. package/src/form/Completion.ts +49 -56
  229. package/src/form/Compose.ts +8 -8
  230. package/src/form/ContactSearch.ts +3 -4
  231. package/src/form/DatePicker.ts +32 -38
  232. package/src/form/{FormField.ts → FieldElement.ts} +108 -55
  233. package/src/form/FieldRenderer.ts +466 -0
  234. package/src/form/ImagePicker.ts +107 -110
  235. package/src/form/KeyValueEditor.ts +43 -39
  236. package/src/form/MessageEditor.ts +61 -67
  237. package/src/form/TembaSlider.ts +3 -3
  238. package/src/form/TemplateEditor.ts +3 -3
  239. package/src/form/TextInput.ts +26 -29
  240. package/src/form/select/Select.ts +63 -37
  241. package/src/form/select/UserSelect.ts +10 -11
  242. package/src/form/select/WorkspaceSelect.ts +9 -10
  243. package/src/live/ContactChat.ts +62 -47
  244. package/src/live/ContactFieldEditor.ts +2 -2
  245. package/src/markdown.ts +19 -11
  246. package/src/store/flow-definition.d.ts +5 -2
  247. package/static/api/labels.json +31 -0
  248. package/static/api/topics.json +24 -9
  249. package/static/api/users.json +35 -16
  250. package/static/css/temba-components.css +3 -3
  251. package/stress-test.js +18 -13
  252. package/temba-modules.ts +3 -2
  253. package/test/ActionHelper.ts +2 -0
  254. package/test/NodeHelper.ts +184 -0
  255. package/test/actions/call_llm.test.ts +137 -0
  256. package/test/nodes/README.md +78 -0
  257. package/test/nodes/split_by_llm_categorize.test.ts +698 -0
  258. package/test/nodes/split_by_random.test.ts +177 -0
  259. package/test/nodes/wait_for_digits.test.ts +176 -0
  260. package/test/nodes/wait_for_response.test.ts +206 -0
  261. package/test/temba-add-input-labels.test.ts +87 -0
  262. package/test/temba-checkbox.test.ts +26 -0
  263. package/test/temba-field-renderer.test.ts +482 -0
  264. package/test/temba-integration-markdown.test.ts +2 -4
  265. package/test/temba-markdown.test.ts +1 -1
  266. package/test/temba-node-editor.test.ts +496 -0
  267. package/test/temba-select.test.ts +6 -6
  268. package/test/temba-slider.test.ts +0 -1
  269. package/test/temba-webchat.test.ts +1 -1
  270. package/test-assets/contacts/history.json +7 -20
  271. package/test-assets/select/llms.json +18 -0
  272. package/web-dev-mock.mjs +96 -6
  273. package/web-dev-server.config.mjs +29 -7
  274. package/out-tsc/src/form/FormElement.js +0 -67
  275. package/out-tsc/src/form/FormElement.js.map +0 -1
  276. package/out-tsc/src/form/FormField.js.map +0 -1
  277. package/out-tsc/test/temba-formfield.test.js +0 -94
  278. package/out-tsc/test/temba-formfield.test.js.map +0 -1
  279. package/src/form/FormElement.ts +0 -69
  280. package/test/temba-flow-editor.test.ts.backup +0 -563
  281. package/test/temba-formfield.test.ts +0 -121
  282. package/test/temba-utils-index.test.ts.backup +0 -1737
@@ -0,0 +1,698 @@
1
+ import { expect } from '@open-wc/testing';
2
+ import { split_by_llm_categorize } from '../../src/flow/nodes/split_by_llm_categorize';
3
+ import { Node } from '../../src/store/flow-definition';
4
+ import { NodeTest } from '../NodeHelper';
5
+
6
+ // Helper function to create routers with proper cases and exits
7
+ function createSplitRouter(categoryNames: string[]) {
8
+ const categories = [];
9
+ const exits = [];
10
+ const cases = [];
11
+
12
+ // Add user categories
13
+ categoryNames.forEach((categoryName) => {
14
+ const categoryUuid = `category-${categoryName
15
+ .toLowerCase()
16
+ .replace(/\s+/g, '-')}`;
17
+ const exitUuid = `exit-${categoryName.toLowerCase().replace(/\s+/g, '-')}`;
18
+ const caseUuid = `case-${categoryName.toLowerCase().replace(/\s+/g, '-')}`;
19
+
20
+ categories.push({
21
+ uuid: categoryUuid,
22
+ name: categoryName,
23
+ exit_uuid: exitUuid
24
+ });
25
+
26
+ exits.push({
27
+ uuid: exitUuid,
28
+ destination_uuid: null
29
+ });
30
+
31
+ cases.push({
32
+ uuid: caseUuid,
33
+ type: 'has_only_text',
34
+ arguments: [categoryName],
35
+ category_uuid: categoryUuid
36
+ });
37
+ });
38
+
39
+ // Add "Other" category (default)
40
+ const otherCategoryUuid = 'category-other';
41
+ const otherExitUuid = 'exit-other';
42
+
43
+ categories.push({
44
+ uuid: otherCategoryUuid,
45
+ name: 'Other',
46
+ exit_uuid: otherExitUuid
47
+ });
48
+ exits.push({
49
+ uuid: otherExitUuid,
50
+ destination_uuid: null
51
+ });
52
+
53
+ // Add "Failure" category
54
+ const failureCategoryUuid = 'category-failure';
55
+ const failureExitUuid = 'exit-failure';
56
+ const failureCaseUuid = 'case-failure';
57
+
58
+ categories.push({
59
+ uuid: failureCategoryUuid,
60
+ name: 'Failure',
61
+ exit_uuid: failureExitUuid
62
+ });
63
+ exits.push({
64
+ uuid: failureExitUuid,
65
+ destination_uuid: null
66
+ });
67
+
68
+ // Add failure case for <ERROR>
69
+ cases.push({
70
+ uuid: failureCaseUuid,
71
+ type: 'has_only_text',
72
+ arguments: ['<ERROR>'],
73
+ category_uuid: failureCategoryUuid
74
+ });
75
+
76
+ return {
77
+ router: {
78
+ type: 'switch' as const,
79
+ categories: categories,
80
+ default_category_uuid: otherCategoryUuid,
81
+ operand: '@locals._llm_output',
82
+ cases: cases
83
+ },
84
+ exits: exits
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Test suite for the split_by_llm_categorize node configuration.
90
+ */
91
+ describe('split_by_llm_categorize node config', () => {
92
+ const helper = new NodeTest(
93
+ split_by_llm_categorize,
94
+ 'split_by_llm_categorize'
95
+ );
96
+
97
+ describe('basic properties', () => {
98
+ helper.testBasicProperties();
99
+
100
+ it('has correct name', () => {
101
+ expect(split_by_llm_categorize.name).to.equal('Split by AI');
102
+ });
103
+
104
+ it('has correct type', () => {
105
+ expect(split_by_llm_categorize.type).to.equal('split_by_llm_categorize');
106
+ });
107
+ });
108
+
109
+ describe('node scenarios', () => {
110
+ const basicRouter = createSplitRouter(['Greeting', 'Question']);
111
+ helper.testNode(
112
+ {
113
+ uuid: 'test-node-1',
114
+ actions: [
115
+ {
116
+ uuid: 'call-llm-uuid',
117
+ type: 'call_llm',
118
+ llm: { uuid: 'llm-123', name: 'Claude' },
119
+ input: '@input',
120
+ instructions:
121
+ '@(prompt("categorize", slice(node.categories, 0, -2)))',
122
+ output_local: '_llm_output'
123
+ } as any
124
+ ],
125
+ router: basicRouter.router,
126
+ exits: basicRouter.exits
127
+ } as Node,
128
+ { type: 'split_by_llm_categorize' },
129
+ 'basic-categorization'
130
+ );
131
+
132
+ const premiumRouter = createSplitRouter(['Premium', 'Regular', 'VIP']);
133
+ helper.testNode(
134
+ {
135
+ uuid: 'test-node-2',
136
+ actions: [
137
+ {
138
+ uuid: 'call-llm-uuid-2',
139
+ type: 'call_llm',
140
+ llm: { uuid: 'llm-456', name: 'GPT-4' },
141
+ input: '@contact.name',
142
+ instructions:
143
+ '@(prompt("categorize", slice(node.categories, 0, -2)))',
144
+ output_local: '_llm_output'
145
+ } as any
146
+ ],
147
+ router: premiumRouter.router,
148
+ exits: premiumRouter.exits
149
+ } as Node,
150
+ { type: 'split_by_llm_categorize' },
151
+ 'custom-input-and-result-name'
152
+ );
153
+
154
+ const priorityRouter = createSplitRouter([
155
+ 'High',
156
+ 'Medium',
157
+ 'Low',
158
+ 'Critical',
159
+ 'Urgent'
160
+ ]);
161
+ helper.testNode(
162
+ {
163
+ uuid: 'test-node-3',
164
+ actions: [
165
+ {
166
+ uuid: 'call-llm-uuid-3',
167
+ type: 'call_llm',
168
+ llm: { uuid: 'llm-789', name: 'Gemini' },
169
+ input: '@fields.priority',
170
+ instructions:
171
+ '@(prompt("categorize", slice(node.categories, 0, -2)))',
172
+ output_local: '_llm_output'
173
+ } as any
174
+ ],
175
+ router: priorityRouter.router,
176
+ exits: priorityRouter.exits
177
+ } as Node,
178
+ { type: 'split_by_llm_categorize' },
179
+ 'many-categories'
180
+ );
181
+
182
+ const minimalRouter = createSplitRouter(['Yes']);
183
+ helper.testNode(
184
+ {
185
+ uuid: 'test-node-4',
186
+ actions: [
187
+ {
188
+ uuid: 'call-llm-uuid-4',
189
+ type: 'call_llm',
190
+ llm: { uuid: 'llm-minimal', name: 'Basic LLM' },
191
+ input: '@input',
192
+ instructions:
193
+ '@(prompt("categorize", slice(node.categories, 0, -2)))',
194
+ output_local: '_llm_output'
195
+ } as any
196
+ ],
197
+ router: minimalRouter.router,
198
+ exits: minimalRouter.exits
199
+ } as Node,
200
+ { type: 'split_by_llm_categorize' },
201
+ 'minimal-categories'
202
+ );
203
+
204
+ const feedbackRouter = createSplitRouter([
205
+ 'Bug Report',
206
+ 'Feature Request',
207
+ 'General Feedback',
208
+ 'Support Request'
209
+ ]);
210
+ helper.testNode(
211
+ {
212
+ uuid: 'test-node-5',
213
+ actions: [
214
+ {
215
+ uuid: 'call-llm-uuid-5',
216
+ type: 'call_llm',
217
+ llm: { uuid: 'llm-special', name: 'Special Characters LLM' },
218
+ input: '@contact.fields.feedback',
219
+ instructions:
220
+ '@(prompt("categorize", slice(node.categories, 0, -2)))',
221
+ output_local: '_llm_output'
222
+ } as any
223
+ ],
224
+ router: feedbackRouter.router,
225
+ exits: feedbackRouter.exits
226
+ } as Node,
227
+ { type: 'split_by_llm_categorize' },
228
+ 'feedback-categorization'
229
+ );
230
+ });
231
+
232
+ describe('round-trip conversion validation', () => {
233
+ it('converts to form data correctly', () => {
234
+ const testRouter = createSplitRouter(['Greeting', 'Question']);
235
+ const node: Node = {
236
+ uuid: 'test-node',
237
+ actions: [
238
+ {
239
+ uuid: 'call-llm-uuid',
240
+ type: 'call_llm',
241
+ llm: { uuid: 'llm-123', name: 'Test LLM' },
242
+ input: '@input',
243
+ instructions:
244
+ '@(prompt("categorize", slice(node.categories, 0, -2)))',
245
+ output_local: '_llm_output'
246
+ } as any
247
+ ],
248
+ router: testRouter.router,
249
+ exits: testRouter.exits
250
+ };
251
+
252
+ const formData = split_by_llm_categorize.toFormData!(node);
253
+
254
+ expect(formData.uuid).to.equal('test-node');
255
+ expect(formData.llm).to.deep.equal([
256
+ { value: 'llm-123', name: 'Test LLM' }
257
+ ]);
258
+ expect(formData.input).to.equal('@input');
259
+ expect(formData.categories).to.deep.equal([
260
+ { name: 'Greeting' },
261
+ { name: 'Question' }
262
+ ]);
263
+ });
264
+
265
+ it('converts from form data correctly', () => {
266
+ const formData = {
267
+ uuid: 'test-node',
268
+ llm: [{ value: 'llm-456', name: 'GPT-4' }],
269
+ input: '@contact.name',
270
+ categories: [{ name: 'Premium' }, { name: 'Regular' }]
271
+ };
272
+
273
+ const originalNode: Node = {
274
+ uuid: 'test-node',
275
+ actions: [],
276
+ exits: []
277
+ };
278
+
279
+ const result = split_by_llm_categorize.fromFormData!(
280
+ formData,
281
+ originalNode
282
+ );
283
+
284
+ expect(result.uuid).to.equal('test-node');
285
+ expect(result.actions).to.have.length(1);
286
+ expect(result.actions[0].type).to.equal('call_llm');
287
+ expect((result.actions[0] as any).llm.uuid).to.equal('llm-456');
288
+ expect((result.actions[0] as any).llm.name).to.equal('GPT-4');
289
+ expect((result.actions[0] as any).input).to.equal('@contact.name');
290
+
291
+ // Should have user categories plus Other and Failure
292
+ expect(result.router!.categories).to.have.length(4);
293
+ const categoryNames = result.router!.categories.map((cat) => cat.name);
294
+ expect(categoryNames).to.include.members([
295
+ 'Premium',
296
+ 'Regular',
297
+ 'Other',
298
+ 'Failure'
299
+ ]);
300
+
301
+ // Should have corresponding exits
302
+ expect(result.exits).to.have.length(4);
303
+ });
304
+ });
305
+
306
+ describe('edge cases and validation', () => {
307
+ it('handles categories with empty names correctly', () => {
308
+ const formData = {
309
+ uuid: 'test-node-uuid',
310
+ llm: [{ value: 'llm-uuid-123', name: 'Claude' }],
311
+ input: '@input',
312
+ categories: [
313
+ { name: 'Valid Category' },
314
+ { name: '' }, // empty name
315
+ { name: ' ' }, // only whitespace
316
+ { name: 'Another Valid' }
317
+ ],
318
+ result_name: 'Intent'
319
+ };
320
+
321
+ const originalNode: Node = {
322
+ uuid: 'test-node-uuid',
323
+ actions: [],
324
+ exits: []
325
+ };
326
+
327
+ const result = split_by_llm_categorize.fromFormData!(
328
+ formData,
329
+ originalNode
330
+ );
331
+
332
+ // Should only include non-empty categories
333
+ const userCategories = result.router!.categories.filter(
334
+ (cat) => cat.name !== 'Other' && cat.name !== 'Failure'
335
+ );
336
+ expect(userCategories).to.have.length(2);
337
+ expect(userCategories.map((cat) => cat.name)).to.deep.equal([
338
+ 'Valid Category',
339
+ 'Another Valid'
340
+ ]);
341
+ });
342
+
343
+ it('handles categories with special characters', () => {
344
+ const formData = {
345
+ uuid: 'test-node-uuid',
346
+ llm: [{ value: 'llm-uuid-123', name: 'Claude' }],
347
+ input: '@input',
348
+ categories: [
349
+ { name: 'Category-1' },
350
+ { name: 'Category_2' },
351
+ { name: 'Category@3' },
352
+ { name: 'Category with spaces' }
353
+ ],
354
+ result_name: 'Intent'
355
+ };
356
+
357
+ const originalNode: Node = {
358
+ uuid: 'test-node-uuid',
359
+ actions: [],
360
+ exits: []
361
+ };
362
+
363
+ const result = split_by_llm_categorize.fromFormData!(
364
+ formData,
365
+ originalNode
366
+ );
367
+
368
+ // Should preserve all special characters in category names
369
+ const userCategories = result.router!.categories.filter(
370
+ (cat) => cat.name !== 'Other' && cat.name !== 'Failure'
371
+ );
372
+ expect(userCategories).to.have.length(4);
373
+ expect(userCategories.map((cat) => cat.name)).to.include.members([
374
+ 'Category-1',
375
+ 'Category_2',
376
+ 'Category@3',
377
+ 'Category with spaces'
378
+ ]);
379
+
380
+ // Verify cases also have correct names
381
+ const caseNames = result
382
+ .router!.cases.filter((c) => c.arguments[0] !== '<ERROR>')
383
+ .map((c) => c.arguments[0]);
384
+ expect(caseNames).to.include.members([
385
+ 'Category-1',
386
+ 'Category_2',
387
+ 'Category@3',
388
+ 'Category with spaces'
389
+ ]);
390
+ });
391
+
392
+ it('maintains UUID consistency between categories, cases, and exits', () => {
393
+ const formData = {
394
+ uuid: 'test-node-uuid',
395
+ llm: [{ value: 'llm-uuid-123', name: 'Claude' }],
396
+ input: '@input',
397
+ categories: [{ name: 'Test Category' }],
398
+ result_name: 'Intent'
399
+ };
400
+
401
+ const originalNode: Node = {
402
+ uuid: 'test-node-uuid',
403
+ actions: [],
404
+ exits: []
405
+ };
406
+
407
+ const result = split_by_llm_categorize.fromFormData!(
408
+ formData,
409
+ originalNode
410
+ );
411
+
412
+ // Find the test category
413
+ const testCategory = result.router!.categories.find(
414
+ (cat) => cat.name === 'Test Category'
415
+ );
416
+ const testCase = result.router!.cases.find(
417
+ (c) => c.arguments[0] === 'Test Category'
418
+ );
419
+ const testExit = result.exits.find(
420
+ (exit) => exit.uuid === testCategory!.exit_uuid
421
+ );
422
+
423
+ // Verify UUID consistency
424
+ expect(testCase!.category_uuid).to.equal(testCategory!.uuid);
425
+ expect(testExit!.uuid).to.equal(testCategory!.exit_uuid);
426
+ });
427
+
428
+ it('generates unique UUIDs for each run', () => {
429
+ const formData = {
430
+ uuid: 'test-node-uuid',
431
+ llm: [{ value: 'llm-uuid-123', name: 'Claude' }],
432
+ input: '@input',
433
+ categories: [{ name: 'Test' }],
434
+ result_name: 'Intent'
435
+ };
436
+
437
+ const originalNode: Node = {
438
+ uuid: 'test-node-uuid',
439
+ actions: [],
440
+ exits: []
441
+ };
442
+
443
+ const result1 = split_by_llm_categorize.fromFormData!(
444
+ formData,
445
+ originalNode
446
+ );
447
+ const result2 = split_by_llm_categorize.fromFormData!(
448
+ formData,
449
+ originalNode
450
+ );
451
+
452
+ // UUIDs should be different for each generation
453
+ expect(result1.actions[0].uuid).to.not.equal(result2.actions[0].uuid);
454
+ expect(result1.router!.categories[0].uuid).to.not.equal(
455
+ result2.router!.categories[0].uuid
456
+ );
457
+ expect(result1.exits[0].uuid).to.not.equal(result2.exits[0].uuid);
458
+ });
459
+
460
+ it('roundtrip conversion (fromFormData -> toFormData) works correctly', () => {
461
+ const originalFormData = {
462
+ uuid: 'test-node-uuid',
463
+ llm: [{ value: 'llm-uuid-123', name: 'Claude' }],
464
+ input: '@custom.input',
465
+ categories: [{ name: 'Category1' }, { name: 'Category2' }],
466
+ result_name: 'CustomResult'
467
+ };
468
+
469
+ const originalNode: Node = {
470
+ uuid: 'test-node-uuid',
471
+ actions: [],
472
+ exits: []
473
+ };
474
+
475
+ // Convert form data to node
476
+ const node = split_by_llm_categorize.fromFormData!(
477
+ originalFormData,
478
+ originalNode
479
+ );
480
+
481
+ // Convert back to form data
482
+ const recoveredFormData = split_by_llm_categorize.toFormData!(node);
483
+
484
+ // Should match original data
485
+ expect(recoveredFormData.uuid).to.equal(originalFormData.uuid);
486
+ expect(recoveredFormData.llm).to.deep.equal(originalFormData.llm);
487
+ expect(recoveredFormData.input).to.equal(originalFormData.input);
488
+ expect(recoveredFormData.categories).to.deep.equal(
489
+ originalFormData.categories
490
+ );
491
+ });
492
+
493
+ it('handles max 10 categories requirement', () => {
494
+ // Create 12 categories to test the limit
495
+ const categories = Array.from({ length: 12 }, (_, i) => ({
496
+ name: `Category${i + 1}`
497
+ }));
498
+
499
+ const formData = {
500
+ uuid: 'test-node-uuid',
501
+ llm: [{ value: 'llm-uuid-123', name: 'Claude' }],
502
+ input: '@input',
503
+ categories: categories,
504
+ result_name: 'Intent'
505
+ };
506
+
507
+ const originalNode: Node = {
508
+ uuid: 'test-node-uuid',
509
+ actions: [],
510
+ exits: []
511
+ };
512
+
513
+ const result = split_by_llm_categorize.fromFormData!(
514
+ formData,
515
+ originalNode
516
+ );
517
+
518
+ // Should process all categories provided (fromFormData doesn't enforce the limit, validation should)
519
+ const userCategories = result.router!.categories.filter(
520
+ (cat) => cat.name !== 'Other' && cat.name !== 'Failure'
521
+ );
522
+ expect(userCategories).to.have.length(12);
523
+
524
+ // Note: The actual 10-category limit should be enforced by the UI validation
525
+ // which uses the maxItems: 10 property in the form configuration
526
+ });
527
+
528
+ it('preserves original node UUID', () => {
529
+ const formData = {
530
+ uuid: 'should-be-ignored',
531
+ llm: [{ value: 'llm-uuid-123', name: 'Claude' }],
532
+ input: '@input',
533
+ categories: [{ name: 'Test' }],
534
+ result_name: 'Intent'
535
+ };
536
+
537
+ const originalNode: Node = {
538
+ uuid: 'original-node-uuid',
539
+ actions: [],
540
+ exits: []
541
+ };
542
+
543
+ const result = split_by_llm_categorize.fromFormData!(
544
+ formData,
545
+ originalNode
546
+ );
547
+
548
+ // Should use original node UUID, not the one from form data
549
+ expect(result.uuid).to.equal('original-node-uuid');
550
+ });
551
+ });
552
+
553
+ describe('validation', () => {
554
+ it('should validate duplicate category names', () => {
555
+ const formData = {
556
+ uuid: 'test-node-uuid',
557
+ llm: [{ value: 'llm-uuid-123', name: 'Claude' }],
558
+ input: '@input',
559
+ categories: [
560
+ { name: 'Category1' },
561
+ { name: 'Category2' },
562
+ { name: 'Category1' }, // duplicate
563
+ { name: 'category2' }, // case insensitive duplicate
564
+ { name: 'Category3' }
565
+ ]
566
+ };
567
+
568
+ const validationResult = split_by_llm_categorize.validate!(formData);
569
+
570
+ expect(validationResult.valid).to.be.false;
571
+ expect(validationResult.errors.categories).to.include(
572
+ 'Duplicate category names found'
573
+ );
574
+ expect(validationResult.errors.categories).to.include('Category1');
575
+ expect(validationResult.errors.categories).to.include('Category2');
576
+ expect(validationResult.errors.categories).to.include('category2');
577
+ });
578
+
579
+ it('should pass validation with unique category names', () => {
580
+ const formData = {
581
+ uuid: 'test-node-uuid',
582
+ llm: [{ value: 'llm-uuid-123', name: 'Claude' }],
583
+ input: '@input',
584
+ categories: [
585
+ { name: 'Category1' },
586
+ { name: 'Category2' },
587
+ { name: 'Category3' }
588
+ ]
589
+ };
590
+
591
+ const validationResult = split_by_llm_categorize.validate!(formData);
592
+
593
+ expect(validationResult.valid).to.be.true;
594
+ expect(Object.keys(validationResult.errors)).to.have.length(0);
595
+ });
596
+
597
+ it('should ignore empty categories in validation', () => {
598
+ const formData = {
599
+ uuid: 'test-node-uuid',
600
+ llm: [{ value: 'llm-uuid-123', name: 'Claude' }],
601
+ input: '@input',
602
+ categories: [
603
+ { name: 'Category1' },
604
+ { name: '' }, // empty
605
+ { name: ' ' }, // whitespace only
606
+ { name: 'Category2' }
607
+ ]
608
+ };
609
+
610
+ const validationResult = split_by_llm_categorize.validate!(formData);
611
+
612
+ expect(validationResult.valid).to.be.true;
613
+ expect(Object.keys(validationResult.errors)).to.have.length(0);
614
+ });
615
+ });
616
+
617
+ describe('JSON output verification', () => {
618
+ it('generates JSON matching the exact format from the issue', () => {
619
+ const formData = {
620
+ uuid: '145eb3d3-b841-4e66-abac-297ae525c7ad',
621
+ llm: [
622
+ { value: '1c06c884-39dd-4ce4-ad9f-9a01cbe6c000', name: 'Claude' }
623
+ ],
624
+ input: '@input',
625
+ categories: [{ name: 'Flights' }, { name: 'Hotels' }],
626
+ result_name: 'Intent'
627
+ };
628
+
629
+ const originalNode: Node = {
630
+ uuid: '145eb3d3-b841-4e66-abac-297ae525c7ad',
631
+ actions: [],
632
+ exits: []
633
+ };
634
+
635
+ const result = split_by_llm_categorize.fromFormData!(
636
+ formData,
637
+ originalNode
638
+ );
639
+
640
+ // Verify the call_llm action
641
+ const callLlmAction = result.actions[0] as any;
642
+ expect(callLlmAction.type).to.equal('call_llm');
643
+ expect(callLlmAction.llm.uuid).to.equal(
644
+ '1c06c884-39dd-4ce4-ad9f-9a01cbe6c000'
645
+ );
646
+ expect(callLlmAction.llm.name).to.equal('Claude');
647
+ expect(callLlmAction.instructions).to.equal(
648
+ '@(prompt("categorize", slice(node.categories, 0, -2)))'
649
+ );
650
+ expect(callLlmAction.input).to.equal('@input');
651
+ expect(callLlmAction.output_local).to.equal('_llm_output');
652
+
653
+ // Verify the router structure
654
+ const router = result.router!;
655
+ expect(router.type).to.equal('switch');
656
+ expect(router.operand).to.equal('@locals._llm_output');
657
+
658
+ // Verify categories structure
659
+ expect(router.categories).to.have.length(4);
660
+ const categoryNames = router.categories.map((cat) => cat.name);
661
+ expect(categoryNames).to.include.members([
662
+ 'Flights',
663
+ 'Hotels',
664
+ 'Other',
665
+ 'Failure'
666
+ ]);
667
+
668
+ // Verify cases structure
669
+ expect(router.cases).to.have.length(3);
670
+ const caseArguments = router.cases.map((c) => c.arguments[0]);
671
+ expect(caseArguments).to.include.members([
672
+ 'Flights',
673
+ 'Hotels',
674
+ '<ERROR>'
675
+ ]);
676
+
677
+ // Verify all cases use has_only_text
678
+ router.cases.forEach((caseItem) => {
679
+ expect(caseItem.type).to.equal('has_only_text');
680
+ });
681
+
682
+ // Verify exits match categories
683
+ expect(result.exits).to.have.length(4);
684
+ router.categories.forEach((category) => {
685
+ const matchingExit = result.exits.find(
686
+ (exit) => exit.uuid === category.exit_uuid
687
+ );
688
+ expect(matchingExit).to.exist;
689
+ });
690
+
691
+ // Verify default category is "Other"
692
+ const otherCategory = router.categories.find(
693
+ (cat) => cat.name === 'Other'
694
+ );
695
+ expect(router.default_category_uuid).to.equal(otherCategory!.uuid);
696
+ });
697
+ });
698
+ });