collavre 0.24.0 → 0.25.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 (440) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +0 -1
  3. data/app/assets/stylesheets/collavre/comments_popup.css +165 -99
  4. data/app/assets/stylesheets/collavre/creatives.css +346 -37
  5. data/app/assets/stylesheets/collavre/desktop_setup.css +246 -0
  6. data/app/assets/stylesheets/collavre/gnb.css +64 -3
  7. data/app/assets/stylesheets/collavre/landing.css +108 -3
  8. data/app/assets/stylesheets/collavre/mention_menu.css +32 -0
  9. data/app/assets/stylesheets/collavre/popup.css +14 -0
  10. data/app/assets/stylesheets/collavre/print.css +0 -1
  11. data/app/assets/stylesheets/collavre/search_popup.css +1 -1
  12. data/app/assets/stylesheets/collavre/secret_fields.css +3 -0
  13. data/app/assets/stylesheets/collavre/workspace.css +307 -0
  14. data/app/channels/collavre/agent_channel.rb +14 -21
  15. data/app/channels/collavre/comments_presence_channel.rb +181 -21
  16. data/app/channels/collavre/inbox_badge_channel.rb +7 -1
  17. data/app/components/collavre/inbox/badge_component.rb +6 -13
  18. data/app/components/collavre/progress_filter_component.html.erb +2 -2
  19. data/app/components/collavre/progress_filter_component.rb +20 -0
  20. data/app/controllers/collavre/admin/settings_controller.rb +4 -1
  21. data/app/controllers/collavre/agent_connections_controller.rb +165 -0
  22. data/app/controllers/collavre/agent_gateways_controller.rb +132 -0
  23. data/app/controllers/collavre/agent_provisioning_controller.rb +79 -0
  24. data/app/controllers/collavre/api/v1/agents_controller.rb +45 -97
  25. data/app/controllers/collavre/application_controller.rb +13 -2
  26. data/app/controllers/collavre/comment_read_pointers_controller.rb +69 -40
  27. data/app/controllers/collavre/comments_controller.rb +45 -131
  28. data/app/controllers/collavre/concerns/shareable.rb +9 -3
  29. data/app/controllers/collavre/creatives_controller.rb +144 -11
  30. data/app/controllers/collavre/desktop_gateway_registrations_controller.rb +173 -0
  31. data/app/controllers/collavre/desktop_setup_controller.rb +119 -0
  32. data/app/controllers/collavre/desktop_sidecar_health_controller.rb +35 -0
  33. data/app/controllers/collavre/features_controller.rb +57 -0
  34. data/app/controllers/collavre/invites_controller.rb +12 -1
  35. data/app/controllers/collavre/llm_models_controller.rb +8 -0
  36. data/app/controllers/collavre/notices_controller.rb +20 -0
  37. data/app/controllers/collavre/tasks_controller.rb +40 -6
  38. data/app/controllers/collavre/topics_controller.rb +130 -53
  39. data/app/controllers/collavre/user_creative_preferences_controller.rb +214 -30
  40. data/app/controllers/concerns/collavre/comments/batch_operations.rb +15 -3
  41. data/app/controllers/concerns/collavre/comments/conversion.rb +1 -1
  42. data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +118 -8
  43. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +1 -15
  44. data/app/controllers/concerns/collavre/users_controller/registration.rb +2 -2
  45. data/app/errors/collavre/turn_deadline_error.rb +20 -0
  46. data/app/helpers/collavre/application_helper.rb +38 -0
  47. data/app/helpers/collavre/creatives_helper.rb +56 -22
  48. data/app/helpers/collavre/navigation_helper.rb +12 -0
  49. data/app/javascript/collavre.js +1 -2
  50. data/app/javascript/components/InlineLexicalEditor.jsx +30 -21
  51. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +32 -2
  52. data/app/javascript/components/__tests__/creative_tree_row_progress_toggle.test.js +185 -0
  53. data/app/javascript/components/creative_tree_row.js +62 -22
  54. data/app/javascript/controllers/__tests__/agent_connection_controller.test.js +195 -0
  55. data/app/javascript/controllers/__tests__/desktop_proxy_setup_controller.test.js +96 -0
  56. data/app/javascript/controllers/__tests__/index.test.js +42 -0
  57. data/app/javascript/controllers/__tests__/last_visited_creative_controller.test.js +103 -0
  58. data/app/javascript/controllers/__tests__/link_creative_controller.test.js +253 -2
  59. data/app/javascript/controllers/__tests__/progress_filter_controller.test.js +134 -0
  60. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +310 -0
  61. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +111 -0
  62. data/app/javascript/controllers/__tests__/workspace_tree_controller.test.js +1011 -0
  63. data/app/javascript/controllers/agent_connection_controller.js +357 -0
  64. data/app/javascript/controllers/agent_vendor_controller.js +23 -0
  65. data/app/javascript/controllers/comment_controller.js +3 -1
  66. data/app/javascript/controllers/comments/__tests__/feature_cards_controller.test.js +223 -0
  67. data/app/javascript/controllers/comments/__tests__/form_controller_default_placeholder.test.js +83 -0
  68. data/app/javascript/controllers/comments/__tests__/form_controller_draft.test.js +2069 -0
  69. data/app/javascript/controllers/comments/__tests__/form_controller_ime_send.test.js +324 -0
  70. data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +28 -2
  71. data/app/javascript/controllers/comments/__tests__/form_controller_search_submit.test.js +106 -0
  72. data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft.test.js +134 -0
  73. data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_creative_switch.test.js +166 -0
  74. data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_failure.test.js +183 -0
  75. data/app/javascript/controllers/comments/__tests__/list_controller_deep_link_topic.test.js +467 -0
  76. data/app/javascript/controllers/comments/__tests__/list_controller_docked.test.js +115 -0
  77. data/app/javascript/controllers/comments/__tests__/list_controller_prev_message.test.js +245 -0
  78. data/app/javascript/controllers/comments/__tests__/list_controller_read_pointer.test.js +276 -0
  79. data/app/javascript/controllers/comments/__tests__/list_controller_stream_archived.test.js +221 -0
  80. data/app/javascript/controllers/comments/__tests__/list_controller_stream_search.test.js +104 -0
  81. data/app/javascript/controllers/comments/__tests__/placeholder_controller.test.js +79 -0
  82. data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +754 -1
  83. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +717 -15
  84. data/app/javascript/controllers/comments/__tests__/prev_message_navigator.test.js +129 -0
  85. data/app/javascript/controllers/comments/__tests__/topics_controller_action_errors.test.js +184 -0
  86. data/app/javascript/controllers/comments/__tests__/topics_controller_archived.test.js +879 -0
  87. data/app/javascript/controllers/comments/__tests__/topics_controller_creation_container.test.js +154 -0
  88. data/app/javascript/controllers/comments/__tests__/topics_controller_creation_scroll.test.js +92 -0
  89. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +130 -0
  90. data/app/javascript/controllers/comments/__tests__/topics_controller_docked.test.js +32 -0
  91. data/app/javascript/controllers/comments/__tests__/topics_controller_last_topic_echo.test.js +1938 -0
  92. data/app/javascript/controllers/comments/__tests__/topics_controller_primary_agent.test.js +256 -0
  93. data/app/javascript/controllers/comments/__tests__/topics_controller_scroll.test.js +182 -0
  94. data/app/javascript/controllers/comments/__tests__/topics_controller_selection_race.test.js +981 -0
  95. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +313 -0
  96. data/app/javascript/controllers/comments/feature_cards_controller.js +83 -0
  97. data/app/javascript/controllers/comments/form_controller.js +795 -14
  98. data/app/javascript/controllers/comments/list_controller.js +338 -52
  99. data/app/javascript/controllers/comments/placeholder_controller.js +30 -0
  100. data/app/javascript/controllers/comments/popup_controller.js +425 -83
  101. data/app/javascript/controllers/comments/presence_controller.js +317 -55
  102. data/app/javascript/controllers/comments/prev_message_navigator.js +94 -0
  103. data/app/javascript/controllers/comments/topics_controller.js +1772 -100
  104. data/app/javascript/controllers/common_popup_controller.js +2 -2
  105. data/app/javascript/controllers/creatives/__tests__/select_mode_controller.test.js +139 -0
  106. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +605 -0
  107. data/app/javascript/controllers/creatives/__tests__/write_access_request_controller.test.js +125 -0
  108. data/app/javascript/controllers/creatives/select_mode_controller.js +7 -2
  109. data/app/javascript/controllers/creatives/tree_controller.js +140 -31
  110. data/app/javascript/controllers/creatives/write_access_request_controller.js +58 -0
  111. data/app/javascript/controllers/desktop_proxy_setup_controller.js +51 -0
  112. data/app/javascript/controllers/gateway_check_controller.js +36 -0
  113. data/app/javascript/controllers/index.js +25 -1
  114. data/app/javascript/controllers/last_visited_creative_controller.js +58 -0
  115. data/app/javascript/controllers/link_creative_controller.js +89 -18
  116. data/app/javascript/controllers/progress_filter_controller.js +12 -22
  117. data/app/javascript/controllers/search_popup_controller.js +64 -46
  118. data/app/javascript/controllers/share_user_search_controller.js +2 -2
  119. data/app/javascript/controllers/topic_list_controller.js +44 -7
  120. data/app/javascript/controllers/workspace_tree_controller.js +662 -0
  121. data/app/javascript/creatives/__tests__/tree_renderer.test.js +80 -0
  122. data/app/javascript/creatives/drag_drop/__tests__/drop_completion_empty_state.test.js +98 -0
  123. data/app/javascript/creatives/drag_drop/__tests__/topic_move_released_agent.test.js +125 -0
  124. data/app/javascript/creatives/drag_drop/event_handlers.js +12 -0
  125. data/app/javascript/creatives/tree_renderer.js +65 -22
  126. data/app/javascript/lib/__tests__/chat_drafts.test.js +1458 -0
  127. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +8 -3
  128. data/app/javascript/lib/__tests__/common_popup_placement.test.js +374 -0
  129. data/app/javascript/lib/__tests__/common_popup_reshow.test.js +131 -0
  130. data/app/javascript/lib/__tests__/creative_link_prefetch.test.js +46 -0
  131. data/app/javascript/lib/__tests__/last_visited_creative.test.js +87 -0
  132. data/app/javascript/lib/__tests__/skill_html_entities.test.js +23 -0
  133. data/app/javascript/lib/__tests__/turbo_stream_actions.test.js +190 -0
  134. data/app/javascript/lib/api/__tests__/creatives.test.js +68 -0
  135. data/app/javascript/lib/api/__tests__/csrf_fetch.test.js +27 -0
  136. data/app/javascript/lib/api/__tests__/topics_save_last_topic.test.js +112 -0
  137. data/app/javascript/lib/api/creatives.js +32 -13
  138. data/app/javascript/lib/api/csrf_fetch.js +6 -4
  139. data/app/javascript/lib/api/topics.js +63 -5
  140. data/app/javascript/lib/chat_drafts.js +786 -0
  141. data/app/javascript/lib/common_popup.js +164 -25
  142. data/app/javascript/lib/creative_link_prefetch.js +27 -0
  143. data/app/javascript/lib/last_visited_creative.js +97 -0
  144. data/app/javascript/lib/lexical/__tests__/creative_link_node.test.js +162 -0
  145. data/app/javascript/lib/lexical/__tests__/creative_link_trigger.test.js +133 -0
  146. data/app/javascript/lib/lexical/__tests__/link_toolbar.test.js +62 -0
  147. data/app/javascript/lib/lexical/creative_link_node.js +101 -0
  148. data/app/javascript/lib/lexical/creative_link_trigger.js +98 -0
  149. data/app/javascript/lib/lexical/link_toolbar.js +23 -0
  150. data/app/javascript/lib/turbo_stream_actions.js +23 -16
  151. data/app/javascript/lib/utils/__tests__/filter_navigation.test.js +210 -0
  152. data/app/javascript/lib/utils/__tests__/markdown.test.js +44 -0
  153. data/app/javascript/lib/utils/filter_navigation.js +100 -0
  154. data/app/javascript/modules/__tests__/command_menu_creative_picker.test.js +62 -0
  155. data/app/javascript/modules/__tests__/command_menu_typeahead.test.js +267 -0
  156. data/app/javascript/modules/__tests__/creative_link_picker.test.js +85 -0
  157. data/app/javascript/modules/__tests__/creative_row_editor_archive.test.js +391 -0
  158. data/app/javascript/modules/__tests__/creative_row_editor_delegated_clicks.test.js +158 -0
  159. data/app/javascript/modules/__tests__/creative_row_editor_empty_state_recovery.test.js +519 -0
  160. data/app/javascript/modules/__tests__/creative_row_editor_empty_state_removal.test.js +346 -0
  161. data/app/javascript/modules/__tests__/creative_row_editor_session.test.js +144 -0
  162. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +30 -0
  163. data/app/javascript/modules/__tests__/creative_tree_empty_state.test.js +186 -0
  164. data/app/javascript/modules/__tests__/dom_listener_registry.test.js +67 -0
  165. data/app/javascript/modules/__tests__/support/inline_editor_dom.js +159 -0
  166. data/app/javascript/modules/command_args_form.js +0 -12
  167. data/app/javascript/modules/command_menu.js +80 -38
  168. data/app/javascript/modules/creative_link_picker.js +56 -0
  169. data/app/javascript/modules/creative_row_editor.js +446 -243
  170. data/app/javascript/modules/creative_row_editor_delegated_clicks.js +87 -0
  171. data/app/javascript/modules/creative_tree_dom.js +4 -0
  172. data/app/javascript/modules/creative_tree_empty_state.js +80 -0
  173. data/app/javascript/modules/dom_listener_registry.js +23 -0
  174. data/app/javascript/modules/mention_menu.js +2 -3
  175. data/app/javascript/utils/__tests__/caret_anchor.test.js +75 -0
  176. data/app/javascript/utils/__tests__/chevron_icons.test.js +15 -0
  177. data/app/javascript/utils/caret_position.js +10 -0
  178. data/app/javascript/utils/chevron_icons.js +7 -0
  179. data/app/jobs/collavre/ai_agent_job.rb +262 -72
  180. data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +11 -36
  181. data/app/jobs/collavre/comment_badges_broadcast_job.rb +28 -0
  182. data/app/jobs/collavre/comment_link_preview_job.rb +33 -0
  183. data/app/jobs/collavre/comment_notification_job.rb +14 -0
  184. data/app/jobs/collavre/comment_push_delivery_sweep_job.rb +17 -0
  185. data/app/jobs/collavre/compress_job.rb +33 -22
  186. data/app/jobs/collavre/creative_broadcast_job.rb +161 -48
  187. data/app/jobs/collavre/cron_action_job.rb +36 -18
  188. data/app/jobs/collavre/drop_trigger_job.rb +23 -9
  189. data/app/jobs/collavre/merge_comments_job.rb +33 -16
  190. data/app/jobs/collavre/push_notification_job.rb +62 -18
  191. data/app/jobs/collavre/restore_dropped_dispatches_job.rb +17 -0
  192. data/app/jobs/collavre/stuck_detector_job.rb +1 -1
  193. data/app/jobs/collavre/touch_creative_subtree_job.rb +14 -0
  194. data/app/models/collavre/agent_gateway.rb +200 -0
  195. data/app/models/collavre/agent_subscription.rb +4 -4
  196. data/app/models/collavre/agent_workspace.rb +210 -0
  197. data/app/models/collavre/comment/broadcastable.rb +22 -52
  198. data/app/models/collavre/comment/notifiable.rb +154 -77
  199. data/app/models/collavre/comment/topic_membership.rb +41 -0
  200. data/app/models/collavre/comment.rb +336 -31
  201. data/app/models/collavre/comment_notification_delivery.rb +61 -0
  202. data/app/models/collavre/comment_presence_store/cache_batches.rb +64 -0
  203. data/app/models/collavre/comment_presence_store.rb +272 -12
  204. data/app/models/collavre/comment_read_pointer.rb +9 -15
  205. data/app/models/collavre/creative/describable.rb +8 -2
  206. data/app/models/collavre/creative/permissible.rb +11 -0
  207. data/app/models/collavre/creative.rb +29 -4
  208. data/app/models/collavre/creative_share.rb +109 -4
  209. data/app/models/collavre/current.rb +1 -0
  210. data/app/models/collavre/llm_model.rb +62 -0
  211. data/app/models/collavre/system_setting.rb +12 -1
  212. data/app/models/collavre/task.rb +121 -2
  213. data/app/models/collavre/topic.rb +51 -0
  214. data/app/models/collavre/user.rb +101 -41
  215. data/app/models/collavre/user_creative_preference.rb +3 -1
  216. data/app/services/collavre/agent_provisioning/archive.rb +108 -0
  217. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +16 -3
  218. data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +63 -6
  219. data/app/services/collavre/ai_agent/approval_handler.rb +12 -4
  220. data/app/services/collavre/ai_agent/claude_channel_adapter.rb +10 -5
  221. data/app/services/collavre/ai_agent/merged_trigger_comments.rb +164 -0
  222. data/app/services/collavre/ai_agent/message_builder.rb +103 -16
  223. data/app/services/collavre/ai_agent/session_provisioner.rb +3 -7
  224. data/app/services/collavre/ai_agent/task_claim_service.rb +29 -15
  225. data/app/services/collavre/ai_agent/task_reply_service.rb +67 -0
  226. data/app/services/collavre/ai_agent_service.rb +108 -20
  227. data/app/services/collavre/ai_client.rb +211 -21
  228. data/app/services/collavre/cli_proxy/client.rb +147 -0
  229. data/app/services/collavre/cli_proxy/endpoint_policy.rb +81 -0
  230. data/app/services/collavre/cli_proxy/identity.rb +47 -0
  231. data/app/services/collavre/cli_proxy/safe_net_http_adapter.rb +19 -0
  232. data/app/services/collavre/command_menu_service.rb +0 -10
  233. data/app/services/collavre/comment_move_service.rb +96 -7
  234. data/app/services/collavre/comment_snapshot_restore_service.rb +11 -5
  235. data/app/services/collavre/comments/action_executor.rb +48 -20
  236. data/app/services/collavre/comments/command_processor.rb +0 -1
  237. data/app/services/collavre/comments/list_response.rb +133 -0
  238. data/app/services/collavre/comments/read_pointer_writer.rb +210 -0
  239. data/app/services/collavre/comments/read_receipt_broadcaster.rb +157 -0
  240. data/app/services/collavre/comments/read_receipt_index.rb +152 -0
  241. data/app/services/collavre/comments/topic_command.rb +72 -1
  242. data/app/services/collavre/comments/topic_mutation.rb +20 -0
  243. data/app/services/collavre/creatives/breadcrumb_resolver.rb +1 -1
  244. data/app/services/collavre/creatives/comment_badge_index.rb +299 -27
  245. data/app/services/collavre/creatives/reorderer.rb +15 -3
  246. data/app/services/collavre/creatives/tree_builder.rb +1 -1
  247. data/app/services/collavre/creatives/tree_formatter.rb +5 -6
  248. data/app/services/collavre/creatives/workspace_path_resolver.rb +50 -0
  249. data/app/services/collavre/creatives/workspace_tree_builder.rb +92 -0
  250. data/app/services/collavre/crons/recurring_topic_tasks.rb +23 -0
  251. data/app/services/collavre/home_page_path.rb +55 -0
  252. data/app/services/collavre/html_text.rb +64 -0
  253. data/app/services/collavre/http_client.rb +11 -2
  254. data/app/services/collavre/inbox_reply_service.rb +1 -1
  255. data/app/services/collavre/markdown_converter.rb +13 -1
  256. data/app/services/collavre/orchestration/agent_orchestrator.rb +776 -81
  257. data/app/services/collavre/orchestration/arbiter.rb +48 -5
  258. data/app/services/collavre/orchestration/deferred_trigger_scope.rb +53 -0
  259. data/app/services/collavre/orchestration/delivery_record.rb +792 -0
  260. data/app/services/collavre/orchestration/loop_breaker.rb +10 -4
  261. data/app/services/collavre/orchestration/matcher.rb +132 -17
  262. data/app/services/collavre/orchestration/policy_resolver.rb +58 -10
  263. data/app/services/collavre/orchestration/scheduler.rb +10 -4
  264. data/app/services/collavre/orchestration/scheduling_hooks.rb +10 -0
  265. data/app/services/collavre/orchestration/selection.rb +43 -0
  266. data/app/services/collavre/orchestration/stuck_detector.rb +25 -112
  267. data/app/services/collavre/orchestration/task_coalescer.rb +355 -0
  268. data/app/services/collavre/orchestration/topic_slot.rb +73 -0
  269. data/app/services/collavre/system_events/context_builder.rb +60 -11
  270. data/app/services/collavre/system_events/dispatcher.rb +58 -12
  271. data/app/services/collavre/system_events/envelope.rb +73 -0
  272. data/app/services/collavre/system_events/vocabulary.rb +45 -0
  273. data/app/services/collavre/tools/creative_retrieval_service.rb +1 -1
  274. data/app/services/collavre/tools/cron_create_service.rb +32 -18
  275. data/app/services/collavre/tools/id_list.rb +38 -0
  276. data/app/services/collavre/tools/topic_authorizer.rb +61 -7
  277. data/app/services/collavre/tools/topic_branch_service.rb +85 -0
  278. data/app/services/collavre/tools/topic_create_service.rb +117 -0
  279. data/app/services/collavre/tools/topic_list_service.rb +101 -0
  280. data/app/services/collavre/tools/topic_message_create_service.rb +192 -0
  281. data/app/services/collavre/tools/topic_messages_service.rb +346 -0
  282. data/app/services/collavre/tools/topic_move_service.rb +108 -0
  283. data/app/services/collavre/tools/topic_selection.rb +59 -0
  284. data/app/services/collavre/tools/topic_update_service.rb +165 -0
  285. data/app/services/collavre/topic_branch_service.rb +39 -10
  286. data/app/services/collavre/topics/agent_resolver.rb +87 -0
  287. data/app/services/collavre/topics/char_budget.rb +70 -0
  288. data/app/services/collavre/topics/last_topic_preference_payload.rb +18 -0
  289. data/app/services/collavre/topics/message_page.rb +380 -0
  290. data/app/services/collavre/topics/message_page_markdown.rb +102 -0
  291. data/app/services/collavre/topics/message_scope.rb +61 -0
  292. data/app/services/collavre/topics/message_stats.rb +92 -0
  293. data/app/services/collavre/topics/move_blocker.rb +47 -0
  294. data/app/services/collavre/topics/next_name.rb +35 -0
  295. data/app/services/collavre/topics/pin_rejection.rb +25 -0
  296. data/app/services/collavre/topics/read_pointer_relocator.rb +71 -0
  297. data/app/services/collavre/topics/reserved_name.rb +18 -0
  298. data/app/services/collavre/topics/serializer.rb +92 -0
  299. data/app/services/collavre/topics/topic_destroy.rb +30 -0
  300. data/app/services/collavre/topics/topic_move.rb +80 -0
  301. data/app/services/collavre/topics/topic_move_effects.rb +35 -0
  302. data/app/services/collavre/topics/topic_mutation.rb +27 -0
  303. data/app/views/collavre/admin/integrations/_setting_row.html.erb +5 -8
  304. data/app/views/collavre/admin/settings/_system_tab.html.erb +7 -0
  305. data/app/views/collavre/agent_connections/show.html.erb +77 -0
  306. data/app/views/collavre/agent_gateways/_form.html.erb +71 -0
  307. data/app/views/collavre/agent_gateways/edit.html.erb +2 -0
  308. data/app/views/collavre/agent_gateways/index.html.erb +54 -0
  309. data/app/views/collavre/agent_gateways/new.html.erb +2 -0
  310. data/app/views/collavre/comments/_comments_popup.html.erb +32 -17
  311. data/app/views/collavre/comments/_empty_state.html.erb +33 -0
  312. data/app/views/collavre/comments/_empty_state_minimal.html.erb +7 -0
  313. data/app/views/collavre/comments/_feature_card.html.erb +35 -0
  314. data/app/views/collavre/comments/_list.html.erb +3 -2
  315. data/app/views/collavre/comments/_no_search_results.html.erb +3 -0
  316. data/app/views/collavre/creatives/_add_button.html.erb +17 -19
  317. data/app/views/collavre/creatives/_empty_state.html.erb +109 -0
  318. data/app/views/collavre/creatives/_inline_edit_form.html.erb +5 -5
  319. data/app/views/collavre/creatives/_tree_loading_placeholder.html.erb +16 -0
  320. data/app/views/collavre/creatives/_workspace_tree.html.erb +33 -0
  321. data/app/views/collavre/creatives/index.html.erb +79 -18
  322. data/app/views/collavre/desktop_setup/show.html.erb +114 -0
  323. data/app/views/collavre/features/index.html.erb +42 -0
  324. data/app/views/collavre/features/show.html.erb +52 -0
  325. data/app/views/collavre/invitation_mailer/invite.html.erb +1 -1
  326. data/app/views/collavre/invitation_mailer/invite.text.erb +1 -1
  327. data/app/views/collavre/invites/show.html.erb +14 -1
  328. data/app/views/collavre/landing/show.html.erb +5 -1
  329. data/app/views/collavre/shared/_link_creative_modal.html.erb +4 -1
  330. data/app/views/collavre/shared/navigation/_help_button.html.erb +3 -1
  331. data/app/views/collavre/shared/navigation/_panels.html.erb +1 -8
  332. data/app/views/collavre/shared/navigation/_search_form.html.erb +13 -2
  333. data/app/views/collavre/user_themes/index.html.erb +1 -1
  334. data/app/views/collavre/users/_llm_fields.html.erb +65 -0
  335. data/app/views/collavre/users/_org_chart_node.html.erb +1 -1
  336. data/app/views/collavre/users/edit_ai.html.erb +33 -31
  337. data/app/views/collavre/users/new.html.erb +4 -2
  338. data/app/views/collavre/users/new_ai.html.erb +14 -32
  339. data/app/views/collavre/users/show.html.erb +13 -1
  340. data/app/views/layouts/collavre/landing.html.erb +9 -4
  341. data/config/initializers/ruby_llm_openai_reasoning_content.rb +77 -0
  342. data/config/locales/admin.en.yml +3 -1
  343. data/config/locales/admin.ko.yml +3 -1
  344. data/config/locales/agent_gateway.en.yml +217 -0
  345. data/config/locales/agent_gateway.ko.yml +217 -0
  346. data/config/locales/ai_agent.en.yml +0 -1
  347. data/config/locales/ai_agent.ko.yml +0 -1
  348. data/config/locales/comments.en.yml +72 -30
  349. data/config/locales/comments.ko.yml +72 -30
  350. data/config/locales/creatives.en.yml +32 -1
  351. data/config/locales/creatives.ko.yml +32 -1
  352. data/config/locales/features.en.yml +140 -0
  353. data/config/locales/features.ko.yml +140 -0
  354. data/config/locales/invites.en.yml +3 -0
  355. data/config/locales/invites.ko.yml +3 -0
  356. data/config/locales/landing.en.yml +2 -1
  357. data/config/locales/landing.ko.yml +2 -1
  358. data/config/locales/tools.en.yml +20 -0
  359. data/config/locales/tools.ko.yml +20 -0
  360. data/config/locales/users.en.yml +15 -14
  361. data/config/locales/users.ko.yml +14 -14
  362. data/config/routes.rb +45 -19
  363. data/db/migrate/20250911084338_backfill_creative_in_comment_inbox_items.rb +9 -1
  364. data/db/migrate/20260724000000_add_creative_id_index_to_comments.rb +34 -0
  365. data/db/migrate/20260724000001_add_creative_private_id_index_to_comments.rb +50 -0
  366. data/db/migrate/20260724090000_add_async_comment_notification_state.rb +64 -0
  367. data/db/migrate/20260727000000_add_waiting_notice_scope_to_comments.rb +30 -0
  368. data/db/migrate/20260727010000_add_waiting_notice_scope_to_tasks.rb +26 -0
  369. data/db/migrate/20260805000000_add_creative_workspace_enabled_to_users.rb +5 -0
  370. data/db/migrate/20260807050000_add_dismissed_notices_to_users.rb +5 -0
  371. data/db/migrate/20260809000000_change_creative_workspace_enabled_default_to_true.rb +18 -0
  372. data/db/migrate/20260809000001_create_llm_models.rb +64 -0
  373. data/db/migrate/20260809000002_create_agent_gateways_and_workspaces.rb +35 -0
  374. data/db/migrate/20260809000003_hash_agent_workspace_callback_tokens.rb +44 -0
  375. data/db/migrate/20260809000004_encrypt_agent_workspace_manifest_tokens.rb +59 -0
  376. data/db/migrate/20260810000000_split_agent_workspace_identity_axes.rb +58 -0
  377. data/db/migrate/20260810000001_seed_codex_custom_proxy_llm_models.rb +28 -0
  378. data/db/migrate/20260810010000_add_last_visited_creative_to_users.rb +8 -0
  379. data/db/migrate/20260810020000_add_last_visited_creative_at_to_users.rb +5 -0
  380. data/db/migrate/20260810030000_add_last_visited_creative_navigation_order_to_users.rb +6 -0
  381. data/db/migrate/20260810060000_add_last_visited_creative_issued_sequence_to_users.rb +5 -0
  382. data/db/migrate/20260810100000_add_desktop_managed_to_agent_gateways.rb +40 -0
  383. data/db/migrate/20260810100001_add_desktop_preset_adapter_to_users.rb +38 -0
  384. data/db/migrate/20260811000000_make_agent_gateway_completion_key_nullable.rb +7 -0
  385. data/db/migrate/20260812000000_remove_workflow_columns_from_tasks.rb +101 -0
  386. data/db/migrate/20260812000001_remove_android_devices.rb +24 -0
  387. data/db/migrate/20260812010000_drop_inbox_items.rb +22 -0
  388. data/db/migrate/20260813000000_clear_claude_channel_presence_routing_expressions.rb +16 -0
  389. data/db/migrate/20260813000001_add_last_topic_revision_to_user_creative_preferences.rb +5 -0
  390. data/db/migrate/20260813000002_add_last_topic_save_order_to_user_creative_preferences.rb +6 -0
  391. data/db/migrate/20260813000003_add_last_topic_save_sequences_to_user_creative_preferences.rb +5 -0
  392. data/db/migrate/20260813000004_add_last_topic_save_fences_to_user_creative_preferences.rb +6 -0
  393. data/db/migrate/20260814000000_add_topic_to_comment_read_pointers.rb +79 -0
  394. data/db/migrate/20260814000001_add_creative_topic_private_id_index_to_comments.rb +37 -0
  395. data/db/migrate/20260816000000_add_watermark_index_to_comment_read_pointers.rb +27 -0
  396. data/db/migrate/20260816000001_add_private_recipient_topic_index_to_comments.rb +48 -0
  397. data/db/migrate/20260820000000_add_topic_assigned_at_to_comments.rb +13 -0
  398. data/db/seeds.rb +1 -51
  399. data/lib/collavre/engine.rb +108 -1
  400. data/lib/collavre/feature_card_registry.rb +168 -0
  401. data/lib/collavre/hashed_access_token_lookup.rb +22 -0
  402. data/lib/collavre/sensitive_request_silencer.rb +23 -0
  403. data/lib/collavre/user_extensions.rb +0 -2
  404. data/lib/collavre/version.rb +1 -1
  405. data/lib/collavre.rb +1 -0
  406. data/skills/collavre/SKILL.md +165 -0
  407. data/skills/collavre/package.json +8 -0
  408. data/skills/collavre/references/tool-reference.md +347 -0
  409. data/skills/collavre/scripts/collavre +634 -0
  410. data/skills/collavre/scripts/html_entities.js +24 -0
  411. metadata +228 -30
  412. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +0 -224
  413. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +0 -95
  414. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +0 -31
  415. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +0 -25
  416. data/app/controllers/collavre/inbox_items_controller.rb +0 -70
  417. data/app/controllers/collavre/typo_corrections_controller.rb +0 -39
  418. data/app/javascript/lib/__tests__/typo_correction.test.js +0 -192
  419. data/app/javascript/lib/typo_correction.js +0 -146
  420. data/app/javascript/modules/__tests__/typo_corrector.test.js +0 -365
  421. data/app/javascript/modules/creative_guide.js +0 -67
  422. data/app/javascript/modules/typo_corrector.js +0 -534
  423. data/app/jobs/collavre/inbox_summary_job.rb +0 -24
  424. data/app/mailers/collavre/inbox_mailer.rb +0 -19
  425. data/app/models/collavre/inbox_item.rb +0 -89
  426. data/app/services/collavre/comments/concerns/workflow_support.rb +0 -115
  427. data/app/services/collavre/comments/work_command.rb +0 -161
  428. data/app/services/collavre/comments/workflow_executor.rb +0 -276
  429. data/app/services/collavre/gemini_parent_recommender.rb +0 -77
  430. data/app/services/collavre/mobile/event_summarizer.rb +0 -40
  431. data/app/services/collavre/typo_corrector.rb +0 -188
  432. data/app/views/collavre/inbox_items/_item.html.erb +0 -18
  433. data/app/views/collavre/inbox_items/_items.html.erb +0 -3
  434. data/app/views/collavre/inbox_items/_list.html.erb +0 -7
  435. data/app/views/collavre/inbox_items/index.html.erb +0 -1
  436. data/app/views/collavre/inbox_mailer/daily_summary.html.erb +0 -11
  437. data/app/views/collavre/inbox_mailer/daily_summary.text.erb +0 -8
  438. data/app/views/collavre/users/typo_correction.html.erb +0 -50
  439. data/config/locales/mobile.en.yml +0 -16
  440. data/config/locales/mobile.ko.yml +0 -16
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module AiAgent
5
+ # Keeps a delegated Claude reply's claim, comment insert, and completion on
6
+ # the topic lock used by TopicMove. A move therefore sees the delegated task
7
+ # and waits, or runs after the complete reply is committed.
8
+ class TaskReplyService
9
+ Result = Data.define(:status, :body, :comment, :agent, :task)
10
+
11
+ def initialize(topic:, current_user:, text:, requested_task_id:, agent_resolver:, task_claimer:, claim_service:)
12
+ @topic = topic
13
+ @current_user = current_user
14
+ @text = text
15
+ @requested_task_id = requested_task_id
16
+ @agent_resolver = agent_resolver
17
+ @task_claimer = task_claimer
18
+ @claim_service = claim_service
19
+ end
20
+
21
+ def call
22
+ topic.with_lock { build_result }
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :topic, :current_user, :text, :requested_task_id, :agent_resolver, :task_claimer, :claim_service
28
+
29
+ def build_result
30
+ creative = topic.creative&.effective_origin
31
+ return result(:not_found, error: "Creative not found") unless creative
32
+ return result(:forbidden, error: "Not authorized") unless creative.has_permission?(current_user, :feedback)
33
+
34
+ agent = agent_resolver.call(topic, requested_task_id)
35
+ return result(:forbidden, error: "Not authorized") unless agent
36
+
37
+ task = task_claimer.call(agent, topic, requested_task_id)
38
+ if requested_task_id.present? && task.nil?
39
+ return result(:conflict, error: "Task already completed or not delegated")
40
+ end
41
+
42
+ persist_reply(creative, agent, task)
43
+ end
44
+
45
+ def persist_reply(creative, agent, task)
46
+ comment = creative.comments.build(
47
+ content: text.to_s, topic: topic, user: agent,
48
+ skip_default_user: true, skip_dispatch: true
49
+ )
50
+ return failed_result(comment, task) unless comment.save
51
+
52
+ claim_service.link_reply(task: task, comment: comment) if task
53
+ claim_service.finalize(agent: agent, task: task, comment: comment) if task
54
+ Result.new(status: :created, body: { comment_id: comment.id }, comment: comment, agent: agent, task: task)
55
+ end
56
+
57
+ def failed_result(comment, task)
58
+ task&.update!(status: "delegated")
59
+ result(:unprocessable_entity, errors: comment.errors.full_messages)
60
+ end
61
+
62
+ def result(status, **body)
63
+ Result.new(status: status, body: body, comment: nil, agent: nil, task: nil)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -13,25 +13,39 @@ module Collavre
13
13
  @task = task
14
14
  @agent = task.agent
15
15
  @context = task.trigger_event_payload
16
+ @original_comment = find_original_comment
16
17
  end
17
18
 
18
19
  def call
19
- Current.set(user: @agent) do
20
- if @agent.claude_channel_agent?
21
- delegate_to_claude_channel
22
- else
23
- execute_llm_conversation
20
+ begin
21
+ Current.set(user: @agent, agent_turn: { user: workspace_user, task: @task }) do
22
+ if @agent.claude_channel_agent?
23
+ delegate_to_claude_channel
24
+ else
25
+ execute_llm_conversation
26
+ end
24
27
  end
28
+ rescue ApprovalPendingError => e
29
+ summary = generate_approval_summary(e)
30
+ AiAgent::ApprovalHandler.new(
31
+ task: @task, agent: @agent, context: @context,
32
+ creative: @creative, reply_comment: @reply_comment
33
+ ).handle(e, summary: summary)
34
+ raise
25
35
  end
26
- rescue ApprovalPendingError => e
27
- summary = generate_approval_summary(e)
28
- AiAgent::ApprovalHandler.new(
29
- task: @task, agent: @agent, context: @context,
30
- creative: @creative, reply_comment: @reply_comment
31
- ).handle(e, summary: summary)
36
+ rescue TurnDeadlineError => e
37
+ handle_cancelled(
38
+ action_type: "failed",
39
+ message: "Turn exceeded the #{e.deadline_seconds}s deadline"
40
+ )
41
+ abort_agent_session_if_needed
32
42
  raise
