Skip to content

Replace case statement in Claude Message.from_hash with dynamic class resolution #911

@juniper-shopify

Description

@juniper-shopify

Summary

Message.from_hash in lib/roast/cogs/agent/providers/claude/message.rb uses an 8-branch case statement to map a :type symbol to a Messages::*Message class. Since every branch follows the exact same naming convention (:"#{type}_message".to_s.camelize), we can replace the static dispatch with dynamic constant resolution.

Current code (lines 23–44)

def from_hash(hash)
  type = hash.delete(:type)&.to_sym
  case type
  when :assistant
    Messages::AssistantMessage.new(type:, hash:)
  when :result
    Messages::ResultMessage.new(type:, hash:)
  when :system
    Messages::SystemMessage.new(type:, hash:)
  when :text
    Messages::TextMessage.new(type:, hash:)
  when :thinking
    Messages::ThinkingMessage.new(type:, hash:)
  when :tool_result
    Messages::ToolResultMessage.new(type:, hash:)
  when :tool_use
    Messages::ToolUseMessage.new(type:, hash:)
  when :user
    Messages::UserMessage.new(type:, hash:)
  else
    Messages::UnknownMessage.new(type:, hash:)
  end
end

Proposed replacement

def from_hash(hash)
  type = hash.delete(:type)&.to_sym
  class_name = :"#{type}_message".to_s.camelize  # e.g. :tool_use → "ToolUseMessage"
  klass = if Messages.const_defined?(class_name, false)
    Messages.const_get(class_name, false)
  else
    Messages::UnknownMessage
  end
  klass.new(type:, hash:)
end

The false argument to const_defined?/const_get restricts the lookup to Messages itself (no ancestor walk), which is the safest approach.

Why

  • Open/Closed Principle: Adding a new message type (e.g. :image) only requires creating Messages::ImageMessage — no need to touch the factory method.
  • Precedent: This mirrors how Workflow#load_cog_or_provider already uses camelize/constantize for dynamic class resolution (see lib/roast/workflow.rb:116-119).
  • Less surface area: Eliminates 16 lines of mechanical case branches that are easy to forget when adding new types.

Edge cases to verify

  • type is nil"_message".camelize"Message"Messages.const_defined?("Message", false) returns false, so we correctly fall through to UnknownMessage.
  • Unknown type like :foo"FooMessage" — not defined in Messages, falls through to UnknownMessage. ✓
  • Existing types all resolve correctly (confirmed: the naming convention holds for all 8 current types). ✓

Acceptance criteria

  • Replace the case statement with dynamic resolution
  • All existing tests in test/roast/cogs/agent/providers/claude/messages/ continue to pass
  • Add a test confirming that an unknown type falls back to UnknownMessage

Metadata

Metadata

Labels

enhancementNew feature or request

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions