From a2450d37b0f83275b09a7453219ec3eb9a22fedc Mon Sep 17 00:00:00 2001 From: Matt Webb Date: Mon, 13 Jul 2026 14:31:27 -0700 Subject: [PATCH 1/4] Finalize an open thinking block on messageStop, not just its own contentBlockStop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bedrock's Converse stream doesn't always emit a content_block_stop for every reasoning-content index before the stream ends — observed for a redacted thinking block immediately superseded by the next block's content_block_start with no stop in between. That left thinking.blocks empty, forcing replay to fall back to format_thinking_blocks' lossy single-block reconstruction, which Anthropic rejects with "Invalid `data` in `redacted_thinking` block". messageStop now finalizes any block still open at that point, but only if it's structurally complete (has a signature or redacted-content marker) — a genuinely truncated block with neither is still dropped, matching existing behavior. Co-Authored-By: Claude Sonnet 5 --- lib/ruby_llm/protocols/converse/streaming.rb | 43 +++++++++++++++---- .../protocols/converse/streaming_spec.rb | 22 ++++++++++ 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/lib/ruby_llm/protocols/converse/streaming.rb b/lib/ruby_llm/protocols/converse/streaming.rb index b39707d64..7c6bbcce2 100644 --- a/lib/ruby_llm/protocols/converse/streaming.rb +++ b/lib/ruby_llm/protocols/converse/streaming.rb @@ -194,19 +194,44 @@ def thinking_delta_source(event) [delta_index, normalized_delta(event)['reasoningContent']] end - # A thinking block only finalizes here, on its content_block_stop. If the turn is - # truncated before this event arrives for a given index, that block is intentionally - # dropped rather than replayed signature-less — Anthropic rejects replay of a thinking - # block without a valid signature, so a half-formed block is worse than none. + # A thinking block finalizes on its own content_block_stop. As a safety net, messageStop + # (end of the event stream, on ANY stopReason including truncation) also finalizes any + # block still open at that point IF it is structurally complete (has a signature or + # redacted-content marker) — Bedrock is not guaranteed to emit a content_block_stop for + # every index before the stream ends (observed for at least one redacted-thinking block + # immediately superseded by the next index's content_block_start with no stop in + # between). Without this net, such a block is dropped from thinking.blocks entirely, + # silently falling back to format_single_thinking_block's lossy text/signature + # reconstruction — which Anthropic rejects on replay ("Invalid `data` in + # `redacted_thinking` block") since a reconstructed single block does not match the raw + # shape the API originally sent. + # + # A block still open with neither a signature nor redacted content (text-only, or fully + # empty) is genuinely half-formed — e.g. truncated mid-thought before the model ever + # emitted its closing signature — and stays dropped: Anthropic rejects replay of a + # thinking block without a valid signature, so a half-formed block is worse than none. def extract_finalized_thinking_blocks(event, thinking_state) index = event.dig('contentBlockStop', 'contentBlockIndex') - return nil unless index + if index + state = thinking_state.delete(index) + return nil unless state - state = thinking_state.delete(index) - return nil unless state + block = finalize_thinking_block(state) + return block ? [block] : nil + end + + return nil unless event.key?('messageStop') && thinking_state.any? + + finalize_remaining_thinking_blocks(thinking_state) + end + + def finalize_remaining_thinking_blocks(thinking_state) + thinking_state.keys.sort.filter_map do |index| + state = thinking_state.delete(index) + next unless state[:redacted] || state[:signature] - block = finalize_thinking_block(state) - block ? [block] : nil + finalize_thinking_block(state) + end.presence end def finalize_thinking_block(state) diff --git a/spec/ruby_llm/protocols/converse/streaming_spec.rb b/spec/ruby_llm/protocols/converse/streaming_spec.rb index 8ebf7ca16..043a72883 100644 --- a/spec/ruby_llm/protocols/converse/streaming_spec.rb +++ b/spec/ruby_llm/protocols/converse/streaming_spec.rb @@ -209,6 +209,28 @@ def accumulate(events) expect(message.finish_reason).to eq('max_tokens') end + it 'finalizes a redacted thinking block via messageStop when Bedrock never sends its own contentBlockStop' do + events = [ + { 'contentBlockStart' => { 'contentBlockIndex' => 0, + 'start' => { 'reasoningContent' => { 'redactedContent' => 'opaque-blob-1' } } } }, + # No contentBlockStop for index 0 — the next block starts immediately, as Bedrock has + # been observed to do for a redacted-thinking block with no visible content. + { 'contentBlockStart' => { 'contentBlockIndex' => 1, + 'start' => { 'toolUse' => { 'toolUseId' => 'call_1', 'name' => 'search' } } } }, + { 'contentBlockDelta' => { 'contentBlockIndex' => 1, + 'delta' => { 'toolUse' => { 'input' => '{}' } } } }, + { 'contentBlockStop' => { 'contentBlockIndex' => 1 } }, + { 'messageStop' => { 'stopReason' => 'tool_use' } } + ] + + message = accumulate(events) + + expect(message.thinking.blocks).to eq( + [{ 'reasoningContent' => { 'redactedContent' => 'opaque-blob-1' } }] + ) + expect(message.tool_calls['call_1'].name).to eq('search') + end + it 'round-trips a streamed multi-block thinking turn through format_thinking_blocks unmodified' do events = [ { 'contentBlockStart' => { 'contentBlockIndex' => 0, From 83bb28408205fbd8db6f936769db9de81c81236a Mon Sep 17 00:00:00 2001 From: Matt Webb Date: Mon, 13 Jul 2026 14:46:58 -0700 Subject: [PATCH 2/4] Drop Array#presence (ActiveSupport) from finalize_remaining_thinking_blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit activesupport isn't a runtime dependency of this gem, so #presence isn't available to non-Rails consumers — it only worked in specs because spec_helper loads active_support/core_ext. Use a plain empty? check instead. Co-Authored-By: Claude Sonnet 5 --- lib/ruby_llm/protocols/converse/streaming.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/ruby_llm/protocols/converse/streaming.rb b/lib/ruby_llm/protocols/converse/streaming.rb index 7c6bbcce2..01a0a42f1 100644 --- a/lib/ruby_llm/protocols/converse/streaming.rb +++ b/lib/ruby_llm/protocols/converse/streaming.rb @@ -226,12 +226,13 @@ def extract_finalized_thinking_blocks(event, thinking_state) end def finalize_remaining_thinking_blocks(thinking_state) - thinking_state.keys.sort.filter_map do |index| + blocks = thinking_state.keys.sort.filter_map do |index| state = thinking_state.delete(index) next unless state[:redacted] || state[:signature] finalize_thinking_block(state) - end.presence + end + blocks.empty? ? nil : blocks end def finalize_thinking_block(state) From 2fd48eddf1cd29442eaebd24b84cfa113e7eb228 Mon Sep 17 00:00:00 2001 From: Matt Webb Date: Mon, 13 Jul 2026 14:55:43 -0700 Subject: [PATCH 3/4] Pin simplecov below 1.0 to unbreak CI's coverage cleanup step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit simplecov 1.0.0 removed SimpleCov.running, which bin/rspec-queue's custom test-queue runner depends on in cleanup_worker for per-worker coverage bookkeeping. The prior >= 0.21 constraint let CI's bundle install float onto the new major version, crashing every test job after a clean run (0 real failures) with "undefined method 'running' for module SimpleCov" — unrelated to this branch's actual change, but breaking CI for any PR right now. Co-Authored-By: Claude Sonnet 5 --- Gemfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index 12562db25..47c5c80e9 100644 --- a/Gemfile +++ b/Gemfile @@ -29,7 +29,9 @@ group :development do # rubocop:disable Metrics/BlockLength gem 'rubocop-performance' gem 'rubocop-rake', '>= 0.6' gem 'rubocop-rspec' - gem 'simplecov', '>= 0.21' + # Pinned below 1.0 — that release removed SimpleCov.running, which bin/rspec-queue's + # custom test-queue runner depends on for per-worker coverage bookkeeping. + gem 'simplecov', '~> 0.22' gem 'simplecov-cobertura' gem 'test-queue' From 068abcb938a6889165d9842ddf50ccfc8fefaba1 Mon Sep 17 00:00:00 2001 From: Matt Webb Date: Mon, 13 Jul 2026 15:00:07 -0700 Subject: [PATCH 4/4] Regenerate Appraisal gemfiles to pick up the simplecov ~> 0.22 pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bundle exec appraisal generate — each gemfiles/rails_*.gemfile is a standalone materialized copy of the main Gemfile's dependencies, not an eval_gemfile include, so the simplecov constraint had to be regenerated into each one to actually take effect in CI. Co-Authored-By: Claude Sonnet 5 --- gemfiles/rails_7.1.gemfile | 2 +- gemfiles/rails_7.2.gemfile | 2 +- gemfiles/rails_8.0.gemfile | 2 +- gemfiles/rails_8.1.gemfile | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/gemfiles/rails_7.1.gemfile b/gemfiles/rails_7.1.gemfile index a2ca98c04..e8bd55b5c 100644 --- a/gemfiles/rails_7.1.gemfile +++ b/gemfiles/rails_7.1.gemfile @@ -26,7 +26,7 @@ group :development do gem "rubocop-performance" gem "rubocop-rake", ">= 0.6" gem "rubocop-rspec" - gem "simplecov", ">= 0.21" + gem "simplecov", "~> 0.22" gem "simplecov-cobertura" gem "test-queue" gem "activerecord-jdbcsqlite3-adapter", platform: "jruby" diff --git a/gemfiles/rails_7.2.gemfile b/gemfiles/rails_7.2.gemfile index 417f4e106..1a4586315 100644 --- a/gemfiles/rails_7.2.gemfile +++ b/gemfiles/rails_7.2.gemfile @@ -26,7 +26,7 @@ group :development do gem "rubocop-performance" gem "rubocop-rake", ">= 0.6" gem "rubocop-rspec" - gem "simplecov", ">= 0.21" + gem "simplecov", "~> 0.22" gem "simplecov-cobertura" gem "test-queue" gem "activerecord-jdbcsqlite3-adapter", platform: "jruby" diff --git a/gemfiles/rails_8.0.gemfile b/gemfiles/rails_8.0.gemfile index a65a19eae..dbfc6d8bc 100644 --- a/gemfiles/rails_8.0.gemfile +++ b/gemfiles/rails_8.0.gemfile @@ -26,7 +26,7 @@ group :development do gem "rubocop-performance" gem "rubocop-rake", ">= 0.6" gem "rubocop-rspec" - gem "simplecov", ">= 0.21" + gem "simplecov", "~> 0.22" gem "simplecov-cobertura" gem "test-queue" gem "activerecord-jdbcsqlite3-adapter", platform: "jruby" diff --git a/gemfiles/rails_8.1.gemfile b/gemfiles/rails_8.1.gemfile index dfc68ea8a..5d2ec9f63 100644 --- a/gemfiles/rails_8.1.gemfile +++ b/gemfiles/rails_8.1.gemfile @@ -26,7 +26,7 @@ group :development do gem "rubocop-performance" gem "rubocop-rake", ">= 0.6" gem "rubocop-rspec" - gem "simplecov", ">= 0.21" + gem "simplecov", "~> 0.22" gem "simplecov-cobertura" gem "test-queue" gem "activerecord-jdbcsqlite3-adapter", platform: "jruby"