33
43
  rescue CancelledError
34
- handle_cancelled
44
+ if @task.reload.status == "failed"
45
+ handle_cancelled(action_type: "failed", message: "Task failed externally")
46
+ else
47
+ handle_cancelled
48
+ end
35
49
  abort_agent_session_if_needed
36
50
  raise
37
51
  end
@@ -60,7 +74,6 @@ module Collavre
60
74
  def execute_llm_conversation
61
75
  log_action("start", { message: "Starting agent execution" })
62
76
 
63
- @original_comment = find_original_comment
64
77
  messages_data = build_messages
65
78
  log_action("prompt_generated", { messages: messages_data[:messages] })
66
79
 
@@ -70,6 +83,14 @@ module Collavre
70
83
 
71
84
  resolved = resolve_session_context(messages_data, system_prompt)
72
85
 
86
+ # Write down what this turn is about to hand the agent, before it hands it
87
+ # over. A comment that landed after this task was dispatched and got swept
88
+ # into the history window has been read by the agent, and the dispatch
89
+ # still on its way for it should be dropped rather than queued behind this
90
+ # turn. Recorded off `resolved` — after the session filter — because a
91
+ # session-backed agent is sent only its :trigger and swallows nothing.
92
+ Orchestration::DeliveryRecord.record!(@task, resolved)
93
+
73
94
  @reply_comment = create_reply_comment_if_needed
