Skip to content

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

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

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions