collavre_openclaw 0.3.1 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e7022c888e2fbe72f646eba7d022d489ff1458da405058638c0b15bc426076ec
4
- data.tar.gz: 4b822a95fba2cba72b9ceda1d8a92f24919b8982221d8ef4ce1ed6cddea4ade0
3
+ metadata.gz: 47fb3ac9892c9b3c1ee7531645732dd47acdba3b7504d048cd207ff8ae4fa82e
4
+ data.tar.gz: e6233d85c5bff7f28ad2201b0cb84df1fe6c62827cd23666de54ef64060f41d5
5
5
  SHA512:
6
- metadata.gz: 26df63f943760253bba51dd0667e06a6fdac8821a5982e49163d9d6114f94eed5ff110f47d4af58531e22a966f7efa669f3cacafb608c3446b9d025530b9806f
7
- data.tar.gz: 33b8d566a78989521fe384add9bdedbdce668422c9658176c4c9cd2aa5620e31fa3fe247f7aefb82fd045ba33573bcfce391f2d3f88c8788c32a6d4737ce3cdb
6
+ metadata.gz: 1a8b34146dedfcec60bc2b0b78ac4f9f51448c8b2bd3ee9064e24d24ac7854271e8cb943b5083da143ec9a18e7470b869e927f2b680db1518ff6a324897a0dc9
7
+ data.tar.gz: 6f97da70c3443dcda6087cfa114b5f068cb952e567b90849a07b27239d6df89c55cafa07f35e76a2c864337d02e56f9cc328e5bc33f8655af4fd87ec93c4dee7
@@ -66,6 +66,7 @@ module CollavreOpenclaw
66
66
  payload[:context][:creative_id] ||= pending.creative_id
67
67
  payload[:context][:comment_id] ||= pending.comment_id
68
68
  payload[:context][:thread_id] ||= pending.thread_id
69
+ payload[:context][:topic_id] ||= pending.thread_id
69
70
 
70
71
  # Merge any extra context stored in pending callback
71
72
  if pending.context.present?
@@ -3,11 +3,28 @@ module CollavreOpenclaw
3
3
  allow_unauthenticated_access only: :show
4
4
 
5
5
  def show