74
95
 
75
96
  @lifecycle_manager = AiAgent::AgentLifecycleManager.new(
@@ -86,12 +107,40 @@ module Collavre
86
107
  @lifecycle_manager.broadcast_status("thinking")
87
108
 
88
109
  @client = build_ai_client(resolved[:system_prompt])
89
- stream_response(@client, resolved)
110
+ begin
111
+ stream_response(@client, resolved)
112
+ ensure
113
+ # Write down that the payload got there, whatever became of the turn
114
+ # afterwards. In an `ensure` because the ending this is for leaves by
115
+ # exception: a user pressing Stop mid-answer raises CancelledError out
116
+ # of the block above, and the task ends `cancelled` — an undelivered
117
+ # ending to every reader, although the agent has read this turn's
118
+ # payload and every comment it swallowed. See
119
+ # Orchestration::DeliveryRecord::HANDED_OFF_KEY.
120
+ Orchestration::DeliveryRecord.mark_handed_off!(@task) if @client.handed_off?
121
+ end
122
+
123
+ # ...and write down when the handing over did not happen. The record
124
+ # above licences discarding other dispatches on the strength of the agent
125
+ # having read their comments; a request that never reached the provider
126
+ # read nothing. AiAgentJob finishes this task `done` either way, so
127
+ # without this the delivered ending is the one it ends in. See
128
+ # Orchestration::DeliveryRecord::HANDOFF_FAILED_KEY.
129
+ Orchestration::DeliveryRecord.mark_handoff_failed!(@task) if @client.last_handoff_failed?
130
+
131
+ # Signal thinking state during finalize so the indicator shows ⏳
132
+ @lifecycle_manager.broadcast_status("thinking")
90
133
 
91
134
  log_action("completion", { response: @streamer.content })
92
135
 
93
136
  finalized_comment = finalize_response
137
+ # Stop/failure can land while the finalizer persists and broadcasts the
138
+ # reply. Revalidate before dispatching that reply to other agents.
139
+ @lifecycle_manager.check_cancelled!(force: true)
94
140
  dispatch_a2a(finalized_comment) unless @finalizer&.review_flow
141
+ # A2A dispatch may itself perform I/O. Keep the normal return path from
142
+ # handing a stale "success" to AiAgentJob after a terminal transition.
143
+ @lifecycle_manager.check_cancelled!(force: true)
95
144
 
96
145
  @lifecycle_manager.broadcast_status("idle")
97
146
 
@@ -172,17 +221,35 @@ module Collavre
172
221
  creative: @creative,
173
222
  user: @agent,
174
223
  task: @task,
175
- comment: @reply_comment || @original_comment
176
- }
224
+ comment: @reply_comment || @original_comment,
225
+ workspace_user: workspace_user
226
+ },
227
+ request_timeout_seconds: @lifecycle_manager.method(:remaining_deadline_seconds),
228
+ # The streaming block below checks cancellation only when a text delta
229
+ # arrives; a tool-only loop emits none, so the tool-call boundary is
230
+ # that loop's only checkpoint against terminal status and the deadline.
231
+ before_tool_call: ->(force = false) { @lifecycle_manager.check_cancelled!(force: force) }
177
232
  )
178
233
  end
179
234
 
180
235
  def stream_response(client, messages_data)
181
- client.chat(messages_data, tools: @agent.tools || []) do |delta|
236
+ # Prompt/session preparation can take long enough for Stop or
237
+ # StuckDetector to end the task before the provider request begins.
238
+ # Bypass the new manager's initial polling throttle at this handoff
239
+ # boundary so a terminal turn cannot start remote tool side effects.
240
+ @lifecycle_manager.check_cancelled!(force: true)
241
+ response = client.chat(messages_data, tools: @agent.tools || []) do |delta|
182
242
  @lifecycle_manager.check_cancelled!
183
243
  @streamer.append(delta)
184
244
  @lifecycle_manager.heartbeat_if_needed
185
245
  end
246
+ # A provider can return its final response inside the one-second polling
247
+ # throttle after StuckDetector failed the task or the turn crossed its
248
+ # deadline. This is the common completion boundary for RubyLLM and both
249
+ # OpenClaw transports; validate it without throttling before finalization
250
+ # can overwrite the terminal outcome.
251
+ @lifecycle_manager.check_cancelled!(force: true)
252
+ response
186
253
  end
187
254
 
188
255
  def finalize_response
@@ -202,15 +269,36 @@ module Collavre
202
269
  dispatcher = AiAgent::A2aDispatcher.new(
203
270
  agent: @agent,
204
271
  reply_comment: finalized_comment,
205
- context: @context
272
+ context: @context,
273
+ workspace_user: workspace_user
206
274
  )
