opencode-ruby 0.0.1.alpha7 → 0.0.1.alpha9

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: 7a4ddecebacda21e25ac50bd14d6d36c8ad61b35d23dee985b620d82a2b94b36
4
- data.tar.gz: 3aa46267d464d9f95ddc96cb0a7d59cff04a9559c1dffb8d5c6d99c84f46b7b5
3
+ metadata.gz: d0723db2b0d77a65c6718f0dcc49b67d298a62c33996169a697eb47c9436980a
4
+ data.tar.gz: c0446dbb4539a3432e6d0b71f4790b6c16377ac8aad32406c6f178b04c1dbf54
5
5
  SHA512:
6
- metadata.gz: 2b7a4ef58b44d2d179c3dd389a0b3a2593557188fe03e62d8e63fa19b766de22f75cef35309f2155d3eb6af80bb79c4f44f3251448602aade9fc928761561a4e
7
- data.tar.gz: 141450c28378875563cb27742eea0ae60d08ef325c37b40923bb0b4103e26f9ce82dd603dc5db313578a12e8a7a160bbbea5aefdeed7e5f87751acb364410cd6
6
+ metadata.gz: 73205b52ce2f279b8daee9f7274f52d3ff2382cba757f8af2cfce0d2addb0e67c1bf7b684f6575b9161af53678bccfd0ff40c154afe2336b4202fff5f2085f7a
7
+ data.tar.gz: 98d7e60949dcad273ab8533c4652902084f0df117cb52ec522ee7a9cdf8bf35906093479634350b395ea1159d9d881e1b683c70a53fd7be5f5815d4d96cb0d84
data/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.1.alpha9 - 2026-07-20
4
+
5
+ ### Fixed
6
+
7
+ - Parse SSE events with CRLF, CR-only, or LF framing; accept `data:` fields
8
+ with or without the optional leading space; join multiple `data:` fields;
9
+ ignore the stream's optional leading UTF-8 BOM and comment fields; and decode
10
+ correctly when a transport chunk splits any byte of the event framing.
11
+ - Keep request instrumentation free of query credentials and private workspace
12
+ paths.
13
+ - Load non-empty todo events and written-file artifacts in standalone Ruby
14
+ clients.
15
+ - Bound reconnects after an interactive prompt loses its event stream, handle
16
+ DNS failures as transient stream errors, and map non-404 4xx responses to
17
+ `BadRequestError` consistently.
18
+ - Retry the Rails recovery recipe with its replacement session and keep
19
+ exception details out of user-facing message content.
20
+
21
+ ### Changed
22
+
23
+ - Test Ruby 3.2, 3.3, 3.4, and 4.0; pin every third-party CI and release
24
+ action to an exact reviewed commit; and use Ruby 4.0 for release builds.
25
+ - Fail the trusted-publishing job before release when the pushed tag does not
26
+ match `Opencode::VERSION`.
27
+
28
+ ## 0.0.1.alpha8 - 2026-07-20
29
+
30
+ ### Yanked
31
+
32
+ - Published from the unrepaired alpha8 source by mistake and yanked the same
33
+ day. Use alpha9, which contains the intended alpha8 fixes and release gates.
34
+
3
35
  ## 0.0.1.alpha7 - 2026-07-18
4
36
 
5
37
  ### Fixed
@@ -100,5 +132,7 @@ First public alpha. HTTP + SSE client for OpenCode REST API.
100
132
  ### Compatibility
101
133
 
102
134
  - Ruby ≥ 3.2
103
- - OpenCode server 1.15 (tested against the message bus schema in `packages/opencode/src/session/message-v2.ts`)
135
+ - OpenCode targeted the then-current 1.15 message-bus shape. This historical
136
+ target was not a blanket SemVer compatibility guarantee; use the README's
137
+ current compatibility evidence for deployment decisions.
104
138
  - Runtime dependency: `activesupport (>= 6.1)` for `blank?`/`present?`/`presence`/`truncate`/`duplicable?`/`megabytes`. ActiveSupport is *not* Rails — it's a standalone helpers gem.