6
- render json: {
6
+ payload = {
7
7
  status: "ok",
8
8
  engine: "collavre_openclaw",
9
9
  version: CollavreOpenclaw::VERSION
10
10
  }
11
+
12
+ # WebSocket details only for authenticated requests
13
+ if authenticated?
14
+ payload[:transport] = CollavreOpenclaw.config.transport
15
+ payload[:websocket] = ConnectionManager.status_summary
16
+ payload[:reactor] = { running: EmReactor.running? }
17
+ end
18
+
19
+ render json: payload
20
+ end
21
+
22
+ private
23
+
24
+ def authenticated?
25
+ respond_to?(:current_user, true) && current_user.present?
26
+ rescue
27
+ false
11
28
  end
12
29
  end
13
30
  end
@@ -39,6 +39,8 @@ module CollavreOpenclaw
39
39
  comment_id = context[:comment_id]
40
40
  content = payload[:content] || payload[:message]
41
41
 
42
+ normalize_topic_context!(context)
43
+
42
44
  if comment_id.present?
43
45
  # Update existing comment (streaming completion)
44
46
  comment = Collavre::Comment.find_by(id: comment_id)
@@ -56,7 +58,7 @@ module CollavreOpenclaw
56
58
  def handle_proactive(payload)
57
59
  creative_id = payload[:creative_id] || payload.dig(:context, :creative_id)
58
60
  content = payload[:content] || payload[:message]
59
- thread_id = payload[:thread_id] || payload.dig(:context, :thread_id)
61
+ thread_id = payload[:thread_id] || payload[:topic_id] || payload.dig(:context, :thread_id) || payload.dig(:context, :topic_id)
60
62
  parent_comment_id = payload[:parent_comment_id] || payload.dig(:context, :parent_comment_id)
61
63
 
62
64
  unless creative_id.present?
@@ -94,6 +96,8 @@ module CollavreOpenclaw
94
96
  end
95
97
 
96
98
  def create_ai_comment(creative_id, content, context = {})
99
+ normalize_topic_context!(context)
100
+
97
101
  creative = Collavre::Creative.find_by(id: creative_id)
98
102
  unless creative
99
103
  Rails.logger.error("[CollavreOpenclaw] Creative not found: #{creative_id}")
@@ -135,5 +139,13 @@ module CollavreOpenclaw
135
139
  Rails.logger.error("[CollavreOpenclaw] Failed to create comment: #{e.message}")
136
140
  nil
137
141
  end
142
+
143
+ def normalize_topic_context!(context)
144
+ return unless context.is_a?(Hash)
145
+ return if context[:thread_id].present?
146
+
147
+ topic_id = context[:topic_id]
148
+ context[:thread_id] = topic_id if topic_id.present?
149
+ end
138
150
  end
139
151
  end
@@ -43,10 +43,17 @@ module CollavreOpenclaw
43
43
  client = @connections[gateway_url]
44
44
 
45
45
  if client.nil?
46
- client = WebsocketClient.new(user: user)
47
- client.on_proactive_message(&@proactive_handler) if @proactive_handler
48
- @connections[gateway_url] = client
49
- @gateway_users[gateway_url] = Set.new
46
+ client = create_client(user, gateway_url)
47
+ elsif client.user.llm_api_key != user.llm_api_key
48
+ # Same gateway_url but different API key. This is a configuration
49
+ # error: one Gateway = one API key. Log a warning so the admin
50
+ # notices, rather than silently ignoring the second user's key.
51
+ # In HTTP mode this would surface as a 401 on each request.
52
+ Rails.logger.warn(
53
+ "[CollavreOpenclaw::ConnectionManager] API key mismatch for gateway #{gateway_url}: " \
54
+ "user #{user.id} has a different key than connection owner #{client.user.id}. " \
55
+ "The connection uses the owner's key. Verify AI agent settings."
56
+ )
50
57
  end
51
58
 
52
59
  # Track this user as using this gateway
@@ -109,6 +116,17 @@ module CollavreOpenclaw
109
116
  end
110
117
  end
111
118
 
119
+ # Safe accessor: returns status without triggering singleton initialization.
120
+ # Use this from controllers/monitoring instead of instance_variable_get.
121
+ def self.status_summary
122
+ if instance_variable_defined?(:@singleton__instance__) && @singleton__instance__
123
+ instance.status
124
+ else
125
+ { total_connections: 0, total_users: 0,
126
+ connected: 0, connecting: 0, reconnecting: 0, disconnected: 0 }
127
+ end
128
+ end
129
+
112
130
  # Register a proactive message handler for all connections.
113
131
  # New connections will also get this handler.
114
132
  def on_proactive_message(&handler)
@@ -138,6 +156,35 @@ module CollavreOpenclaw
138
156
 
139
157
  private
140
158
 
159
+ # Create a new WebsocketClient and wire up handlers.
160
+ def create_client(user, gateway_url)
161
+ client = WebsocketClient.new(user: user)
162
+ client.on_proactive_message(&@proactive_handler) if @proactive_handler
163
+ client.on_fatal_close do |dead_client|
164
+ handle_fatal_close(gateway_url, dead_client)
165
+ end
166
+ @connections[gateway_url] = client
167
+ @gateway_users[gateway_url] ||= Set.new
168
+ client
169
+ end
170
+
171
+ # Called when a client receives a fatal close code (auth failure, etc.).
172
+ # Removes the dead client so the next connection_for call creates a fresh one.
173
+ #
174
+ # Guard: only deletes if the current mapping still points to dead_client.
175
+ # Without this, a late-arriving callback from an old client could wipe a
176
+ # new live connection that was already registered for the same gateway_url.
177
+ def handle_fatal_close(gateway_url, dead_client)
178
+ @mutex.synchronize do
179
+ return unless @connections[gateway_url].equal?(dead_client)
180
+
181
+ Rails.logger.warn("[CollavreOpenclaw::ConnectionManager] Fatal close for gateway #{gateway_url}, removing connection")
182
+ @connections.delete(gateway_url)
183
+ user_ids = @gateway_users.delete(gateway_url) || Set.new
184
+ user_ids.each { |uid| @user_gateways.delete(uid) }
185
+ end
186
+ end
187
+
141
188
  # Set up the default proactive message handler that dispatches
142
189
  # unsolicited chat events to CallbackProcessorJob.
143
190
  def setup_default_proactive_handler!
@@ -37,7 +37,7 @@ module CollavreOpenclaw
37
37
  # Try WebSocket first, fall back to HTTP
38
38
  # Set OPENCLAW_TRANSPORT=http to force HTTP-only mode
39
39
  if CollavreOpenclaw.config.transport == "http"
40
- Rails.logger.info("[CollavreOpenclaw] Using HTTP transport (forced by config)")
40
+ Rails.logger.info("[CollavreOpenclaw::WS] TRANSPORT mode=http_forced")
41
41
  chat_via_http(messages, &block)
42
42
  elsif websocket_available?
43
43
  chat_via_websocket(messages, &block)
@@ -88,13 +88,14 @@ module CollavreOpenclaw
88
88
 
89
89
  begin
90
90
  client = ConnectionManager.instance.connection_for(@user)
91
- message_text = format_message_for_ws(messages)
91
+ payload = build_ws_chat_payload(messages)
92
92
 
93
93
  Rails.logger.info("[CollavreOpenclaw] Sending via WebSocket (session: #{session_key})")
94
94
 
95
95
  client.chat_send(
96
96
  session_key: session_key,
97
- message: message_text
97
+ message: payload[:message],
98
+ attachments: payload[:attachments]
98
99
  ) do |event|
99
100
  case event[:state]
100
101
  when "delta"
@@ -119,7 +120,7 @@ module CollavreOpenclaw
119
120
  response_content.presence
120
121
  rescue CollavreOpenclaw::ConnectionError,
121
122
  CollavreOpenclaw::TimeoutError => e
122
- Rails.logger.warn("[CollavreOpenclaw] WebSocket failed, falling back to HTTP: #{e.message}")
123
+ Rails.logger.warn("[CollavreOpenclaw::WS] FALLBACK gateway=#{@user.gateway_url} reason=#{e.class}:#{e.message}")
123
124
  chat_via_http(messages, &block)
124
125
  rescue CollavreOpenclaw::ChatError, CollavreOpenclaw::RpcError => e
125
126
  Rails.logger.error("[CollavreOpenclaw] WebSocket chat error: #{e.message}")
@@ -129,73 +130,167 @@ module CollavreOpenclaw
129
130
  rescue StandardError => e
130
131
  Rails.logger.error("[CollavreOpenclaw] WebSocket unexpected error: #{e.message}\n" \
131
132
  "#{e.backtrace.first(5).join("\n")}")
132
- Rails.logger.info("[CollavreOpenclaw] Falling back to HTTP")
133
+ Rails.logger.info("[CollavreOpenclaw::WS] FALLBACK gateway=#{@user.gateway_url} reason=#{e.class}:#{e.message}")
133
134
  chat_via_http(messages, &block)
134
135
  end
135
136
  end
136
137
 
137
- # Format messages for WebSocket chat.send (single message string).
138
- # Gateway manages session history, so we only send the latest user message
139
- # with optional context prefix on the FIRST message only.
138
+ # Build WebSocket chat.send payload.
139
+ #
140
+ # Includes the same full text context as HTTP mode plus optional base64
141
+ # image attachments supported by the Gateway's chat.send.attachments field.
142
+ def build_ws_chat_payload(messages)
143
+ {
144
+ message: format_message_for_ws(messages),
145
+ attachments: extract_ws_attachments(messages).presence
146
+ }
147
+ end
148
+
149
+ # Format messages for WebSocket chat.send text payload.
150
+ #
151
+ # Includes full context on EVERY request, matching HTTP mode behavior.
152
+ # The Gateway's WS session may be new (no prior history), so we cannot
153
+ # assume context was sent before. This is consistent with how the HTTP
154
+ # Chat Completions API works (full history on every request).
155
+ #
156
+ # Message structure sent:
157
+ # [system prompt]
158
+ # [creative context messages]
159
+ # [context creative messages]
160
+ # [chat history]
161
+ # [latest user message]
140
162
  def format_message_for_ws(messages)
141
163
  formatted = Array(messages)
164
+ return "" if formatted.empty?
165
+
166
+ parts = []
142
167
 
143
- # Extract the last user message
144
- last_user = formatted.reverse.find do |m|
168
+ # 1. System prompt (same as HTTP mode's build_payload)
169
+ parts << @system_prompt if @system_prompt.present?
170
+
171
+ # 2. All context messages (Creative:, Context Creative:, Referenced Creative:)
172
+ formatted.each do |m|
145
173
  role = m[:role] || m["role"]
146
- role.to_s == "user"
174
+ next unless role.to_s == "user"
175
+
176
+ text = extract_message_text(m)
177
+ next unless text.present?
178
+ next unless text.match?(/\A(Creative|Context Creative|Referenced Creative)\s*\(/)
179
+
180
+ parts << text
147
181
  end
148
182
 
149
- return "" unless last_user
183
+ # 3. Chat history (prior user/assistant exchanges)
184
+ formatted.each do |m|
185
+ role = (m[:role] || m["role"]).to_s
186
+ text = extract_message_text(m)
187
+ next unless text.present?
150
188
 
151
- text = extract_message_text(last_user)
189
+ # Skip context messages (already included above)
190
+ next if text.match?(/\A(Creative|Context Creative|Referenced Creative)\s*\(/)
152
191
 
153
- # Only prepend creative context on the first message in a session.
154
- # If there are prior assistant replies, the Gateway already has context.
155
- if first_message_in_session?(formatted)
156
- context_prefix = build_context_prefix(formatted)
157
- if context_prefix.present?
158
- return "#{context_prefix}\n\n#{text}"
192
+ case role
193
+ when "user"
194
+ parts << text
195
+ when "assistant", "model"
196
+ parts << "[Assistant]: #{text}"
159
197
  end
160
198
  end
161
199
 
162
- text.to_s
200
+ parts.join("\n\n")
163
201
  end
164
202
 
165
- # Returns true when this looks like the first exchange in a session
166
- # (no prior assistant messages in the conversation history).
167
- def first_message_in_session?(messages)
168
- messages.none? do |m|
169
- role = (m[:role] || m["role"]).to_s
170
- # "model" is used by some providers (e.g. Gemini) as an alias for "assistant"
171
- role == "assistant" || role == "model"
203
+ def extract_message_text(message)
204
+ parts = message[:parts] || message["parts"]
205
+ if parts
206
+ Array(parts).filter_map { |p| p[:text] || p["text"] }.join("\n")
207
+ else
208
+ message[:text] || message["text"] || message[:content] || message["content"]
172
209
  end
173
210
  end
174
211
 
175
- # Build a context prefix from system/context messages if present.
176
- # This includes creative tree markdown and other context that the Gateway
177
- # wouldn't have from its own agent config.
178
- def build_context_prefix(messages)
179
- # Find the first "user" message that looks like creative context
180
- # (typically starts with "Creative:\n")
181
- context_msg = messages.find do |m|
182
- role = m[:role] || m["role"]
183
- text = extract_message_text(m)
184
- role.to_s == "user" && text&.start_with?("Creative:")
185
- end
212
+ def extract_image_sources(message)
213
+ parts = message[:parts] || message["parts"]
214
+ return [] if parts.nil?
186
215
 
187
- return nil unless context_msg
216
+ Array(parts).filter_map { |part| part[:image] || part["image"] }
217
+ end
188
218
 
189
- extract_message_text(context_msg)
219
+ def extract_ws_attachments(messages)
220
+ Array(messages).flat_map do |message|
221
+ extract_image_sources(message).filter_map { |source| encode_image_source_for_ws(source) }
222
+ end
190
223
  end
191
224
 
192
- def extract_message_text(message)
193
- parts = message[:parts] || message["parts"]
194
- if parts
195
- Array(parts).filter_map { |p| p[:text] || p["text"] }.join("\n")
225
+ def encode_image_source(source)
226
+ if defined?(ActiveStorage) && source.is_a?(ActiveStorage::Blob)
227
+ data = Base64.strict_encode64(source.download)
228
+ {
229
+ type: "image_url",
230
+ image_url: { url: "data:#{source.content_type};base64,#{data}" }
231
+ }
232
+ elsif source.respond_to?(:download)
233
+ # ActiveStorage::Attached::One
234
+ blob = source.respond_to?(:blob) ? source.blob : source
235
+ return nil unless blob
236
+
237
+ data = Base64.strict_encode64(blob.download)
238
+ {
239
+ type: "image_url",
240
+ image_url: { url: "data:#{blob.content_type};base64,#{data}" }
241
+ }
242
+ elsif source.is_a?(String) && source.match?(%r{^https?://})
243
+ { type: "image_url", image_url: { url: source } }
244
+ elsif source.is_a?(String)
245
+ # File path
246
+ return nil unless File.exist?(source)
247
+
248
+ mime = Marcel::MimeType.for(Pathname.new(source))
249
+ data = Base64.strict_encode64(File.binread(source))
250
+ { type: "image_url", image_url: { url: "data:#{mime};base64,#{data}" } }
196
251
  else
197
- message[:text] || message["text"] || message[:content] || message["content"]
252
+ nil
253
+ end
254
+ rescue StandardError => e
255
+ Rails.logger.warn("[CollavreOpenclaw] Failed to encode image: #{e.message}")
256
+ nil
257
+ end
258
+
259
+ def encode_image_source_for_ws(source)
260
+ if defined?(ActiveStorage) && source.is_a?(ActiveStorage::Blob)
261
+ {
262
+ type: "image",
263
+ mimeType: source.content_type,
264
+ fileName: source.filename.to_s,
265
+ content: Base64.strict_encode64(source.download)
266
+ }
267
+ elsif source.respond_to?(:download)
268
+ blob = source.respond_to?(:blob) ? source.blob : source
269
+ return nil unless blob
270
+
271
+ {
272
+ type: "image",
273
+ mimeType: blob.content_type,
274
+ fileName: blob.filename.to_s,
275
+ content: Base64.strict_encode64(blob.download)
276
+ }
277
+ elsif source.is_a?(String) && source.match?(%r{^https?://})
278
+ nil
279
+ elsif source.is_a?(String)
280
+ return nil unless File.exist?(source)
281
+
282
+ {
283
+ type: "image",
284
+ mimeType: Marcel::MimeType.for(Pathname.new(source)),
285
+ fileName: File.basename(source),
286
+ content: Base64.strict_encode64(File.binread(source))
287
+ }
288
+ else
289
+ nil
198
290
  end
291
+ rescue StandardError => e
292
+ Rails.logger.warn("[CollavreOpenclaw] Failed to encode WS image attachment: #{e.message}")
293
+ nil
199
294
  end
200
295
 
201
296
  # ─────────────────────────────────────────────
@@ -240,7 +335,7 @@ module CollavreOpenclaw
240
335
  # Format: agent:<agent_id>:collavre:<user_id>:creative:<id>:topic:<id>
241
336
  def build_session_key
242
337
  creative_id = extract_id(@context, :creative) || @context[:creative_id]
243
- topic_id = @context[:thread_id] || @context[:topic_id]
338
+ topic_id = @context[:thread_id] || @context[:topic_id] || infer_topic_id
244
339
  agent_id = extract_agent_id_from_email || "main"
245
340
 
246
341
  parts = [ "agent", agent_id, "collavre", @user.id ]
@@ -277,7 +372,7 @@ module CollavreOpenclaw
277
372
 
278
373
  creative_id = extract_id(@context, :creative) || @context[:creative_id]
279
374
  comment_id = extract_id(@context, :comment) || @context[:comment_id]
280
- topic_id = @context[:thread_id] || @context[:topic_id]
375
+ topic_id = @context[:thread_id] || @context[:topic_id] || infer_topic_id
281
376
 
282
377
  callback = callback_url
283
378
  if callback.present? && creative_id.present?
@@ -308,14 +403,25 @@ module CollavreOpenclaw
308
403
  def format_messages(messages)
309
404
  Array(messages).map do |msg|
310
405
  role = msg[:role] || msg["role"]
311
- content = extract_message_text(msg)
406
+ text = extract_message_text(msg)
312
407
 
313
408
  sender_name = msg[:sender_name] || msg["sender_name"]
314
409
  if sender_name.present? && normalize_role(role) == "user"
315
- content = "[#{sender_name}]: #{content}"
410
+ text = "[#{sender_name}]: #{text}"
316
411
  end
317
412
 
318
- { role: normalize_role(role), content: content.to_s }
413
+ image_sources = extract_image_sources(msg)
414
+
415
+ if image_sources.any?
416
+ content_parts = [ { type: "text", text: text.to_s } ]
417
+ image_sources.each do |source|
418
+ image_data = encode_image_source(source)
419
+ content_parts << image_data if image_data
420
+ end
421
+ { role: normalize_role(role), content: content_parts }
422
+ else
423
+ { role: normalize_role(role), content: text.to_s }
424
+ end
319
425
  end
320
426
  end
321
427
 
@@ -487,6 +593,15 @@ module CollavreOpenclaw
487
593
  @user.email.split("@").first
488
594
  end
489
595
 
596
+ # Infer topic_id from the comment object in context when not explicitly provided.
597
+ # AiAgentService passes :comment (the reply or original comment) which carries topic_id.
598
+ def infer_topic_id
599
+ comment = @context[:comment]
600
+ return comment.topic_id if comment.respond_to?(:topic_id) && comment.topic_id.present?
601
+
602
+ nil
603
+ end
604
+
490
605
  def default_url_options
491
606
  options = Rails.application.config.action_mailer.default_url_options || {}
492
607
 
@@ -22,6 +22,20 @@ module CollavreOpenclaw
22
22
  COMPLETED_RUN_COOLDOWN = 5 # seconds to suppress late-arriving events for completed runs
23
23
  SEEN_EVENT_TTL = 30 # seconds to remember (runId, seq) pairs for dedup
24
24
 
25
+ # WebSocket close codes → reconnection policy
26
+ # :reconnect — schedule reconnect with exponential backoff
27
+ # :fatal — permanent failure (auth, forbidden), don't retry, propagate error
28
+ # :normal — clean shutdown, no reconnect, no error
29
+ CLOSE_POLICIES = {
30
+ 1000 => :normal, # Normal closure
31
+ 1001 => :reconnect, # Going away (server shutting down)
32
+ 1006 => :reconnect, # Abnormal closure (network issue)
33
+ 1008 => :fatal, # Policy violation (likely auth)
34
+ 1011 => :reconnect, # Internal server error
35
+ 4001 => :fatal, # Auth failure (OpenClaw)
36
+ 4003 => :fatal # Forbidden (OpenClaw)
37
+ }.freeze
38
+
25
39
  attr_reader :user, :state
26
40
 
27
41
  # States: :disconnected, :connecting, :connected, :reconnecting
@@ -39,8 +53,7 @@ module CollavreOpenclaw
39
53
  @proactive_handler = nil
40
54
  @reconnect_attempts = 0
41
55
  @last_activity_at = nil
42
- @tick_interval_ms = 15_000
43
- @tick_timer = nil
56
+ @rpc_run_registrations = {} # RPC request_id → run_queue (for EM-thread runId registration)
44
57
  end
45
58
 
46
59
  def connected?
@@ -52,7 +65,6 @@ module CollavreOpenclaw
52
65
  def connect!
53
66
  return if connected?
54
67
 
55
- initiator = false
56
68
  waiter_queue = nil
57
69
 
58
70
  @connect_mutex.synchronize do
@@ -64,7 +76,6 @@ module CollavreOpenclaw
64
76
  @connect_waiters << waiter_queue
65
77
  else
66
78
  @state = :connecting
67
- initiator = true
68
79
  end
69
80
  end
70
81
 
@@ -126,7 +137,6 @@ module CollavreOpenclaw
126
137
  def disconnect!
127
138
  @state = :disconnected
128
139
  EmReactor.next_tick do
129
- cancel_tick_timer!
130
140
  @ws&.close
131
141
  @ws = nil
132
142
  end
@@ -135,16 +145,18 @@ module CollavreOpenclaw
135
145
  @pending_requests.clear
136
146
  @pending_runs.each_value { |q| q.push({ done: true }) }
137
147
  @pending_runs.clear
148
+ @rpc_run_registrations.clear
138
149
  end
139
150
 
140
151
  # Send a chat message. Blocks and yields streaming events.
141
152
  #
142
153
  # @param session_key [String]
143
154
  # @param message [String]
155
+ # @param attachments [Array<Hash>, nil]
144
156
  # @param idempotency_key [String]
145
157
  # @yield [Hash] chat events with :state, :text, :message keys
146
158
  # @return [String, nil] final response text
147
- def chat_send(session_key:, message:, idempotency_key: nil, &block)
159
+ def chat_send(session_key:, message:, attachments: nil, idempotency_key: nil, &block)
148
160
  ensure_connected!
149
161
  touch_activity!
150
162
 
@@ -156,19 +168,33 @@ module CollavreOpenclaw
156
168
  # Pre-register with idempotency_key to catch early events
157
169
  @mutex.synchronize { @pending_runs[idempotency_key] = run_queue }
158
170
 
159
- # Send the RPC request to get the real runId
160
- response = send_rpc("chat.send", {
171
+ # Send the RPC request to get the real runId.
172
+ # IMPORTANT: We pass the run_queue via @rpc_run_registrations so that
173
+ # handle_response can register @pending_runs[actual_run_id] on the EM
174
+ # thread BEFORE any subsequent chat events arrive. This prevents a race
175
+ # condition where fast Gateway responses send events before the Rails
176
+ # thread can re-register with the actual runId.
177
+ rpc_request_id = SecureRandom.uuid
178
+ @mutex.synchronize { @rpc_run_registrations[rpc_request_id] = run_queue }
179
+
180
+ rpc_params = {
161
181
  sessionKey: session_key,
162
182
  message: message,
163
183
  idempotencyKey: idempotency_key
164
- })
184
+ }
185
+ rpc_params[:attachments] = attachments if attachments.present?
186
+
187
+ response = send_rpc("chat.send", rpc_params, request_id: rpc_request_id)
165
188
 
166
- # Re-register with the Gateway-assigned runId
189
+ # The EM thread already registered @pending_runs[actual_run_id] in
190
+ # handle_response. Clean up the idempotency_key entry if a different
191
+ # runId was assigned.
167
192
  actual_run_id = response&.dig(:runId) || idempotency_key
168
193
  if actual_run_id != idempotency_key
169
194
  @mutex.synchronize do
170
195
  @pending_runs.delete(idempotency_key)
171
- @pending_runs[actual_run_id] = run_queue
196
+ # Ensure runId is registered (may already be from handle_response)
197
+ @pending_runs[actual_run_id] ||= run_queue
172
198
  end
173
199
  end
174
200
 
@@ -185,14 +211,19 @@ module CollavreOpenclaw
185
211
  end
186
212
 
187
213
  # Gateway may broadcast + nodeSend the same event, causing
188
- # duplicates on the same WebSocket. Skip already-seen seqs.
214
+ # duplicates on the same WebSocket. Skip already-seen seqs for
215
+ # deltas only. Terminal events (final, error, aborted) must NEVER
216
+ # be skipped — they break the loop and unblock the caller.
189
217
  seq = event[:seq]
190
- if seq && last_seq && seq <= last_seq
218
+ event_state = event[:state]
219
+ is_terminal = event_state == "final" || event_state == "error" || event_state == "aborted"
220
+
221
+ if !is_terminal && seq && last_seq && seq <= last_seq
191
222
  next
192
223
  end
193
224
  last_seq = seq if seq
194
225
 
195
- case event[:state]
226
+ case event_state
196
227
  when "delta"
197
228
  text = extract_event_text(event)
198
229
  if text.present?
@@ -260,6 +291,12 @@ module CollavreOpenclaw
260
291
  @proactive_handler = handler
261
292
  end
262
293
 
294
+ # Register a callback invoked when the connection dies with a fatal close code
295
+ # (auth failure, forbidden, etc.). ConnectionManager uses this to remove dead clients.
296
+ def on_fatal_close(&handler)
297
+ @on_fatal_close = handler
298
+ end
299
+
263
300
  # Time since last activity (for idle timeout)
264
301
  def idle_seconds
265
302
  return Float::INFINITY unless @last_activity_at
@@ -272,6 +309,23 @@ module CollavreOpenclaw
272
309
  CollavreOpenclaw.config
273
310
  end
274
311
 
312
+ # Determine reconnection policy for a WebSocket close code.
313
+ # Returns :reconnect, :fatal, or :normal.
314
+ def close_policy(code)
315
+ CLOSE_POLICIES[code] || (code.to_i >= 4000 ? :fatal : :reconnect)
316
+ end
317
+
318
+ # Drain all pending requests and streaming runs with an error message.
319
+ # Called on fatal close to unblock waiting Rails threads.
320
+ def drain_pending_with_error!(message)
321
+ @mutex.synchronize do
322
+ @pending_requests.each_value { |pr| pr[:queue]&.push({ error: message }) }
323
+ @pending_requests.clear
324
+ @pending_runs.each_value { |q| q.push({ error: message }) }
325
+ @pending_runs.clear
326
+ end
327
+ end
328
+
275
329
  def gateway_ws_url
276
330
  url = @user.gateway_url.to_s.strip
277
331
  return nil if url.blank?
@@ -301,8 +355,7 @@ module CollavreOpenclaw
301
355
  @handshake_done = false
302
356
 
303
357
  @ws.on :open do |_event|
304
- Rails.logger.info("[CollavreOpenclaw::WS] Connected to #{url}")
305
- # Wait for connect.challenge from gateway
358
+ Rails.logger.info("[CollavreOpenclaw::WS] CONNECT gateway=#{url} state=open")
306
359
  end
307
360
 
308
361
  @ws.on :message do |event|
@@ -312,18 +365,26 @@ module CollavreOpenclaw
312
365
  @ws.on :close do |event|
313
366
  code = event.code
314
367
  reason = event.reason
315
- Rails.logger.info("[CollavreOpenclaw::WS] Disconnected (code=#{code}, reason=#{reason})")
316
-
317
- cancel_tick_timer!
368
+ policy = close_policy(code)
369
+ Rails.logger.info("[CollavreOpenclaw::WS] DISCONNECT gateway=#{url} code=#{code} reason=#{reason} policy=#{policy}")
318
370
 
319
371
  unless @handshake_done
320
372
  @handshake_done = true
321
373
  @handshake_queue&.push({ error: "Connection closed during handshake (code=#{code})" })
322
374
  end
323
375
 
324
- if @state == :connected
325
- @state = :reconnecting
326
- schedule_reconnect!
376
+ case policy
377
+ when :reconnect
378
+ if @state == :connected || @state == :connecting
379
+ @state = :reconnecting
380
+ schedule_reconnect!
381
+ end
382
+ when :fatal
383
+ @state = :disconnected
384
+ drain_pending_with_error!("Connection closed with fatal code #{code}: #{reason}")
385
+ @on_fatal_close&.call(self)
386
+ when :normal
387
+ @state = :disconnected
327
388
  end
328
389
  end
329
390
  end
@@ -395,10 +456,8 @@ module CollavreOpenclaw
395
456
  if id == @connect_request_id && !@handshake_done
396
457
  @handshake_done = true
397
458
  if ok
398
- @tick_interval_ms = payload&.dig(:policy, :tickIntervalMs) || 15_000
399
459
  @state = :connected
400
460
  @reconnect_attempts = 0
401
- start_tick_timer!
402
461
  @handshake_queue&.push({ ok: true, payload: payload })
403
462
  else
404
463
  error_msg = error&.dig(:message) || error.to_s || "handshake failed"
@@ -411,6 +470,19 @@ module CollavreOpenclaw
411
470
  # Regular RPC response
412
471
  pending = @mutex.synchronize { @pending_requests.delete(id) }
413
472
  if pending
473
+ # If this RPC response contains a runId (chat.send response), register
474
+ # the run_queue under that runId NOW, on the EM thread, before any
475
+ # subsequent chat events arrive. This eliminates the race condition
476
+ # where fast events arrive before the Rails thread can re-register.
477
+ if ok && payload.is_a?(Hash) && payload[:runId]
478
+ run_queue = @mutex.synchronize { @rpc_run_registrations.delete(id) }
479
+ if run_queue
480
+ @mutex.synchronize { @pending_runs[payload[:runId]] = run_queue }
481
+ end
482
+ else
483
+ @mutex.synchronize { @rpc_run_registrations.delete(id) }
484
+ end
485
+
414
486
  if ok
415
487
  pending[:queue].push({ ok: true, payload: payload })
416
488
  else
@@ -425,9 +497,11 @@ module CollavreOpenclaw
425
497
  seq = payload[:seq]
426
498
 
427
499
  # Dedup: Gateway sends identical events via broadcast() + nodeSendToSession().
428
- # Skip if we've already seen this (runId, seq) pair.
429
- if run_id && seq && duplicate_chat_event?(run_id, seq)
430
- Rails.logger.debug("[CollavreOpenclaw::WS] Skipping duplicate chat event (runId=#{run_id}, seq=#{seq})")
500
+ # Key includes state so that a delta and final with the same seq are NOT
501
+ # treated as duplicates (they are different events that share a seq number).
502
+ state = payload[:state]
503
+ if run_id && seq && duplicate_chat_event?(run_id, seq, state)
504
+ Rails.logger.debug("[CollavreOpenclaw::WS] CHAT run=#{run_id} seq=#{seq} state=dedup_skipped")
431
505
  return
432
506
  end
433
507
 
@@ -439,21 +513,22 @@ module CollavreOpenclaw
439
513
  run_queue.push(payload)
440
514
  elsif recently_completed_run?(run_id)
441
515
  # Late-arriving event for a run we already finished — suppress it
442
- Rails.logger.info("[CollavreOpenclaw::WS] Suppressing late event for completed runId=#{run_id}")
516
+ Rails.logger.info("[CollavreOpenclaw::WS] CHAT run=#{run_id} state=late_suppressed")
443
517
  elsif @proactive_handler
444
518
  # Unknown run — proactive message from Gateway (cron/heartbeat)
445
- Rails.logger.info("[CollavreOpenclaw::WS] Proactive message received (runId=#{run_id})")
519
+ Rails.logger.info("[CollavreOpenclaw::WS] CHAT run=#{run_id} state=proactive")
446
520
  @proactive_handler.call(@user, payload)
447
521
  else
448
- Rails.logger.debug("[CollavreOpenclaw::WS] Ignoring chat event for unknown runId=#{run_id}")
522
+ Rails.logger.debug("[CollavreOpenclaw::WS] CHAT run=#{run_id} state=ignored_no_handler")
449
523
  end
450
524
  end
451
525
 
452
- # Check if this (runId, seq) pair was already seen. Records it if new.
526
+ # Check if this (runId, seq, state) triple was already seen. Records it if new.
453
527
  # Gateway emits each event via broadcast() AND nodeSendToSession(),
454
- # delivering the identical payload (same runId + seq) twice.
455
- def duplicate_chat_event?(run_id, seq)
456
- key = "#{run_id}:#{seq}"
528
+ # delivering the identical payload twice. Including state in the key
529
+ # ensures a delta and final with the same seq are treated as distinct events.
530
+ def duplicate_chat_event?(run_id, seq, state = nil)
531
+ key = "#{run_id}:#{seq}:#{state}"
457
532
  now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
458
533
 
459
534
  @mutex.synchronize do
@@ -485,38 +560,21 @@ module CollavreOpenclaw
485
560
  end
486
561
 
487
562
  def handle_tick(_payload)
488
- # Respond with a tick acknowledgment (poll response)
489
- send_frame({
490
- type: "req",
491
- id: SecureRandom.uuid,
492
- method: "poll",
493
- params: {}
494
- })
495
- end
496
-
497
- def start_tick_timer!
498
- cancel_tick_timer!
499
- interval = @tick_interval_ms / 1000.0
500
- @tick_timer = EM.add_periodic_timer(interval) do
501
- # Send keepalive poll if the server hasn't sent a tick
502
- send_frame({
503
- type: "req",
504
- id: SecureRandom.uuid,
505
- method: "poll",
506
- params: {}
507
- })
508
- end
509
- end
510
-
511
- def cancel_tick_timer!
512
- @tick_timer&.cancel
513
- @tick_timer = nil
563
+ # The tick event from Gateway is a keepalive heartbeat.
564
+ # Receiving it already refreshes our activity timer (via touch_activity!
565
+ # in handle_raw_message). No response is needed.
514
566
  end
515
567
 
516
568
  # Send an RPC request and block until the response.
517
569
  # Returns the response payload.
518
- def send_rpc(method, params)
519
- request_id = SecureRandom.uuid
570
+ #
571
+ # @param method [String] RPC method name
572
+ # @param params [Hash] RPC parameters
573
+ # @param request_id [String, nil] Pre-generated request ID. Used by chat_send
574
+ # to correlate the RPC response with the run_queue for EM-thread runId
575
+ # registration (see handle_response). If nil, a random UUID is generated.
576
+ def send_rpc(method, params, request_id: nil)
577
+ request_id ||= SecureRandom.uuid
520
578
  queue = Queue.new
521
579
 
522
580
  @mutex.synchronize do
@@ -566,7 +624,8 @@ module CollavreOpenclaw
566
624
  delay = config.ws_reconnect_base_delay * (2**(@reconnect_attempts - 1))
567
625
  delay = [ delay, 60 ].min # Cap at 60 seconds
568
626
 
569
- Rails.logger.info("[CollavreOpenclaw::WS] Reconnecting in #{delay}s (attempt #{@reconnect_attempts}/#{max})")
627
+ url = gateway_ws_url
628
+ Rails.logger.info("[CollavreOpenclaw::WS] RECONNECT gateway=#{url} attempt=#{@reconnect_attempts}/#{max} delay=#{delay}s")
570
629
 
571
630
  EM.add_timer(delay) do
572
631
  next if @state == :disconnected # User explicitly disconnected
@@ -580,13 +639,13 @@ module CollavreOpenclaw
580
639
  EM.add_timer(config.ws_connect_timeout) do
581
640
  unless @handshake_done
582
641
  @handshake_done = true
583
- Rails.logger.warn("[CollavreOpenclaw::WS] Reconnect handshake timed out")
642
+ Rails.logger.warn("[CollavreOpenclaw::WS] RECONNECT gateway=#{url} state=handshake_timeout")
584
643
  @ws&.close
585
644
  schedule_reconnect!
586
645
  end
587
646
  end
588
647
  rescue => e
589
- Rails.logger.error("[CollavreOpenclaw::WS] Reconnect failed: #{e.message}")
648
+ Rails.logger.error("[CollavreOpenclaw::WS] RECONNECT gateway=#{url} state=fail reason=#{e.message}")
590
649
  schedule_reconnect!
591
650
  end
592
651
  end
@@ -33,7 +33,7 @@ module CollavreOpenclaw
33
33
  @ws_reconnect_max = ENV.fetch("OPENCLAW_WS_RECONNECT_MAX", 10).to_i
34
34
  @ws_reconnect_base_delay = ENV.fetch("OPENCLAW_WS_RECONNECT_BASE", 1).to_f
35
35
  @ws_connect_timeout = ENV.fetch("OPENCLAW_WS_CONNECT_TIMEOUT", 10).to_i
36
- @transport = ENV.fetch("OPENCLAW_TRANSPORT", "http")
36
+ @transport = ENV.fetch("OPENCLAW_TRANSPORT", "auto")
37
37
  end
38
38
 
39
39
  # Legacy accessor for backward compatibility
@@ -1,3 +1,3 @@
1
1
  module CollavreOpenclaw
2
- VERSION = "0.3.1"
2
+ VERSION = "0.5.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: collavre_openclaw
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Collavre