207
275
  dispatcher.dispatch
208
276
  end
209
277
 
210
- def handle_cancelled
278
+ def workspace_user
279
+ @workspace_user ||= begin
280
+ carried_principal = @context.key?("workspace_user_id")
281
+ carried_user = User.find_by(id: @context["workspace_user_id"])
282
+ comment_user = @original_comment&.user
283
+
284
+ if carried_user && !carried_user.ai_user?
285
+ carried_user
286
+ elsif carried_principal
287
+ nil
288
+ elsif comment_user && !comment_user.ai_user?
289
+ comment_user
290
+ else
291
+ @agent.creator
292
+ end
293
+ end
294
+ end
295
+
296
+ def handle_cancelled(action_type: "cancelled", message: "Task cancelled by user")
211
297
  @lifecycle_manager.handle_cancelled(
212
298
  reply_comment: @reply_comment,
213
- response_content: @streamer.content
299
+ response_content: @streamer.content,
300
+ action_type: action_type,
301
+ message: message
214
302
  )
215
303
 
216
304
  # Reassociate activity logs if there was partial content
@@ -10,13 +10,45 @@ module Collavre
10
10
 
11
11
  attr_reader :last_input_tokens, :last_output_tokens
12
12
 
13
+ # Did the last #chat end without the provider ever receiving the payload?
14
+ #
15
+ # #chat swallows a provider error: it streams "⚠️ AI Error" to the caller
16
+ # and returns nil — which an ordinary empty answer also returns — so the
17
+ # return value cannot tell the two apart. Orchestration::DeliveryRecord
18
+ # needs them apart, because a turn that handed the provider nothing
19
+ # delivered nothing, and AiAgentJob still marks such a turn `done`.
20
+ #
21
+ # An error *after* the provider started answering is not one of these. It
22
+ # had the payload and acted on it; the truncated reply and the retry beside
23
+ # it are the ordinary ending the product already shows, and the comments
24
+ # that turn swallowed did reach the agent. "Started answering" is any chunk,
25
+ # not any *content* — see the stream block for why a tool-call chunk is the
26
+ # sharper case.
27
+ def last_handoff_failed?
28
+ @last_handoff_failed
29
+ end
30
+
31
+ # Whether the last #chat got its payload to the provider — the same
32
+ # boundary, asked positively, for the callers the flag above cannot serve.
33
+ #
34
+ # #last_handoff_failed? is only ever set from a rescue, so it says nothing
35
+ # about a #chat that left by exception past it: a user pressing Stop
36
+ # mid-answer raises CancelledError through here, and the turn ends in a
37
+ # status every reader counts as undelivered although the provider has the
38
+ # payload. Answering that needs the fact recorded as it happens rather than
39
+ # inferred from how the call ended.
40
+ def handed_off?
41
+ @handed_off
42
+ end
43
+
13
44
  # Vendor <select> options for AI-agent config. Core ships only its built-in
