From fae5dd8e70db3148583ab0401b0d59cfad37da7b Mon Sep 17 00:00:00 2001 From: Matt Webb Date: Fri, 30 Jan 2026 21:50:18 -0800 Subject: [PATCH 1/2] Fix Anthropic streaming error for higher-level gem events The anthropic gem (1.12+) emits higher-level convenience events (TextEvent, ThinkingEvent, SignatureEvent, etc.) in addition to raw streaming events when using MessageStream. The :signature event was causing "Unexpected chunk type: signature" errors because it lacks a :snapshot method, causing it to fall through to the error handler. Added explicit handling for all higher-level convenience events: :text, :input_json, :citation, :thinking, :signature These are safely ignored since the actual content is already processed via the raw :content_block_delta events. Co-Authored-By: Claude Opus 4.5 --- .../providers/anthropic_provider.rb | 9 +++- .../anthropic/streaming_events_test.rb | 46 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 test/providers/anthropic/streaming_events_test.rb diff --git a/lib/active_agent/providers/anthropic_provider.rb b/lib/active_agent/providers/anthropic_provider.rb index 6f2498bf..b16ab17c 100644 --- a/lib/active_agent/providers/anthropic_provider.rb +++ b/lib/active_agent/providers/anthropic_provider.rb @@ -189,9 +189,14 @@ def process_stream_chunk(api_response_chunk) when :ping # No-Op Keep Awake when :overloaded_error - # TODO: https://docs.claude.com/en/docs/build-with-claude/streaming#error-events + # TODO: https://docs.claude.com/en/docs/build-with-claude/streaming#error-events + + # Higher-level convenience events from anthropic gem's MessageStream + when :text, :input_json, :citation, :thinking, :signature + # No-Op; Already handled via :content_block_delta + else - # No-Op: Looks like internal tracking from gem wrapper + # No-Op; Internal tracking from gem wrapper return if api_response_chunk.respond_to?(:snapshot) raise "Unexpected chunk type: #{api_response_chunk.type}" end diff --git a/test/providers/anthropic/streaming_events_test.rb b/test/providers/anthropic/streaming_events_test.rb new file mode 100644 index 00000000..6273b489 --- /dev/null +++ b/test/providers/anthropic/streaming_events_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "../../../lib/active_agent/providers/anthropic_provider" + +module Providers + module Anthropic + class StreamingEventsTest < ActiveSupport::TestCase + setup do + @provider = ActiveAgent::Providers::AnthropicProvider.new( + service: "Anthropic", + model: "claude-sonnet-4-5", + messages: [ { role: "user", content: "Hello" } ], + stream_broadcaster: ->(message, delta, event_type) { } + ) + + @provider.send(:message_stack).push({ + role: "assistant", + content: [ { type: "text", text: "" } ] + }) + end + + MockEvent = Struct.new(:type, keyword_init: true) do + def [](key) + send(key) if respond_to?(key) + end + end + + test "handles :signature event without raising" do + event = MockEvent.new(type: :signature) + + assert_nothing_raised do + @provider.send(:process_stream_chunk, event) + end + end + + test "handles :thinking event without raising" do + event = MockEvent.new(type: :thinking) + + assert_nothing_raised do + @provider.send(:process_stream_chunk, event) + end + end + end + end +end From adc2124162cb294e46cff5f25b371871b57e3c0f Mon Sep 17 00:00:00 2001 From: Matt Webb Date: Fri, 30 Jan 2026 21:50:44 -0800 Subject: [PATCH 2/2] Fix empty tool input JSON parsing for parameterless tools For tools with no parameters, the streaming API may send empty partial_json, resulting in an empty string input that would fail JSON.parse with a syntax error. Changes: - Use json_buf.present? instead of just json_buf to skip empty strings - For string inputs, check .present? before parsing and default to {} Co-Authored-By: Claude Opus 4.5 --- .../providers/anthropic_provider.rb | 9 +++- .../anthropic/empty_tool_input_test.rb | 47 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 test/providers/anthropic/empty_tool_input_test.rb diff --git a/lib/active_agent/providers/anthropic_provider.rb b/lib/active_agent/providers/anthropic_provider.rb index b16ab17c..bc66f633 100644 --- a/lib/active_agent/providers/anthropic_provider.rb +++ b/lib/active_agent/providers/anthropic_provider.rb @@ -301,11 +301,16 @@ def process_prompt_finished_extract_messages(api_response) def process_prompt_finished_extract_function_calls message_stack.pluck(:content).flatten.select { _1 in { type: "tool_use" } }.map do |api_function_call| json_buf = api_function_call.delete(:json_buf) - api_function_call[:input] = JSON.parse(json_buf, symbolize_names: true) if json_buf + api_function_call[:input] = JSON.parse(json_buf, symbolize_names: true) if json_buf.present? # Handle case where :input is still a JSON string (gem >= 1.14.0) + # For tools with no parameters, input may be an empty string if api_function_call[:input].is_a?(String) - api_function_call[:input] = JSON.parse(api_function_call[:input], symbolize_names: true) + if api_function_call[:input].present? + api_function_call[:input] = JSON.parse(api_function_call[:input], symbolize_names: true) + else + api_function_call[:input] = {} + end end api_function_call diff --git a/test/providers/anthropic/empty_tool_input_test.rb b/test/providers/anthropic/empty_tool_input_test.rb new file mode 100644 index 00000000..521d78d6 --- /dev/null +++ b/test/providers/anthropic/empty_tool_input_test.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "../../../lib/active_agent/providers/anthropic_provider" + +module Providers + module Anthropic + class EmptyToolInputTest < ActiveSupport::TestCase + setup do + @provider = ActiveAgent::Providers::AnthropicProvider.new( + service: "Anthropic", + model: "claude-sonnet-4-5", + messages: [ { role: "user", content: "Hello" } ], + stream_broadcaster: ->(message, delta, event_type) { } + ) + end + + test "handles empty string input for tools with no parameters" do + @provider.send(:message_stack).push({ + role: "assistant", + content: [ + { type: "tool_use", id: "tool_123", name: "no_param_tool", input: "" } + ] + }) + + result = @provider.send(:process_prompt_finished_extract_function_calls) + + assert_equal 1, result.size + assert_equal({}, result.first[:input]) + end + + test "handles empty json_buf gracefully" do + @provider.send(:message_stack).push({ + role: "assistant", + content: [ + { type: "tool_use", id: "tool_123", name: "no_param_tool", json_buf: "", input: nil } + ] + }) + + result = @provider.send(:process_prompt_finished_extract_function_calls) + + assert_equal 1, result.size + assert_nil result.first[:input] + end + end + end +end