From 9277646a4bb2cf25a8384ffc140b154f49ea5766 Mon Sep 17 00:00:00 2001 From: Ajay Krishnan Date: Sun, 19 Jul 2026 22:20:04 -0700 Subject: [PATCH] Prepare opencode-ruby alpha8 --- .github/workflows/release.yml | 8 ++++ CHANGELOG.md | 16 ++++++++ lib/opencode/client.rb | 35 +++++++++++++---- lib/opencode/version.rb | 2 +- test/opencode/smoke_test.rb | 73 +++++++++++++++++++++++++++++++++++ test/release_workflow_test.rb | 15 +++++++ 6 files changed, 141 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d3a9f13..db94508 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,4 +21,12 @@ jobs: with: ruby-version: "4.0" bundler-cache: true + - name: Verify tag matches gem version + env: + RELEASE_TAG: ${{ github.ref_name }} + run: >- + ruby -Ilib -ropencode/version -e + 'expected = ENV.fetch("RELEASE_TAG").delete_prefix("v"); + abort "tag #{expected.inspect} does not match gem #{Opencode::VERSION.inspect}" + unless Opencode::VERSION == expected' - uses: rubygems/release-gem@052cc82692552de3ef2b81fd670e41d13cba8092 # v1.4.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5298a19..699e8b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.0.1.alpha8 - 2026-07-20 + +### Fixed + +- Parse SSE events with CRLF, CR-only, or LF framing; accept `data:` fields + with or without the optional leading space; join multiple `data:` fields; + ignore the stream's optional leading UTF-8 BOM and comment fields; and decode + correctly when a transport chunk splits any byte of the event framing. + +### Changed + +- Test Ruby 3.2, 3.3, 3.4, and 4.0; pin every third-party CI and release + action to an exact reviewed commit; and use Ruby 4.0 for release builds. +- Fail the trusted-publishing job before release when the pushed tag does not + match `Opencode::VERSION`. + ## 0.0.1.alpha7 - 2026-07-18 ### Fixed diff --git a/lib/opencode/client.rb b/lib/opencode/client.rb index 9308ff3..624cd1b 100644 --- a/lib/opencode/client.rb +++ b/lib/opencode/client.rb @@ -255,6 +255,10 @@ module Opencode end MAX_SSE_BUFFER = 1_048_576 # 1 MB — safety valve against pathological server responses + # Keep CRLF atomic so it cannot backtrack into CR + LF and look like a + # blank line. SSE also permits bare CR and LF line endings. + SSE_EVENT_BOUNDARY = /(?>\r\n|[\r\n]){2}/ + UTF8_BOM = "\xEF\xBB\xBF".b.freeze SSE_RECONNECT_DELAY = 0.1 TRANSIENT_SSE_ERRORS = [ EOFError, @@ -360,6 +364,7 @@ module Opencode subscription_ready = on_subscribed.nil? begin buffer = String.new + first_sse_event = true http.request(request) do |response| unless response.is_a?(Net::HTTPSuccess) @@ -386,9 +391,14 @@ module Opencode raise ServerError, "SSE buffer exceeded #{MAX_SSE_BUFFER} bytes" end - while (idx = buffer.index("\n\n")) - raw_event = buffer.slice!(0, idx + 2) - event = parse_sse_event(raw_event, session_id) + while (boundary = buffer.match(SSE_EVENT_BOUNDARY)) + raw_event = buffer.slice!(0, boundary.end(0)) + event = parse_sse_event( + raw_event, + session_id, + allow_bom: first_sse_event + ) + first_sse_event = false next unless event unless subscription_ready @@ -639,11 +649,22 @@ module Opencode end end - def parse_sse_event(raw, session_id) - data_line = raw.lines.find { |l| l.start_with?("data: ") } - return nil unless data_line + def parse_sse_event(raw, session_id, allow_bom: false) + if allow_bom && raw.b.start_with?(UTF8_BOM) + raw = raw.byteslice(UTF8_BOM.bytesize..) + end - json = JSON.parse(data_line.sub("data: ", "").strip, symbolize_names: true) + data = raw.split(/\r\n|\r|\n/, -1).filter_map do |line| + next if line.start_with?(":") + + field, value = line.split(":", 2) + next unless field == "data" + + (value || "").delete_prefix(" ") + end + return nil if data.empty? + + json = JSON.parse(data.join("\n"), symbolize_names: true) event_session = json.dig(:properties, :sessionID) || json.dig(:properties, :info, :sessionID) || diff --git a/lib/opencode/version.rb b/lib/opencode/version.rb index bee0247..68247d8 100644 --- a/lib/opencode/version.rb +++ b/lib/opencode/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Opencode - VERSION = "0.0.1.alpha7" + VERSION = "0.0.1.alpha8" end diff --git a/test/opencode/smoke_test.rb b/test/opencode/smoke_test.rb index 7e160a6..209b1b5 100644 --- a/test/opencode/smoke_test.rb +++ b/test/opencode/smoke_test.rb @@ -386,6 +386,79 @@ class SmokeTest < Minitest::Test assert_requested event_stream, times: 1 end + def test_stream_events_accepts_standard_sse_framing_variants + terminal_event = { + type: "session.status", + properties: { sessionID: SESSION_ID, status: { type: "idle" } } + } + connected_data = JSON.pretty_generate(CONNECTED_EVENT).lines(chomp: true) + .map { |line| "data: #{line}\r\n" } + .join + body = ": readiness comment\r\nevent: message\r\n#{connected_data}\r\n" \ + "data:#{terminal_event.to_json}\r\r" + + event_stream = stub_request(:get, %r{#{Regexp.escape(BASE)}/event(\?.*)?\z}) + .to_return(status: 200, body: body, + headers: { "Content-Type" => "text/event-stream" }) + + subscribed_calls = 0 + event_types = [] + @client.stream_events( + session_id: SESSION_ID, + timeout: 1, + first_event_timeout: 1, + on_subscribed: -> { + subscribed_calls += 1 + true + } + ) { |event| event_types << event.fetch(:type) } + + assert_equal 1, subscribed_calls + assert_equal [ "server.connected", "session.status" ], event_types + assert_requested event_stream, times: 1 + end + + def test_stream_events_handles_a_bom_and_boundaries_split_across_chunks + terminal_event = { + type: "session.status", + properties: { sessionID: SESSION_ID, status: { type: "idle" } } + } + body = "\uFEFF: initial comment\r\n\r\n" \ + "data: #{CONNECTED_EVENT.to_json}\r\n\r\n" \ + "data: #{terminal_event.to_json}\n\n" + chunks = body.b.bytes.map { |byte| byte.chr(Encoding::BINARY) } + + response = Net::HTTPOK.new("1.1", "200", "OK") + response.define_singleton_method(:read_body) do |&callback| + chunks.each(&callback) + end + http = Object.new + http.define_singleton_method(:use_ssl=) { |_value| } + http.define_singleton_method(:open_timeout=) { |_value| } + http.define_singleton_method(:read_timeout=) { |_value| } + http.define_singleton_method(:request) do |_request, &callback| + callback.call(response) + end + http.define_singleton_method(:started?) { false } + + subscribed_calls = 0 + event_types = [] + Net::HTTP.stub(:new, ->(_host, _port) { http }) do + @client.stream_events( + session_id: SESSION_ID, + timeout: 1, + first_event_timeout: 1, + on_subscribed: -> { + subscribed_calls += 1 + true + } + ) { |event| event_types << event.fetch(:type) } + end + + assert_equal 1, subscribed_calls + assert_equal [ "server.connected", "session.status" ], event_types + end + def test_stream_events_preserves_question_and_permission_wait_state events = [ { diff --git a/test/release_workflow_test.rb b/test/release_workflow_test.rb index e81664d..ecbc78d 100644 --- a/test/release_workflow_test.rb +++ b/test/release_workflow_test.rb @@ -33,6 +33,21 @@ class ReleaseWorkflowTest < Minitest::Test assert_equal "4.0", setup_ruby.dig("with", "ruby-version") assert_equal 1, steps.count { |step| step["uses"] == RELEASE_GEM_ACTION } + assert steps.filter_map { |step| step["uses"] }.all? { |uses| uses.match?(/@[0-9a-f]{40}\z/) } refute steps.any? { |step| step.fetch("run", "").match?(/\bgem\s+push\b/) } end + + def test_release_tag_must_match_the_gem_version_before_publish + steps = push_job.fetch("steps") + preflight_index = steps.index { |step| step["name"] == "Verify tag matches gem version" } + publish_index = steps.index { |step| step["uses"] == RELEASE_GEM_ACTION } + + refute_nil preflight_index + refute_nil publish_index + assert_operator preflight_index, :<, publish_index + + preflight = steps.fetch(preflight_index) + assert_equal "${{ github.ref_name }}", preflight.dig("env", "RELEASE_TAG") + assert_includes preflight.fetch("run"), "unless Opencode::VERSION == expected" + end end