Skip to content

Cast message to MessageType before creating StreamChunk in stream_broadcaster#311

Open
TheRealNeil wants to merge 1 commit intoactiveagents:mainfrom
qlarity:fix-chunk-message-content
Open

Cast message to MessageType before creating StreamChunk in stream_broadcaster#311
TheRealNeil wants to merge 1 commit intoactiveagents:mainfrom
qlarity:fix-chunk-message-content

Conversation

@TheRealNeil
Copy link
Contributor

@TheRealNeil TheRealNeil commented Feb 8, 2026

Cast message to MessageType before creating StreamChunk in stream_broadcaster

Fixes #307

Summary

  • Cast raw Hash messages to MessageType in stream_broadcaster so that chunk.message has the same typed interface as response.message

Root cause

During streaming, StreamChunk#message wraps the raw Hash from the provider's message stack — no type casting is applied. After generation completes, PromptResponse#messages are cast through Types::MessagesType into Common::Messages::* objects with method accessors (.content, .role).

This means chunk.message.content raises NoMethodError during streaming, while response.message.content works fine after generation. Users have to use chunk.message[:content] during streaming but response.message.content afterwards — an inconsistent API.

Fix

In stream_broadcaster, cast the message through Providers::Common::Messages::Types::MessageType when it's a Hash before passing it to StreamChunk.new. This gives chunk.message the same typed interface as response.message, so .content and .role work consistently in both streaming and non-streaming contexts.

Test plan

  • Verify chunk.message.content works in on_stream callbacks
  • Verify chunk.message.role works in on_stream callbacks
  • Confirm non-Hash messages are passed through unchanged
  • Confirm post-generation response.message.content is unaffected

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes an inconsistency in the streaming API by ensuring StreamChunk#message exposes the same typed interface as post-generation responses, so callers can use method accessors like .content/.role during streaming.

Changes:

  • Cast streamed message values that are Hashes through Providers::Common::Messages::Types::MessageType before constructing StreamChunk.
  • Preserve pass-through behavior for non-Hash messages.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +271 to +272
cast_message = message.is_a?(Hash) ? Providers::Common::Messages::Types::MessageType.new.cast(message) : message
self.stream_chunk = StreamChunk.new(cast_message, delta)
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are streaming callback tests, but none currently assert the new behavior that Hash messages are cast to a typed message with .content/.role during streaming. Add a test that calls stream_broadcaster with a Hash message and asserts chunk.message is cast (and that non-Hash messages still pass through unchanged) to prevent regressions.

Copilot uses AI. Check for mistakes.
def stream_broadcaster
proc do |message, delta, type|
self.stream_chunk = StreamChunk.new(message, delta)
cast_message = message.is_a?(Hash) ? Providers::Common::Messages::Types::MessageType.new.cast(message) : message
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Providers::Common::Messages::Types::MessageType.new is instantiated for every streamed chunk. Since stream_broadcaster may be called many times per request, this creates avoidable allocations; consider memoizing the MessageType instance (e.g., build it once per stream_broadcaster call and close over it, or store it in an ivar) and reuse it for each cast.

Copilot uses AI. Check for mistakes.
Comment on lines +271 to +272
cast_message = message.is_a?(Hash) ? Providers::Common::Messages::Types::MessageType.new.cast(message) : message
self.stream_chunk = StreamChunk.new(cast_message, delta)
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Casting Hash messages through Providers::Common::Messages::Types::MessageType drops provider-specific keys (e.g., OpenAI Chat streaming messages can include :tool_calls, and the common MessageType slices assistant hashes down to :role, :content, :name). This is an API change for streaming callbacks that currently can inspect the full provider message hash; consider preserving the original Hash alongside the typed wrapper (or otherwise exposing raw fields) so streaming users don’t lose access to metadata/tool-call details.

Suggested change
cast_message = message.is_a?(Hash) ? Providers::Common::Messages::Types::MessageType.new.cast(message) : message
self.stream_chunk = StreamChunk.new(cast_message, delta)
self.stream_chunk = StreamChunk.new(message, delta)

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

StreamChunk.message is a raw Hash while PromptResponse.message is a typed object

1 participant