14
45
  # (stateless) providers; vendor engines append their own through
15
46
  # register_vendor_option, so core never names a vendor engine.
16
47
  BASE_VENDOR_OPTIONS = [
17
48
  [ "Google (Gemini)", "google" ],
18
49
  [ "OpenAI", "openai" ],
19
- [ "Anthropic", "anthropic" ]
50
+ [ "Anthropic", "anthropic" ],
51
+ [ "CLI Proxy", "cli_proxy" ]
20
52
  ].freeze
21
53
 
22
54
  class << self
@@ -54,10 +86,16 @@ module Collavre
54
86
  end
55
87
 
56
88
  # log_interactions: persist each call to ActivityLog. Default true. Pass false
57
- # for ephemeral, high-frequency calls on text the user has not submitted (e.g.
58
- # inline typo correction on debounced typing) so private drafts are never
59
- # written to server-side activity logs.
60
- def initialize(vendor:, model:, system_prompt:, llm_api_key: nil, gateway_url: nil, context: {}, log_interactions: true)
89
+ # when a caller must not persist sensitive request or response content.
90
+ #
91
+ # before_tool_call: callable run before every tool execution, on every
92
+ # iteration of the request->tools->request loop. This is the only
93
+ # cancellation/deadline checkpoint a tool-only turn has: tool-call chunks
94
+ # carry no content, so #chat never yields them to the caller's streaming
95
+ # block, where the text path runs its check. A CancelledError raised here
96
+ # propagates out of #chat as a cancellation, not an "⚠️ AI Error" delta.
97
+ def initialize(vendor:, model:, system_prompt:, llm_api_key: nil, gateway_url: nil, context: {},
98
+ log_interactions: true, before_tool_call: nil, request_timeout_seconds: nil)
61
99
  @vendor = vendor
62
100
  @model = model
63
101
  @system_prompt = system_prompt
@@ -65,11 +103,17 @@ module Collavre
65
103
  @gateway_url = gateway_url
66
104
  @context = context
67
105
  @log_interactions = log_interactions
106
+ @before_tool_call = before_tool_call
107
+ @request_timeout_seconds = request_timeout_seconds
68
108
  @last_input_tokens = 0
69
109
  @last_output_tokens = 0
110
+ @last_handoff_failed = false
111
+ @handed_off = false
70
112
  end
71
113
 
72
114
  def chat(contents, tools: [], &block)
115
+ @last_handoff_failed = false
116
+ @handed_off = false
73
117
  response_content = +""
74
118
  error_message = nil
75
119
  input_tokens = nil
@@ -84,6 +128,17 @@ module Collavre
84
128
  add_messages(@conversation, contents)
85
129
 
86
130
  response = @conversation.complete do |chunk|
131
+ # A chunk at all is the provider having accepted the request and begun
132
+ # answering — the earliest acceptance observable from here, and the
133
+ # analogue of the gateway run id the OpenClaw adapter records.
134
+ #
135
+ # Set above the empty check, not below it: role-only and tool-call
136
+ # chunks carry no content, and a conversation that reached its tools has
137
+ # not merely been handed the payload, it has acted on it — this
138
+ # product's tools write creatives and post comments. A turn classified
139
+ # as a failed handoff has everything it swallowed dispatched again, and
140
+ # the restored turn runs those tools a second time.
141
+ @handed_off = true
87
142
  delta = extract_chunk_content(chunk).to_s
88
143
  # Deliberately NOT `blank?`. A delta of exactly "\n\n" — the paragraph
89
144
  # break, which providers routinely emit as a token of its own — is
@@ -100,7 +155,10 @@ module Collavre
100
155
  end
101
156
 
102
157
  if response
103
- response_content = response.content.to_s if response.content.present?
158
+ if response.content.present?
159
+ response_content = response.content.to_s
160
+ @handed_off = true
161
+ end
104
162
 
105
163
  # Extract token usage directly from response object (RubyLLM style)
106
164
  if response.respond_to?(:input_tokens)
@@ -119,14 +177,21 @@ module Collavre
119
177
  rescue CancelledError
120
178
  raise # Re-raise cancellation errors without catching them
121
179
  rescue StandardError => e
180
+ # Nothing came back at all means nothing was handed over — see
181
+ # #last_handoff_failed?. Asked of @handed_off rather than of the buffer,
182
+ # and not `blank?`: a delta of exactly "\n\n" is content the branch above
183
+ # deliberately keeps, so `blank?` calls a stream that broke after a
184
+ # paragraph break a failed handoff, and the buffer either way says nothing
185
+ # about the content-less chunks. The two are the same
186
+ # boundary asked two ways, and Task#ended_undelivered? reads a row carrying
187
+ # both as undelivered — so it has to be impossible to record both.
188
+ @last_handoff_failed = !@handed_off
122
189
  error_message = "[#{e.class.name}] #{e.message}"