data/README.md CHANGED
@@ -67,7 +67,9 @@ child = client.create_session(
67
67
 
68
68
  Model strings use OpenCode's `provider/model` form; a preformatted model hash
69
69
  with `providerID` and `id` keys is also accepted. These configured-session
70
- fields require OpenCode 1.16.1 or newer.
70
+ fields first appeared in OpenCode 1.16.1. That is an API-introduction note,
71
+ not evidence that every later server is compatible; use the
72
+ [certified compatibility evidence](#compatibility) for deployment choices.
71
73
 
72
74
  ### Streaming (the headline)
73
75
 
@@ -106,8 +108,9 @@ client.update_session(session_id, permissions: permission_rules)
106
108
 
107
109
  OpenCode appends PATCHed permission rules and evaluates the last matching
108
110
  rule. Hosts should send a complete ordered policy and fingerprint it so the
109
- same policy is not appended on every turn. This endpoint requires OpenCode
110
- 1.16.1 or newer; the rest of the client remains compatible with 1.15.
111
+ same policy is not appended on every turn. This endpoint first appeared in
112
+ OpenCode 1.16.1; that version floor describes endpoint availability, not
113
+ compatibility with every later server.
111
114
 
112
115
  ### Lower-level event firehose
113
116
 
@@ -199,8 +202,29 @@ Want every OpenCode endpoint auto-generated from the OpenAPI spec? Use [`opencod
199
202
  ## Compatibility
200
203
 
201
204
  - Ruby ≥ 3.2
202
- - OpenCode server 1.15
203
- - Runtime dependency: `activesupport (>= 6.1)` *not* Rails. ActiveSupport is a standalone helpers gem (`blank?`, `present?`, `presence`, `truncate`, etc.).
205
+ - Runtime dependency: `activesupport (>= 6.1, < 9.0)` — *not* Rails. ActiveSupport is a standalone helpers gem (`blank?`, `present?`, `presence`, `truncate`, etc.).
206
+ - Runtime dependency: `marcel (~> 1.0)` for artifact MIME type detection.
207
+
208
+ OpenCode server compatibility is evidence-based, not an open-ended SemVer
209
+ promise. OpenCode's HTTP, SSE, and runtime behavior can change independently
210
+ of a numeric version floor, so this gem does not infer compatibility from a
211
+ constraint such as `>= 1.15`.
212
+
213
+ Use the [OpenCode compatibility corpus](https://github.com/ajaynomics/opencode-compat)
214
+ as the source of current evidence. Its
215
+ [image matrix](https://github.com/ajaynomics/opencode-compat/blob/main/manifests/image-matrix.json)
216
+ records the exact gem commit and immutable OpenCode OCI digest exercised by
217
+ the `ruby-rest-sse` profile. Its
218
+ [runtime tuples](https://github.com/ajaynomics/opencode-compat/blob/main/manifests/runtime-tuples.json)
219
+ add the exact consumer commit and canary evidence needed for promotion and
220
+ rollback. A version label or image tag is provenance only, never the execution
221
+ coordinate.
222
+
223
+ A combination absent from passing evidence is **unverified**, not necessarily
224
+ incompatible. Run the corpus against the exact gem, image digest, and consumer
225
+ commit before adopting or promoting it. See the
226
+ [certification policy](https://github.com/ajaynomics/opencode-compat/blob/main/docs/certification.md)
227
+ for the full process.
204
228
 
205
229
  ## Development
206
230
 
@@ -213,10 +237,14 @@ The smoke suite covers Client end-to-end against WebMock-stubbed OpenCode
213
237
  endpoints, including subscription-before-prompt ordering and
214
238
  reconnect-without-repost.
215
239
 
216
- Releases use RubyGems trusted publishing. After the repository's
217
- `release.yml` workflow is registered as a trusted publisher with the `release`
218
- environment, pushing a `v*` tag builds, attests, and publishes the gem without
219
- a long-lived RubyGems API key.
240
+ The mistakenly published `0.0.1.alpha8` package contained unrepaired source and
241
+ was yanked; use `0.0.1.alpha9` or later. The repository contains a tag-triggered
242
+ `release.yml` workflow intended for RubyGems trusted publishing, but its
243
+ trusted-publisher registration is not confirmed for alpha9. A `v*` tag push
244
+ therefore does not currently guarantee publication. Before a future release,
245
+ verify the RubyGems registry result explicitly. Once the workflow is registered
246
+ as a trusted publisher for the `release` environment, it can build, attest, and
247
+ publish without a long-lived RubyGems API key.
220
248
 
221
249
  ## License
222
250
 
@@ -128,12 +128,13 @@ class GenerateAssistantReplyJob < ApplicationJob
128
128
  end
129
129
  rescue Opencode::SessionNotFoundError, Opencode::StaleSessionError
130
130
  raise if attempted_recreate
131
- message.conversation.recreate_opencode_session!(client)
131
+ session_id = message.conversation.recreate_opencode_session!(client)
132
132
  attempted_recreate = true
133
133
  retry
134
134
  end
135
135
  rescue StandardError => e
136
- message&.update!(status: :errored, content: "An error occurred: #{e.message.truncate(200)}")
136
+ Rails.logger.error(e.full_message)
137
+ message&.update!(status: :errored, content: "An error occurred. Please try again.")
137
138
  end
138
139
 
139
140
  private
@@ -230,18 +230,12 @@ module Opencode
230
230
  request = Net::HTTP::Get.new(uri)
231
231
  add_auth_header(request)
232
232
 
233
- response = Opencode::Instrumentation.instrument("opencode.request", method: request.method, path: request.path) do
233
+ response = Opencode::Instrumentation.instrument("opencode.request", method: request.method, path: uri.path) do
234
234
  http_client.request(request)
235
235
  end
236
236
 
237
- unless response.code.to_i.between?(200, 299)
238
- raise ServerError, "list_questions failed: HTTP #{response.code} — #{response.body.to_s[0, 200]}"
239
- end
240
-
241
- return [] if response.body.blank?
242
- JSON.parse(response.body, symbolize_names: true)
243
- rescue JSON::ParserError => e
244
- raise ServerError, "list_questions returned invalid JSON: #{e.message}"
237
+ body = handle_response(response)
238
+ body.is_a?(Array) ? body : []
245
239
  rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => e
246
240
  raise TimeoutError, "OpenCode timeout after #{@timeout}s: #{e.message}"
247
241
  rescue Errno::ECONNREFUSED, SocketError => e
@@ -255,6 +249,10 @@ module Opencode
255
249
  end
256
250
 
257
251
  MAX_SSE_BUFFER = 1_048_576 # 1 MB — safety valve against pathological server responses
252
+ # Keep CRLF atomic so it cannot backtrack into CR + LF and look like a
253
+ # blank line. SSE also permits bare CR and LF line endings.
254
+ SSE_EVENT_BOUNDARY = /(?>\r\n|[\r\n]){2}/
255
+ UTF8_BOM = "\xEF\xBB\xBF".b.freeze
258
256
  SSE_RECONNECT_DELAY = 0.1
259
257
  TRANSIENT_SSE_ERRORS = [
260
258
  EOFError,
@@ -263,7 +261,8 @@ module Opencode
263
261
  Net::ReadTimeout,
264
262
  Errno::ECONNREFUSED,
265
263
  Errno::ECONNRESET,
266
- Errno::EPIPE
264
+ Errno::EPIPE,
265
+ SocketError
267
266
  ].freeze
268
267
 
269
268
  # Opens SSE connection to GET /event, yields parsed events filtered by session_id.
@@ -330,7 +329,7 @@ module Opencode
330
329
 
331
330
  loop do
332
331
  now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
333
- deadline = check_deadline_or_suspend(now, deadline, timeout, reply)
332
+ deadline = check_deadline(now, deadline, timeout)
334
333
 
335
334
  # NOTE: first_event_deadline is *not* suspension-eligible. If the agent
336
335
  # never gets started we want to fail fast — a session that's blocked on
@@ -357,9 +356,11 @@ module Opencode
357
356
  http.read_timeout = 30
358
357
 
359
358
  subscription_callback_error = nil
359
+ event_callback_error = nil
360
360
  subscription_ready = on_subscribed.nil?
361
361
  begin
362
362
  buffer = String.new
363
+ first_sse_event = true
363
364
 
364
365
  http.request(request) do |response|
365
366
  unless response.is_a?(Net::HTTPSuccess)
@@ -386,9 +387,14 @@ module Opencode
386
387
  raise ServerError, "SSE buffer exceeded #{MAX_SSE_BUFFER} bytes"
387
388
  end
388
389
 
389
- while (idx = buffer.index("\n\n"))
390
- raw_event = buffer.slice!(0, idx + 2)
391
- event = parse_sse_event(raw_event, session_id)
390
+ while (boundary = buffer.match(SSE_EVENT_BOUNDARY))
391
+ raw_event = buffer.slice!(0, boundary.end(0))
392
+ event = parse_sse_event(
393
+ raw_event,
394
+ session_id,
395
+ allow_bom: first_sse_event
396
+ )
397
+ first_sse_event = false
392
398
  next unless event
393
399
 
394
400
  unless subscription_ready
@@ -438,14 +444,19 @@ module Opencode
438
444
  # that's the whole point: a healthy long wait (user thinking
439
445
  # for 30 minutes) keeps the container warm via heartbeats so
440
446
  # the reaper doesn't kill it mid-wait.
441
- on_activity_tick&.call(event)
442
- block.call(event)
447
+ begin
448
+ on_activity_tick&.call(event)
449
+ block.call(event)
450
+ rescue StandardError => error
451
+ event_callback_error = error
452
+ raise
453
+ end
443
454
  return if terminal_session_event?(event)
444
455
  end
445
456
  end
446
457
  end
447
458
  rescue *TRANSIENT_SSE_ERRORS
448
- raise if subscription_callback_error
459
+ raise if subscription_callback_error || event_callback_error
449
460
 
450
461
  # Treat transport-level SSE disconnects like clean EOF: reconnect
451
462
  # until an idle session event, the overall timeout, or first-event
@@ -504,6 +515,11 @@ module Opencode
504
515
  # the prompt resolves. Otherwise apply the normal deadline check.
505
516
  def check_deadline_or_suspend(now, deadline, timeout, reply)
506
517
  return now + timeout if reply&.prompt_blocked?
518
+
519
+ check_deadline(now, deadline, timeout)
520
+ end
521
+
522
+ def check_deadline(now, deadline, timeout)
507
523
  raise TimeoutError, "SSE stream timed out after #{timeout}s" if now > deadline
508
524
 
509
525
  deadline
@@ -618,7 +634,7 @@ module Opencode
618
634
  add_auth_header(request)
619
635
 
620
636
  response = nil
621
- result = Opencode::Instrumentation.instrument("opencode.request", method: request.method, path: request.path) do
637
+ result = Opencode::Instrumentation.instrument("opencode.request", method: request.method, path: request.uri.path) do
622
638
  response = http_client.request(request)
623
639
  handle_response(response)
624
640
  end
@@ -639,11 +655,22 @@ module Opencode
639
655
  end
640
656
  end
641
657
 
642
- def parse_sse_event(raw, session_id)
643
- data_line = raw.lines.find { |l| l.start_with?("data: ") }
644
- return nil unless data_line
658
+ def parse_sse_event(raw, session_id, allow_bom: false)
659
+ if allow_bom && raw.b.start_with?(UTF8_BOM)
660
+ raw = raw.byteslice(UTF8_BOM.bytesize..)
661
+ end
662
+
663
+ data = raw.split(/\r\n|\r|\n/, -1).filter_map do |line|
664
+ next if line.start_with?(":")
665
+
666
+ field, value = line.split(":", 2)
667
+ next unless field == "data"
645
668
 
646
- json = JSON.parse(data_line.sub("data: ", "").strip, symbolize_names: true)
669
+ (value || "").delete_prefix(" ")
670
+ end
671
+ return nil if data.empty?
672
+
673
+ json = JSON.parse(data.join("\n"), symbolize_names: true)
647
674
 
648
675
  event_session = json.dig(:properties, :sessionID) ||
649
676
  json.dig(:properties, :info, :sessionID) ||
@@ -659,23 +686,30 @@ module Opencode
659
686
  end
660
687
 
661
688
  def handle_response(response)
662
- return {} if response.code.to_i == 204
689
+ status = response.code.to_i
690
+ return {} if status == 204
663
691
 
664
- body = if response.body.present?
665
- JSON.parse(response.body, symbolize_names: true)
666
- else
667
- {}
668
- end
692
+ body = parse_response_body(response, status)
669
693
 
670
- case response.code.to_i
694
+ case status
671
695
  when 200..299 then body
672
- when 400 then raise BadRequestError.new(error_message(body, "Bad request"), response: body)
673
696
  when 404 then raise SessionNotFoundError.new(error_message(body, "Session not found"), response: body)
697
+ when 400..499 then raise BadRequestError.new(error_message(body, "Bad request"), response: body)
674
698
  when 500..599 then raise ServerError.new(error_message(body, "Server error"), response: body)
675
699
  else raise Error.new("Unexpected response: #{response.code}", response: body)
676
700
  end
701
+ end
702
+
703
+ def parse_response_body(response, status)
704
+ return {} if response.body.blank?
705
+
706
+ JSON.parse(response.body, symbolize_names: true)
677
707
  rescue JSON::ParserError
678
- raise ServerError.new("Invalid JSON from OpenCode (HTTP #{response.code}): #{response.body&.truncate(200)}")
708
+ if status.between?(200, 299)
709
+ raise ServerError.new("Invalid JSON from OpenCode (HTTP #{response.code}): #{response.body&.truncate(200)}")
710
+ end
711
+
712
+ { message: response.body.to_s.truncate(200) }
679
713
  end
680
714
 
681
715
  # OpenCode HTTP error bodies use a wrapped shape: { name:, data: { message:, kind?: } }.
@@ -683,6 +717,9 @@ module Opencode
683
717
  # `body[:message]` is no longer populated for errors — only `body[:data][:message]`.
684
718
  # We read both to keep older mock servers working in tests.
685
719
  def error_message(body, fallback)
720
+ return body.truncate(200) if body.is_a?(String) && body.present?
721
+ return fallback unless body.is_a?(Hash)
722
+
686
723
  body.dig(:data, :message) || body[:message] || fallback
687
724
  end
688
725
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Opencode
4
- VERSION = "0.0.1.alpha7"
4
+ VERSION = "0.0.1.alpha9"
5
5
  end
data/lib/opencode-ruby.rb CHANGED
@@ -6,8 +6,10 @@
6
6
  # non-Rails apps.
7
7
  require "active_support/core_ext/object/blank" # provides blank?, present?, presence
8
8
  require "active_support/core_ext/object/duplicable"
9
+ require "active_support/core_ext/hash/keys" # provides Hash#deep_stringify_keys
9
10
  require "active_support/core_ext/string/filters" # provides String#truncate
10
11
  require "active_support/core_ext/numeric/bytes" # provides Integer#megabytes
12
+ require "marcel"
11
13
 
12
14
  require_relative "opencode/version"
13
15
  require_relative "opencode/error"
@@ -30,14 +30,10 @@ Gem::Specification.new do |spec|
30
30
  %w[README.md LICENSE CHANGELOG.md opencode-ruby.gemspec]
31
31
  spec.require_paths = ["lib"]
32
32
 
33
- # The only runtime dependency is ActiveSupport (NOT Rails). ActiveSupport
34
- # is a standalone gem providing the `present?`/`blank?`/`presence`/
35
- # `truncate`/`duplicable?` helpers used in this gem's code. It does NOT
36
- # pull in ActiveRecord, ActionView, ActionController, Turbo, or any other
37
- # Rails-only piece. Most Ruby apps in the wild already have ActiveSupport
38
- # transitively via another gem; in the rare case yours doesn't, ~250 LOC
39
- # of core_ext is added when this gem installs.
33
+ # ActiveSupport supplies the small set of core extensions used by the
34
+ # client without pulling in Rails. Marcel identifies artifact content types.
40
35
  spec.add_runtime_dependency "activesupport", ">= 6.1", "< 9.0"
36
+ spec.add_runtime_dependency "marcel", "~> 1.0"
41
37
 
42
38
  spec.add_development_dependency "minitest", "~> 5.20"
43
39
  spec.add_development_dependency "rake", "~> 13.0"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: opencode-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1.alpha7
4
+ version: 0.0.1.alpha9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ajay Krishnan
@@ -29,6 +29,20 @@ dependencies:
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: '9.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: marcel
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - "~>"
37
+ - !ruby/object:Gem::Version
38
+ version: '1.0'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - "~>"
44
+ - !ruby/object:Gem::Version
45
+ version: '1.0'
32
46
  - !ruby/object:Gem::Dependency
33
47
  name: minitest
34
48
  requirement: !ruby/object:Gem::Requirement