123
- # When log_interactions is false (inline typo correction runs on the user's
124
- # *unsubmitted* draft), the LLM error message can echo the request text. Log
125
- # only the error class to app logs so private drafts never leak — matching the
126
- # no-log guarantee already enforced on the parse path (TypoCorrector) and the
127
- # ActivityLog gate below. error_message stays intact for the gated ensure log
128
- # and the streamed yield (which goes back to the same user).
190
+ # When log_interactions is false, an LLM error message can echo request text.
191
+ # Log only the error class so sensitive content never leaks. error_message
192
+ # stays intact for the gated ensure log and streamed yield to the caller.
129
193
  Rails.logger.error "AI Client error: #{@log_interactions ? error_message : "[#{e.class.name}]"}"
194
+ log_error_response(e) if @log_interactions
130
195
  Rails.logger.error "Partial response length: #{response_content.length} chars" if response_content.present?
131
196
  Rails.logger.debug e.backtrace.join("\n")
132
197
  yield "\n\n⚠️ AI Error: #{error_message}" if block_given?
@@ -152,11 +217,18 @@ module Collavre
152
217
  def ask(prompt)
153
218
  return nil unless @conversation
154
219
 
220
+ refresh_turn_boundary!(@conversation)
155
221
  # Disable tool calls for summary generation to avoid recursive approval
156
222
  @conversation.with_tools(replace: true)
157
223
  response = @conversation.ask(prompt)
224
+ refresh_turn_boundary!(@conversation)
158
225
  response&.content&.strip.presence
226
+ rescue CancelledError
227
+ raise
159
228
  rescue StandardError => e
229
+ # A provider timeout at the turn boundary must become the deadline that
230
+ # caused it, not a missing summary followed by pending_approval.
231
+ refresh_turn_boundary!(@conversation)
160
232
  Rails.logger.warn("AiClient#ask failed: #{e.class} #{e.message}")
161
233
  nil
162
234
  end
@@ -165,8 +237,47 @@ module Collavre
165
237
 
166
238
  attr_reader :vendor, :model, :system_prompt, :llm_api_key, :gateway_url, :context
167
239
 
240
+ # RubyLLM masks provider 400s behind a generic "Invalid request - please check
241
+ # your input" fallback whenever the provider's error body is not in OpenAI's
242
+ # {error:{message}} shape — common for OpenAI-compatible gateways (Cerebras,
243
+ # local Ollama, etc.), whose real reason then lives only in the raw HTTP
244
+ # response. RubyLLM::Error carries that response (status + body); surface it so
245
+ # the actual cause is one grep away instead of buried in ruby_llm.log.
246
+ #
247
+ # A provider 400 body can echo the offending request (prompt or tool arguments),
248
+ # so the raw body is written to the app log only under debug logging — the same
249
+ # level that already gates RubyLLM's own request/response body log (see the
250
+ # ruby_llm initializer). At INFO (production) we record status + body size only,
251
+ # keeping user content out of centralized app logs while still capturing that the
252
+ # provider rejected the request and how large its reason was; raise the log level
253
+ # to recover the full body on demand. The whole path is additionally gated by
254
+ # @log_interactions at the call site, like the error-message log above.
255
+ def log_error_response(error)
256
+ return unless error.respond_to?(:response) && (response = error.response)
257
+
258
+ status = response.respond_to?(:status) ? response.status : nil
259
+ body = response.respond_to?(:body) ? response.body : nil
260
+ return if status.nil? && body.blank?
261
+
262
+ unless Rails.logger.debug?
263
+ size = body.is_a?(String) ? "#{body.bytesize}B" : (body.nil? ? "none" : "non-string")
264
+ Rails.logger.error "AI Client error response: status=#{status} body_size=#{size} " \
265
+ "(body suppressed at INFO; raise log level to capture it)"
266
+ return
267
+ end
268
+
269
+ # Provider error bodies are frequently tagged ASCII-8BIT even though the bytes
270
+ # are valid UTF-8; reinterpret and scrub so non-ASCII text (e.g. Korean) is
271
+ # readable and never re-triggers the transcode failure we are eliminating.
272
+ body_text = body.is_a?(String) ? body.dup.force_encoding("UTF-8").scrub : body.inspect
273
+ Rails.logger.error "AI Client error response: status=#{status} body=#{body_text.to_s.truncate(2000)}"
274
+ rescue StandardError => e
275
+ Rails.logger.debug "AiClient#log_error_response failed: #{e.class}"
276
+ end
277
+
168
278
  VENDOR_TO_PROVIDER = {
169
279
  "openai" => :openai,
280
+ "cli_proxy" => :openai,
170
281
  "anthropic" => :anthropic,
171
282
  "google" => :gemini,
172
283
  "gemini" => :gemini
@@ -176,9 +287,32 @@ module Collavre
176
287
  normalized_vendor = @vendor.to_s.downcase
177
288
 
178
289
  context_block = case normalized_vendor
179
- when "openai"
180
- api_key = @llm_api_key.presence || IntegrationSettings.fetch(:openai_api_key)
181
- base_url = @gateway_url.presence
290
+ when "openai", "cli_proxy"
291
+ if normalized_vendor == "cli_proxy"
292
+ agent = context[:user]
293
+ gateway = agent&.agent_gateway
294
+ raise ArgumentError, "CLI Proxy agent has no active gateway" unless gateway&.active?
295
+
296
+ if context.key?(:workspace_user)
297
+ workspace_user = context[:workspace_user]
298
+ else
299
+ comment_user = context[:comment]&.user
300
+ workspace_user = comment_user unless comment_user&.ai_user?
301
+ workspace_user ||= agent.creator
302
+ end
303
+ if gateway.per_user? && workspace_user.nil?
304
+ raise ArgumentError, "CLI Proxy per-user workspace requires a verified human principal"
305
+ end
306
+ workspace_user = nil if gateway.shared?
307
+ workspace = Collavre::AgentWorkspace.resolve!(agent: agent, user: workspace_user)
308
+ gateway = workspace.agent_gateway
309
+ @cli_proxy_identity = { gateway: gateway, workspace: workspace }
310
+ api_key = gateway.completion_key
311
+ base_url = gateway.completion_base_url
312
+ else
313
+ api_key = @llm_api_key.presence || IntegrationSettings.fetch(:openai_api_key)
314
+ base_url = @gateway_url.presence
315
+ end
182
316
  # A custom OpenAI-compatible gateway (local Ollama / LM Studio, etc.) needs
183
317
  # no real OpenAI key, but RubyLLM raises ConfigurationError before sending
184
318
  # if openai_api_key is blank. Supply a placeholder so keyless local gateways
@@ -187,6 +321,9 @@ module Collavre
187
321
  proc do |config|
188
322
  config.openai_api_key = api_key
189
323
  config.openai_api_base = base_url if base_url
324
+ if normalized_vendor == "cli_proxy" && !gateway.owner.system_admin? && !gateway.desktop_loopback?
325
+ config.faraday_adapter = Collavre::CliProxy::SafeNetHttpAdapter
326
+ end
190
327
  end
191
328
  when "anthropic"
192
329
  api_key = @llm_api_key.presence || IntegrationSettings.fetch(:anthropic_api_key)
@@ -201,17 +338,34 @@ module Collavre
201
338
  chat_opts[:provider] = provider if provider
202
339
  chat_opts[:assume_model_exists] = true if provider
203
340
 
204
- # Apply current system timeout setting (picks up changes without restart)
205
- RubyLLM.config.request_timeout = SystemSetting.llm_request_timeout_seconds
341
+ # RubyLLM::Context duplicates the global config. Set the timeout on that
342
+ # per-client copy so concurrent agent threads cannot overwrite one
343
+ # another's deadline cap.
344
+ @ruby_llm_context = RubyLLM.context do |config|
345
+ context_block.call(config)
346
+ config.request_timeout = effective_request_timeout_seconds
347
+ end
206
348
 
207
- RubyLLM.context(&context_block)
208
- .chat(**chat_opts).tap do |chat|
349
+ @ruby_llm_context.chat(**chat_opts).tap do |chat|
209
350
  chat.with_instructions(system_prompt) if system_prompt.present?
210
- session_id = build_session_id
211
- chat.with_headers("X-Session-Id" => session_id) if session_id
351
+ apply_request_headers!(chat)
212
352
  chat.on_tool_call do |tool_call|
353
+ # Cancellation ahead of the approval gate: a turn that already
354
+ # reached a terminal status or its deadline must end, not park
355
+ # itself as pending approval for a tool it will never run. Force this
356
+ # boundary through the lifecycle throttle: the first tool call can
357
+ # arrive during the manager's initial one-second quiet period.
358
+ @before_tool_call&.call(true)
213
359
  check_tool_approval!(tool_call)
214
360
  end
361
+ if @request_timeout_seconds || @cli_proxy_identity
362
+ chat.after_tool_result do |_result|
363
+ # A tool can consume much of the turn. Recheck the deadline and
364
+ # refresh request-scoped state before the loop starts its next
365
+ # provider request.
366
+ refresh_turn_boundary!(chat)
367
+ end
368
+ end
215
369
  if tools.any?
216
370
  # Resolve tool names to classes using the gem's helper
217
371
  tool_classes = ::Tools::MetaToolService.ruby_llm_tools(tools)
@@ -220,6 +374,42 @@ module Collavre
220
374
  end
221
375
  end
222
376
 
377
+ def refresh_turn_boundary!(conversation)
378
+ if @request_timeout_seconds
379
+ @before_tool_call&.call(true)
380
+ if @ruby_llm_context
381
+ @ruby_llm_context.config.request_timeout = effective_request_timeout_seconds
382
+ conversation.with_context(@ruby_llm_context)
383
+ end
384
+ end
385
+
386
+ apply_request_headers!(conversation)
387
+ end
388
+
389
+ def apply_request_headers!(conversation)
390
+ headers = {}
391
+ session_id = build_session_id
392
+ headers["X-Session-Id"] = session_id if session_id
393
+
394
+ if @cli_proxy_identity
395
+ headers.merge!(Collavre::CliProxy::Identity.headers(
396
+ **@cli_proxy_identity,
397
+ method: :post,
398
+ path: "/v1/chat/completions"
399
+ ))
400
+ end
401
+
402
+ conversation.with_headers(**headers) if headers.present?
403
+ end
404
+
405
+ def effective_request_timeout_seconds
406
+ configured = SystemSetting.llm_request_timeout_seconds.to_f
407
+ remaining = @request_timeout_seconds&.call
408
+ return configured unless remaining
409
+
410
+ [ configured, remaining.to_f ].min.clamp(0.001, configured)
411
+ end
412
+
223
413
  def build_session_id
224
414
  creative_id = context&.dig(:creative)&.id
225
415
  topic_id = context&.dig(:comment)&.topic_id || context&.dig(:topic_id)