From 118cd2a6c8db833ea98c792c780d0267fb4504f3 Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Fri, 5 Dec 2025 16:33:43 -0500 Subject: [PATCH] Improve inspect output for IRB, Rails console, ERB - Add AI::Message class (Hash subclass) with custom display - Add AI::Items class (SimpleDelegator) wrapping API response - Rename items to get_items to indicate API call - Enable reasoning summaries when reasoning_effort is set - Add to_html methods with dark terminal-style background - TTY-aware colorization (disabled when piped/redirected) - Add examples/16_get_items.rb demonstrating item inspection --- .gitignore | 4 - CHANGELOG.md | 22 ++ CONTRIBUTING.md | 37 ++ Gemfile.lock | 2 +- README.md | 355 +++++++++++------- ai-chat.gemspec | 6 +- .../13_conversation_features_comprehensive.rb | 22 +- examples/15_proxy.rb | 4 +- examples/16_get_items.rb | 74 ++++ lib/ai-chat.rb | 11 + lib/ai/amazing_print.rb | 51 ++- lib/ai/chat.rb | 126 +++---- lib/ai/items.rb | 54 +++ lib/ai/message.rb | 23 ++ spec/integration/ai_chat_integration_spec.rb | 2 +- spec/unit/chat_spec.rb | 191 ++++++++++ spec/unit/items_spec.rb | 68 ++++ spec/unit/message_spec.rb | 69 ++++ 18 files changed, 867 insertions(+), 254 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 examples/16_get_items.rb create mode 100644 lib/ai/items.rb create mode 100644 lib/ai/message.rb create mode 100644 spec/unit/chat_spec.rb create mode 100644 spec/unit/items_spec.rb create mode 100644 spec/unit/message_spec.rb diff --git a/.gitignore b/.gitignore index 6e62f7f..49f9d5b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,10 +15,6 @@ tmp/ .bundle/ vendor/bundle/ -# Development dependencies cloned locally -amazing_print/ -openai-ruby/ - # Temp demo scripts demo2/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ec45de..e2116a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] - 2025-12-05 + +### Breaking Changes + +- **Renamed `items` to `get_items`**: The method now clearly indicates it makes an API call. Returns an `AI::Items` wrapper that delegates to the underlying response while providing nice display formatting. + +### Added + +- **Reasoning summaries**: When `reasoning_effort` is set, the API now returns chain-of-thought summaries in `get_items`. These show the model's reasoning process (e.g., "Planning Ruby version search", "Confirming image tool usage"). + +- **Improved console display**: `AI::Chat`, `AI::Message`, and `AI::Items` now display nicely in IRB and Rails console with colorized, formatted output via AmazingPrint. + +- **HTML output for ERB templates**: All display objects have a `to_html` method for rendering in views. Includes dark terminal-style background for readability. + +- **`AI::Message` class**: Messages are now `AI::Message` instances (a Hash subclass) with custom display methods. + +- **`AI::Items` class**: Wraps the conversation items API response with nice display methods while delegating all other methods (like `.data`, `.has_more`, etc.) to the underlying response. + +- **TTY-aware display**: Console output automatically detects TTY and disables colors when output is piped or redirected. + +- **New example**: `examples/16_get_items.rb` demonstrates inspecting conversation items including reasoning, web searches, and image generation. + ## [0.4.0] - 2025-11-25 ### Breaking Changes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5488cae --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,37 @@ +# Contributing + +Thanks for contributing to `ai-chat`! This gem is intentionally beginner-friendly, so please keep public-facing docs and examples simple and progressively introduced. + +## Development setup + +- Ruby: `~> 3.2` (see `ai-chat.gemspec`) +- Install dependencies: `bundle install` + +## Running the test suite + +- Run unit specs: `bundle exec rspec` +- Integration specs make real API calls and require `OPENAI_API_KEY` (they are skipped automatically if it’s not set). +- Disable coverage locally (optional): `NO_COVERAGE=1 bundle exec rspec` + +## Code style / quality + +- Format/lint: `bundle exec standardrb --fix` +- Smell checks (optional): `bundle exec reek` + +## Running examples (real API calls) + +The `examples/` directory is both documentation and a practical validation suite. + +1. Set `OPENAI_API_KEY` (or create a `.env` file in the repo root): + ```bash + OPENAI_API_KEY=your_openai_api_key_here + ``` +2. Run a quick overview: `bundle exec ruby examples/01_quick.rb` +3. Run everything: `bundle exec ruby examples/all.rb` + +## Documentation expectations + +- If you change the public API, update `README.md`. +- If you change behavior in a user-visible way, update `CHANGELOG.md`. +- Keep examples easy to paste into IRB; use short variable names (`a`, `b`, `c`, …) to match existing style in `examples/`. + diff --git a/Gemfile.lock b/Gemfile.lock index 1c56340..081dbff 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - ai-chat (0.4.0) + ai-chat (0.5.0) amazing_print (~> 1.8) base64 (~> 0.1, > 0.1.1) json (~> 2.0) diff --git a/README.md b/README.md index 52611bf..2cd69ac 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ The `examples/` directory contains focused examples for specific features: - `13_conversation_features_comprehensive.rb` - Conversation features (auto-creation, continuity, inspection) - `14_schema_generation.rb` - Generate JSON schemas from natural language - `15_proxy.rb` - Proxy support for student accounts +- `16_get_items.rb` - Inspecting conversation items (reasoning, web searches, image generation) Each example is self-contained and can be run individually: ```bash @@ -83,22 +84,44 @@ a = AI::Chat.new a.add("If the Ruby community had an official motto, what might it be?") # See the convo so far - it's just an array of hashes! -pp a.messages -# => [{:role=>"user", :content=>"If the Ruby community had an official motto, what might it be?"}] +a.messages +# => [ +# { +# :role => "user", +# :content => "If the Ruby community had an official motto, what might it be?" +# } +# ] # Generate the next message using AI -a.generate! # => { :role => "assistant", :content => "Matz is nice and so we are nice" (or similar) } +a.generate! +# => { +# :role => "assistant", +# :content => "Matz is nice and so we are nice", +# :response => { ... } +# } # Your array now includes the assistant's response -pp a.messages +a.messages # => [ -# {:role=>"user", :content=>"If the Ruby community had an official motto, what might it be?"}, -# {:role=>"assistant", :content=>"Matz is nice and so we are nice", :response => { id=resp_abc... model=gpt-5.1 tokens=12 } } +# { +# :role => "user", +# :content => "If the Ruby community had an official motto, what might it be?" +# }, +# { +# :role => "assistant", +# :content => "Matz is nice and so we are nice", +# :response => { id: "resp_abc...", model: "gpt-5.1", ... } +# } # ] # Continue the conversation a.add("What about Rails?") -a.generate! # => { :role => "assistant", :content => "Convention over configuration."} +a.generate! +# => { +# :role => "assistant", +# :content => "Convention over configuration.", +# :response => { ... } +# } ``` ## Understanding the Data Structure @@ -111,9 +134,19 @@ That's it! You're building something like this: ```ruby [ - {:role => "system", :content => "You are a helpful assistant"}, - {:role => "user", :content => "Hello!"}, - {:role => "assistant", :content => "Hi there! How can I help you today?", :response => { id=resp_abc... model=gpt-5.1 tokens=12 } } + { + :role => "system", + :content => "You are a helpful assistant" + }, + { + :role => "user", + :content => "Hello!" + }, + { + :role => "assistant", + :content => "Hi there! How can I help you today?", + :response => { id: "resp_abc...", model: "gpt-5.1", ... } + } ] ``` @@ -133,14 +166,25 @@ b.add("You are a helpful assistant that talks like Shakespeare.", role: "system" b.add("If the Ruby community had an official motto, what might it be?") # Check what we've built -pp b.messages +b.messages # => [ -# {:role=>"system", :content=>"You are a helpful assistant that talks like Shakespeare."}, -# {:role=>"user", :content=>"If the Ruby community had an official motto, what might it be?"} +# { +# :role => "system", +# :content => "You are a helpful assistant that talks like Shakespeare." +# }, +# { +# :role => "user", +# :content => "If the Ruby community had an official motto, what might it be?" +# } # ] # Generate a response -b.generate! # => { :role => "assistant", :content => "Methinks 'tis 'Ruby doth bring joy to all who craft with care'" } +b.generate! +# => { +# :role => "assistant", +# :content => "Methinks 'tis 'Ruby doth bring joy to all who craft with care'", +# :response => { ... } +# } ``` ### Convenience Methods @@ -219,11 +263,20 @@ h.user("How do I boil an egg?") h.generate! # See the whole conversation -pp h.messages +h.messages # => [ -# {:role=>"system", :content=>"You are a helpful cooking assistant"}, -# {:role=>"user", :content=>"How do I boil an egg?"}, -# {:role=>"assistant", :content=>"Here's how to boil an egg..."} +# { +# :role => "system", +# :content => "You are a helpful cooking assistant" +# }, +# { +# :role => "user", +# :content => "How do I boil an egg?" +# }, +# { +# :role => "assistant", +# :content => "Here's how to boil an egg..." +# } # ] # Get just the last response @@ -460,7 +513,11 @@ You can enable OpenAI's image generation tool: a = AI::Chat.new a.image_generation = true a.user("Draw a picture of a kitten") -a.generate! # => { :content => "Here is your picture of a kitten:", ... } +a.generate! +# => { +# :content => "Here is your picture of a kitten:", +# :response => { ... } +# } ``` By default, images are saved to `./images`. You can configure a different location: @@ -470,7 +527,11 @@ a = AI::Chat.new a.image_generation = true a.image_folder = "./my_images" a.user("Draw a picture of a kitten") -a.generate! # => { :content => "Here is your picture of a kitten:", ... } +a.generate! +# => { +# :content => "Here is your picture of a kitten:", +# :response => { ... } +# } ``` Images are saved in timestamped subfolders using ISO 8601 basic format. For example: @@ -482,11 +543,19 @@ The folder structure ensures images are organized chronologically and by respons The messages array will now look like this: ```ruby -pp a.messages +a.messages # => [ -# {:role=>"user", :content=>"Draw a picture of a kitten"}, -# {:role=>"assistant", :content=>"Here is your picture of a kitten:", :images => ["./images/20250804T11303912_resp_abc123/001.png"], :response => #} -# ] +# { +# :role => "user", +# :content => "Draw a picture of a kitten" +# }, +# { +# :role => "assistant", +# :content => "Here is your picture of a kitten:", +# :images => [ "./images/20250804T11303912_resp_abc123/001.png" ], +# :response => { ... } +# } +# ] ``` You can access the image filenames in several ways: @@ -497,7 +566,7 @@ images = a.messages.last[:images] # => ["./images/20250804T11303912_resp_abc123/001.png"] # From the response object -images = a.messages.last[:response].images +images = a.messages.last.dig(:response, :images) # => ["./images/20250804T11303912_resp_abc123/001.png"] ``` @@ -508,9 +577,11 @@ a = AI::Chat.new a.image_generation = true a.image_folder = "./images" a.user("Draw a picture of a kitten") -a.generate! # => { :content => "Here is a picture of a kitten:", ... } +a.generate! +# => { :content => "Here is a picture of a kitten:", ... } a.user("Make it even cuter") -a.generate! # => { :content => "Here is the kitten, but even cuter:", ... } +a.generate! +# => { :content => "Here is the kitten, but even cuter:", ... } ``` ## Code Interpreter @@ -519,7 +590,23 @@ a.generate! # => { :content => "Here is the kitten, but even cuter:", ... } y = AI::Chat.new y.code_interpreter = true y.user("Plot y = 2x*3 when x is -5 to 5.") -y.generate! # => {:content => "Here is the graph.", ... } +y.generate! +# => { :content => "Here is the graph.", ... } +``` + +## Background mode + +If you want to start a response and poll for it later, set `background = true` before calling `generate!`: + +```ruby +chat = AI::Chat.new +chat.background = true +chat.user("Write a short description about a sci-fi novel about a rat in space.") +chat.generate! + +# Poll until it completes (this updates the existing assistant message) +message = chat.get_response(wait: true, timeout: 600) +puts message[:content] ``` ## Proxying Through prepend.me @@ -586,7 +673,7 @@ By default, `reasoning_effort` is `nil`, which means no reasoning parameter is s ## Advanced: Response Details -When you call `generate!` or `generate!`, the gem stores additional information about the API response: +When you call `generate!` (or later call `get_response` in background mode), the gem stores additional information about the API response: ```ruby t = AI::Chat.new @@ -594,18 +681,18 @@ t.user("Hello!") t.generate! # Each assistant message includes a response object -pp t.messages.last +t.messages.last # => { -# :role => "assistant", -# :content => "Hello! How can I help you today?", -# :response => { id=resp_abc... model=gpt-5.1 tokens=12 } +# :role => "assistant", +# :content => "Hello! How can I help you today?", +# :response => { id: "resp_abc...", model: "gpt-5.1", ... } # } # Access detailed information response = t.last[:response] response[:id] # => "resp_abc123..." response[:model] # => "gpt-5.1" -response[:usage] # => {:prompt_tokens=>5, :completion_tokens=>7, :total_tokens=>12} +response[:usage] # => {:input_tokens=>5, :output_tokens=>7, :total_tokens=>12} ``` This information is useful for: @@ -631,7 +718,18 @@ chat.generate! puts chat.last_response_id # => "resp_xyz789..." (a new ID) ``` -This is particularly useful for managing background tasks. When you make a request in background mode, you can immediately get the `last_response_id` to track, retrieve, or cancel that specific job later from a different process. +This is particularly useful for background mode workflows. If you want to retrieve or cancel a background response from a different process, use `OpenAI::Client` directly: + +```ruby +require "openai" + +client = OpenAI::Client.new(api_key: ENV.fetch("OPENAI_API_KEY")) + +response_id = "resp_abc123..." # e.g., load from your database +response = client.responses.retrieve(response_id) + +client.responses.cancel(response_id) unless response.status.to_s == "completed" +``` ### Automatic Conversation Management @@ -663,64 +761,117 @@ chat.generate! # Uses the loaded conversation ## Inspecting Conversation Details -The gem provides two methods to inspect what happened during a conversation: - -### `items` - Programmatic Access - -Returns the raw conversation items for programmatic use (displaying in views, filtering, etc.): +The `get_items` method fetches all conversation items (messages, tool calls, reasoning, etc.) from the API for both programmatic use and debugging: ```ruby chat = AI::Chat.new +chat.reasoning_effort = "high" # Enable reasoning summaries chat.web_search = true chat.user("Search for Ruby tutorials") chat.generate! # Get all conversation items (chronological order by default) -page = chat.items +chat.get_items -# Access item data -page.data.each do |item| +# Output in IRB/Rails console: +# ┌────────────────────────────────────────────────────────────────────────────┐ +# │ Conversation: conv_6903c1eea6cc819695af3a1b1ebf9b390c3db5e8ec021c9a │ +# │ Items: 8 │ +# └────────────────────────────────────────────────────────────────────────────┘ +# +# [detailed colorized output of all items including web searches, +# reasoning summaries, tool calls, messages, etc.] + +# Iterate over items programmatically +chat.get_items.data.each do |item| case item.type when :message puts "#{item.role}: #{item.content.first.text}" when :web_search_call - puts "Web search: #{item.action.query}" - puts "Results: #{item.results.length}" + puts "Web search: #{item.action.query}" if item.action.respond_to?(:query) && item.action.query when :reasoning - puts "Reasoning: #{item.summary.first.text}" + # Reasoning summaries show a high-level view of the model's reasoning + if item.summary&.first + puts "Reasoning: #{item.summary.first.text}" + end + when :image_generation_call + puts "Image generated" if item.result end end # For long conversations, you can request reverse chronological order # (useful for pagination to get most recent items first) -recent_items = chat.items(order: :desc) +recent_items = chat.get_items(order: :desc) ``` -### `verbose` - Terminal Output - -Pretty-prints the entire conversation with all details for debugging and learning: - -```ruby -chat.verbose - -# Output: -# ┌────────────────────────────────────────────────────────────────────────────┐ -# │ Conversation: conv_6903c1eea6cc819695af3a1b1ebf9b390c3db5e8ec021c9a │ -# │ Items: 3 │ -# └────────────────────────────────────────────────────────────────────────────┘ -# -# [detailed colorized output of all items including web searches, -# reasoning, tool calls, messages, etc.] -``` +When `reasoning_effort` is set, the API returns reasoning summaries (e.g., "Planning Ruby version search", "Confirming image tool usage"). Note that not all reasoning items have summaries - some intermediate steps may be empty. This is useful for: - **Learning** how the model uses tools (web search, code interpreter, etc.) - **Debugging** why the model made certain decisions - **Understanding** the full context beyond just the final response +- **Transparency** into the model's reasoning process + +### HTML Output for ERB Templates + +All display objects have a `to_html` method for rendering in ERB templates: + +```erb +<%# Display a chat object %> +<%= @chat.to_html %> + +<%# Display individual messages %> +<% @chat.messages.each do |msg| %> + <%= msg.to_html %> +<% end %> + +<%# Display conversation items (quick debug view) %> +<%= @chat.get_items.to_html %> +``` + +The HTML output includes a dark background to match the terminal aesthetic. + +You can also loop over `get_items.data` to build custom displays showing reasoning steps, tool calls, etc.: + +```erb +<% @chat.get_items.data.each do |item| %> + <% case item.type.to_s %> + <% when "message" %> +
+ <%= item.role.capitalize %>: + <% if item.content&.first %> + <% content = item.content.first %> + <% if content.type.to_s == "input_text" %> + <%= content.text %> + <% elsif content.type.to_s == "output_text" %> + <%= content.text %> + <% end %> + <% end %> +
+ <% when "reasoning" %> + <% if item.summary&.first %> +
+ Reasoning + <%= item.summary.first.text %> +
+ <% end %> + <% when "web_search_call" %> + <% if item.action.respond_to?(:query) && item.action.query %> + + <% end %> + <% when "image_generation_call" %> +
+ Image generated +
+ <% end %> +<% end %> +``` ## Setting messages directly -You can use `.messages=()` to assign an `Array` of `Hashes`. Each `Hash` must have keys `:role` and `:content`, and optionally `:image` or `:images`: +You can use `.messages=()` to assign an `Array` of `Hashes` (text-only). Each `Hash` must have keys `:role` and `:content`: ```ruby # Using the planet example with array of hashes @@ -743,80 +894,8 @@ response = p.generate! puts response ``` -You can still include images: - -```ruby -# Create a new chat instance -q = AI::Chat.new - -# With images -q.messages = [ - { role: "system", content: "You are a helpful assistant." }, - { role: "user", content: "What's in this image?", image: "path/to/image.jpg" }, -] - -# With multiple images -q.messages = [ - { role: "system", content: "You are a helpful assistant." }, - { role: "user", content: "Compare these images", images: ["image1.jpg", "image2.jpg"] } -] -``` - -## Other Features Being Considered - -- **Streaming responses**: Real-time streaming as the AI generates its response -- **Cost tracking**: Automatic calculation and tracking of API costs -- **Token usage helpers**: Convenience methods like `total_tokens` to sum usage across all responses in a conversation - -## TODO: Missing Test Coverage - -The following gem-specific logic would benefit from additional RSpec test coverage: - -1. **Schema format normalization** - The `wrap_schema_if_needed` method detects and wraps 3 different input formats (raw, named, already-wrapped). This complex conditional logic could silently regress. - -2. **Multimodal content array building** - The `add` method builds nested structures when images/files are provided, handling `image`/`images` and `file`/`files` parameters with specific ordering (text → images → files). - -3. **File classification and processing** - `classify_obj` and `process_file_input` distinguish URLs vs file paths vs file-like objects, with MIME type detection determining encoding behavior. - -4. **Message preparation after response** - `prepare_messages_for_api` has slicing logic that only sends messages after the last response, preventing re-sending entire conversation history. - -These are all gem-specific transformations (not just OpenAI pass-through) that could regress without proper test coverage. - -## TODO: Code Quality - -Address Reek warnings (`bundle exec reek`). Currently 29 warnings for code smells like: - -- `TooManyStatements` in several methods -- `DuplicateMethodCall` in `extract_and_save_files`, `verbose`, etc. -- `RepeatedConditional` for `proxy` checks -- `FeatureEnvy` in `parse_response` and `wait_for_response` - -These don't affect functionality but indicate areas for refactoring. - -Then, add `quality` back as a CI check. - -## Testing with Real API Calls - -While this gem includes specs, they use mocked API responses. To test with real API calls: - -1. Create a `.env` file at the project root with your API credentials: - ``` - # Your OpenAI API key - OPENAI_API_KEY=your_openai_api_key_here - ``` -2. Install dependencies: `bundle install` -3. Run the examples: `bundle exec ruby examples/all.rb` - -This test program runs through all the major features of the gem, making real API calls to OpenAI. +For images/files, prefer using `chat.user(..., image:/images:/file:/files:)` so the gem can build the correct multimodal structure. ## Contributing -When contributing to this project: - -1. **Code Style**: This project uses StandardRB for linting. Run `bundle exec standardrb --fix` before committing to automatically fix style issues. - -2. **Testing**: Ensure all specs pass with `bundle exec rspec`. - -3. **Examples**: If adding a feature, consider adding an example in the `examples/` directory. - -4. **Documentation**: Update the README if your changes affect the public API. +See [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/ai-chat.gemspec b/ai-chat.gemspec index 6735f0f..b87da67 100644 --- a/ai-chat.gemspec +++ b/ai-chat.gemspec @@ -2,9 +2,9 @@ Gem::Specification.new do |spec| spec.name = "ai-chat" - spec.version = "0.4.0" - spec.authors = ["Raghu Betina"] - spec.email = ["raghu@firstdraft.com"] + spec.version = "0.5.0" + spec.authors = ["Raghu Betina", "Jelani Woods"] + spec.email = ["raghu@firstdraft.com", "jelani@firstdraft.com"] spec.homepage = "https://github.com/firstdraft/ai-chat" spec.summary = "A beginner-friendly Ruby interface for OpenAI's API" spec.license = "MIT" diff --git a/examples/13_conversation_features_comprehensive.rb b/examples/13_conversation_features_comprehensive.rb index a73e60c..4e2b351 100644 --- a/examples/13_conversation_features_comprehensive.rb +++ b/examples/13_conversation_features_comprehensive.rb @@ -3,7 +3,7 @@ # This example demonstrates all conversation-related features: # - Automatic conversation creation # - Conversation continuity across multiple turns -# - Inspecting conversation items (programmatically and verbose) +# - Inspecting conversation items with get_items # - Loading existing conversations require_relative "../lib/ai-chat" @@ -47,10 +47,10 @@ # Feature 3: Programmatic access to items puts "3. Accessing Conversation Items (Programmatically)" puts "-" * 60 -puts "Use chat.items to get conversation data for processing or display." +puts "Use chat.get_items to get conversation data for processing or display." puts -page = chat.items +page = chat.get_items puts "Total items: #{page.data.length}" puts "Item breakdown:" page.data.each_with_index do |item, i| @@ -79,7 +79,9 @@ if web_searches.any? search = web_searches.first puts "Web search found:" - puts " Query: #{search.action.query}" + if search.action.respond_to?(:query) && search.action.query + puts " Query: #{search.action.query}" + end puts " Status: #{search.status}" if search.respond_to?(:results) && search.results puts " Results: #{search.results.length} found" @@ -98,8 +100,8 @@ puts "Items default to chronological order (:asc), but you can request :desc." puts -asc_items = chat.items -desc_items = chat.items(order: :desc) +asc_items = chat.get_items +desc_items = chat.get_items(order: :desc) puts "First item in chronological order:" first = asc_items.data.first @@ -111,12 +113,12 @@ puts "\n(Reverse order is useful for pagination in long conversations)" puts -# Feature 6: Verbose inspection -puts "6. Verbose Inspection (Terminal Output)" +# Feature 6: Formatted inspection +puts "6. Formatted Items Display (Terminal Output)" puts "-" * 60 -puts "Use chat.verbose for a detailed, colorized view of all conversation items." +puts "get_items returns an AI::Items object with nice inspect output." puts -chat.verbose +puts chat.get_items puts # Feature 7: Loading existing conversation diff --git a/examples/15_proxy.rb b/examples/15_proxy.rb index d4f0ae1..cc315f6 100644 --- a/examples/15_proxy.rb +++ b/examples/15_proxy.rb @@ -231,10 +231,10 @@ # Feature 3: Programmatic access to items puts "c. Accessing Conversation Items (Programmatically)" puts "-" * 60 -puts "Use chat.items to get conversation data for processing or display." +puts "Use chat.get_items to get conversation data for processing or display." puts -page = chat.items +page = chat.get_items puts "Total items: #{page.data.length}" puts "Item breakdown:" diff --git a/examples/16_get_items.rb b/examples/16_get_items.rb new file mode 100644 index 0000000..386fcf4 --- /dev/null +++ b/examples/16_get_items.rb @@ -0,0 +1,74 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# This example demonstrates how to use get_items to inspect +# all conversation items including reasoning, web searches, and tool calls. + +require_relative "../lib/ai-chat" +require "dotenv" +Dotenv.load(File.expand_path("../.env", __dir__)) +require "amazing_print" + +chat = AI::Chat.new +chat.reasoning_effort = "high" +chat.web_search = true +chat.image_generation = true + +chat.user("Search for the current stable Ruby version, then generate an image of the Ruby logo with the version number prominently displayed.") + +puts "Generating response with reasoning, web search, and image generation..." +puts +response = chat.generate! + +puts "=== Response ===" +puts response[:content] +puts + +# Fetch all conversation items from the API +items = chat.get_items + +puts "=== Conversation Items ===" +puts "Total items: #{items.data.length}" +puts + +# Iterate through items and display based on type +items.data.each_with_index do |item, index| + puts "--- Item #{index + 1}: #{item.type} ---" + + case item.type + when :message + puts "Role: #{item.role}" + if item.content&.first + content = item.content.first + case content.type + when :input_text + puts "Input: #{content.text}" + when :output_text + text = content.text.to_s + puts "Output: #{text[0..200]}#{"..." if text.length > 200}" + end + end + + when :reasoning + if item.summary&.first + text = item.summary.first.text.to_s + puts "Summary: #{text[0..200]}#{"..." if text.length > 200}" + else + puts "(Reasoning without summary)" + end + + when :web_search_call + puts "Query: #{item.action.query}" if item.action.respond_to?(:query) && item.action.query + puts "Status: #{item.status}" + + when :image_generation_call + puts "Status: #{item.status}" + puts "Result: Image generated" if item.result + end + + puts +end + +# Display the full items object (uses custom inspect) +puts "=== Full Items Display (IRB-style) ===" +puts items.inspect diff --git a/lib/ai-chat.rb b/lib/ai-chat.rb index 6eaaa4e..afe0fa8 100644 --- a/lib/ai-chat.rb +++ b/lib/ai-chat.rb @@ -1,3 +1,14 @@ +module AI + HTML_PRE_STYLE = "background-color: #1e1e1e; color: #d4d4d4; padding: 1em; white-space: pre-wrap; word-wrap: break-word;" + + def self.wrap_html(html) + html = html.gsub("
", "
")
+    html.respond_to?(:html_safe) ? html.html_safe : html
+  end
+end
+
+require_relative "ai/message"
+require_relative "ai/items"
 require_relative "ai/chat"
 
 # Load amazing_print extension if amazing_print is available
diff --git a/lib/ai/amazing_print.rb b/lib/ai/amazing_print.rb
index a584fd1..c243104 100644
--- a/lib/ai/amazing_print.rb
+++ b/lib/ai/amazing_print.rb
@@ -1,4 +1,22 @@
 require "amazing_print"
+
+# Fix AmazingPrint's colorless method to strip HTML tags in addition to ANSI codes.
+# Without this, alignment is broken when html: true because colorless_size
+# doesn't account for  tag lengths.
+# TODO: Remove if https://github.com/amazing-print/amazing_print/pull/146 is merged.
+module AmazingPrint
+  module Formatters
+    class BaseFormatter
+      alias_method :original_colorless, :colorless
+
+      def colorless(string)
+        result = original_colorless(string)
+        result.gsub(/]*>|<\/kbd>/, "")
+      end
+    end
+  end
+end
+
 # :reek:IrresponsibleModule
 module AmazingPrint
   module AI
@@ -27,33 +45,11 @@ def awesome_ai_object(object)
       end
     end
 
-    # :reek:DuplicateMethodCall
     # :reek:FeatureEnvy
-    # :reek:NilCheck
-    # :reek:TooManyStatements
     def format_ai_chat(chat)
-      vars = []
-
-      # Format messages with truncation
-      if chat.instance_variable_defined?(:@messages)
-        messages = chat.instance_variable_get(:@messages).map do |msg|
-          truncated_msg = msg.dup
-          if msg[:content].is_a?(String) && msg[:content].length > 80
-            truncated_msg[:content] = msg[:content][0..77] + "..."
-          end
-          truncated_msg
-        end
-        vars << ["@messages", messages]
+      vars = chat.inspectable_attributes.map do |(name, value)|
+        [name.to_s, value]
       end
-
-      # Add other variables (except sensitive ones)
-      skip_vars = [:@api_key, :@client, :@messages]
-      chat.instance_variables.sort.each do |var|
-        next if skip_vars.include?(var)
-        value = chat.instance_variable_get(var)
-        vars << [var.to_s, value] unless value.nil?
-      end
-
       format_object(chat, vars)
     end
 
@@ -65,10 +61,13 @@ def format_object(object, vars)
         "#{name}: #{inspector.awesome(value)}"
       end
 
+      lt = @options[:html] ? "<" : "<"
+      gt = @options[:html] ? ">" : ">"
+
       if @options[:multiline]
-        "#<#{object.class}\n#{data.map { |line| "  #{line}" }.join("\n")}\n>"
+        "##{lt}#{object.class}\n#{data.map { |line| "  #{line}" }.join("\n")}\n#{gt}"
       else
-        "#<#{object.class} #{data.join(", ")}>"
+        "##{lt}#{object.class} #{data.join(", ")}#{gt}"
       end
     end
   end
diff --git a/lib/ai/chat.rb b/lib/ai/chat.rb
index f1b7e41..3a5b891 100644
--- a/lib/ai/chat.rb
+++ b/lib/ai/chat.rb
@@ -12,7 +12,6 @@
 require "timeout"
 
 require_relative "http"
-include AI::Http
 
 module AI
   # :reek:MissingSafeMethod { exclude: [ generate! ] }
@@ -21,6 +20,8 @@ module AI
   # :reek:InstanceVariableAssumption
   # :reek:IrresponsibleModule
   class Chat
+    include AI::Http
+
     # :reek:Attribute
     attr_accessor :background, :code_interpreter, :conversation_id, :image_generation, :image_folder, :messages, :model, :proxy, :reasoning_effort, :web_search
     attr_reader :client, :last_response_id, :schema, :schema_file
@@ -84,15 +85,12 @@ def self.generate_schema!(description, location: "schema.json", api_key: nil, ap
     # :reek:TooManyStatements
     # :reek:NilCheck
     def add(content, role: "user", response: nil, status: nil, image: nil, images: nil, file: nil, files: nil)
-      if image.nil? && images.nil? && file.nil? && files.nil?
-        message = {
-          role: role,
-          content: content,
-          response: response
-        }
-        message[:content] = content if content
-        message[:status] = status if status
-        messages.push(message)
+      message = if image.nil? && images.nil? && file.nil? && files.nil?
+        msg = Message[role: role]
+        msg[:content] = content if content
+        msg[:response] = response if response
+        msg[:status] = status if status
+        msg
       else
         text_and_files_array = [
           {
@@ -122,14 +120,15 @@ def add(content, role: "user", response: nil, status: nil, image: nil, images: n
           text_and_files_array.push(process_file_input(file))
         end
 
-        messages.push(
-          {
-            role: role,
-            content: text_and_files_array,
-            status: status
-          }
-        )
+        Message[
+          role: role,
+          content: text_and_files_array,
+          status: status
+        ]
       end
+
+      messages.push(message)
+      message
     end
 
     def system(message)
@@ -189,10 +188,10 @@ def last
       messages.last
     end
 
-    def items(order: :asc)
+    def get_items(order: :asc)
       raise "No conversation_id set. Call generate! first to create a conversation." unless conversation_id
 
-      if proxy
+      raw_items = if proxy
         uri = URI(PROXY_URL + "api.openai.com/v1/conversations/#{conversation_id}/items?order=#{order}")
         response_hash = send_request(uri, content_type: "json", method: "get")
 
@@ -215,62 +214,50 @@ def items(order: :asc)
       else
         client.conversations.items.list(conversation_id, order: order)
       end
+
+      Items.new(raw_items, conversation_id: conversation_id)
     end
 
-    def verbose
-      page = items
+    def inspectable_attributes
+      attrs = []
+
+      # 1. Model and reasoning (configuration)
+      attrs << [:@model, @model]
+      attrs << [:@reasoning_effort, @reasoning_effort]
+
+      # 2. Conversation state
+      attrs << [:@conversation_id, @conversation_id]
+      attrs << [:@last_response_id, @last_response_id] if @last_response_id
 
-      box_width = 78
-      inner_width = box_width - 4
+      # 3. Messages (the main content, without response details)
+      display_messages = @messages.map { |msg| msg.except(:response) }
+      attrs << [:@messages, display_messages]
 
-      puts
-      puts "┌#{"─" * (box_width - 2)}┐"
-      puts "│ Conversation: #{conversation_id.ljust(inner_width - 14)} │"
-      puts "│ Items: #{page.data.length.to_s.ljust(inner_width - 7)} │"
-      puts "└#{"─" * (box_width - 2)}┘"
-      puts
+      # 4. Optional features (only if enabled/changed from default)
+      attrs << [:@proxy, @proxy] if @proxy != false
+      attrs << [:@image_generation, @image_generation] if @image_generation != false
+      attrs << [:@image_folder, @image_folder] if @image_folder != "./images"
 
-      ap page.data, limit: 10, indent: 2
+      # 5. Optional state (only if set)
+      attrs << [:@background, @background] if @background
+      attrs << [:@code_interpreter, @code_interpreter] if @code_interpreter
+      attrs << [:@web_search, @web_search] if @web_search
+      attrs << [:@schema, @schema] if @schema
+      attrs << [:@schema_file, @schema_file] if @schema_file
+
+      attrs
     end
 
     def inspect
-      "#<#{self.class.name} @messages=#{messages.inspect} @model=#{@model.inspect} @schema=#{@schema.inspect} @reasoning_effort=#{@reasoning_effort.inspect}>"
+      ai(plain: !$stdout.tty?, multiline: true)
     end
 
-    # Support for Ruby's pp (pretty print)
-    # :reek:TooManyStatements
-    # :reek:NilCheck
-    # :reek:FeatureEnvy
-    # :reek:DuplicateMethodCall
-    # :reek:UncommunicativeParameterName
-    def pretty_print(q)
-      q.group(1, "#<#{self.class}", ">") do
-        q.breakable
-
-        # Show messages with truncation
-        q.text "@messages="
-        truncated_messages = @messages.map do |msg|
-          truncated_msg = msg.dup
-          if msg[:content].is_a?(String) && msg[:content].length > 80
-            truncated_msg[:content] = msg[:content][0..77] + "..."
-          end
-          truncated_msg
-        end
-        q.pp truncated_messages
-
-        # Show other instance variables (except sensitive ones)
-        skip_vars = [:@messages, :@api_key, :@client]
-        instance_variables.sort.each do |var|
-          next if skip_vars.include?(var)
-          value = instance_variable_get(var)
-          unless value.nil?
-            q.text ","
-            q.breakable
-            q.text "#{var}="
-            q.pp value
-          end
-        end
-      end
+    def to_html
+      AI.wrap_html(ai(html: true, multiline: true))
+    end
+
+    def pretty_inspect
+      "#{inspect}\n"
     end
 
     private
@@ -312,7 +299,7 @@ def create_response
       parameters[:background] = background if background
       parameters[:tools] = tools unless tools.empty?
       parameters[:text] = schema if schema
-      parameters[:reasoning] = {effort: reasoning_effort} if reasoning_effort
+      parameters[:reasoning] = {effort: reasoning_effort, summary: "auto"} if reasoning_effort
 
       create_conversation unless conversation_id
       parameters[:conversation] = conversation_id
@@ -387,12 +374,12 @@ def parse_response(response)
         message.dig(:response, :id) == response_id
       end
 
-      message = {
+      message = Message[
         role: "assistant",
         content: response_content,
         response: chat_response,
         status: response_status
-      }
+      ]
 
       message.store(:images, image_filenames) unless image_filenames.empty?
 
@@ -400,8 +387,9 @@ def parse_response(response)
         messages[existing_message_position] = message
       else
         messages.push(message)
-        message
       end
+
+      message
     end
 
     def cancel_request
diff --git a/lib/ai/items.rb b/lib/ai/items.rb
new file mode 100644
index 0000000..fda667d
--- /dev/null
+++ b/lib/ai/items.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require "delegate"
+
+module AI
+  class Items < SimpleDelegator
+    def initialize(response, conversation_id:)
+      super(response)
+      @conversation_id = conversation_id
+    end
+
+    def to_html
+      AI.wrap_html(build_output(html: true))
+    end
+
+    def inspect
+      build_output(html: false, plain: !$stdout.tty?)
+    end
+
+    def pretty_inspect
+      "#{inspect}\n"
+    end
+
+    def pretty_print(q)
+      q.output << inspect
+    end
+
+    private
+
+    def build_output(html: false, plain: false)
+      box = build_box
+      items_output = data.ai(html: html, plain: plain, limit: 100, indent: 2, index: true)
+
+      if html
+        "
#{box}
\n#{items_output}" + else + "#{box}\n#{items_output}" + end + end + + def build_box + box_width = 78 + inner_width = box_width - 4 + + lines = [] + lines << "┌#{"─" * (box_width - 2)}┐" + lines << "│ Conversation: #{@conversation_id.to_s.ljust(inner_width - 14)} │" + lines << "│ Items: #{data.length.to_s.ljust(inner_width - 7)} │" + lines << "└#{"─" * (box_width - 2)}┘" + + lines.join("\n") + end + end +end diff --git a/lib/ai/message.rb b/lib/ai/message.rb new file mode 100644 index 0000000..b33eade --- /dev/null +++ b/lib/ai/message.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module AI + class Message < Hash + def inspect + ai(plain: !$stdout.tty?, index: false) + end + + def pretty_inspect + "#{inspect}\n" + end + + # IRB's ColorPrinter calls pretty_print and re-colorizes text, + # which escapes our ANSI codes. Write directly to output to bypass. + def pretty_print(q) + q.output << inspect + end + + def to_html + AI.wrap_html(ai(html: true, index: false)) + end + end +end diff --git a/spec/integration/ai_chat_integration_spec.rb b/spec/integration/ai_chat_integration_spec.rb index 451690b..1b123f9 100644 --- a/spec/integration/ai_chat_integration_spec.rb +++ b/spec/integration/ai_chat_integration_spec.rb @@ -204,7 +204,7 @@ chat.user("Say hello") chat.generate! - items = chat.items + items = chat.get_items expect(items).to respond_to(:data) expect(items.data).to be_an(Array) diff --git a/spec/unit/chat_spec.rb b/spec/unit/chat_spec.rb new file mode 100644 index 0000000..c7081dc --- /dev/null +++ b/spec/unit/chat_spec.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AI::Chat do + let(:chat) { AI::Chat.new } + + describe "#add" do + it "returns the added message, not the messages array" do + result = chat.add("Hello", role: "user") + + expect(result).not_to be_an(Array) + expect(result[:content]).to eq("Hello") + expect(result[:role]).to eq("user") + end + + it "returns an AI::Message instance" do + result = chat.add("Hello", role: "user") + + expect(result).to be_an(AI::Message) + end + + it "still adds the message to the messages array" do + chat.add("Hello", role: "user") + + expect(chat.messages.count).to eq(1) + expect(chat.messages.first[:content]).to eq("Hello") + end + + it "only includes :response key when response is provided" do + result_without_response = chat.add("Hello", role: "user") + result_with_response = chat.add("Hi", role: "assistant", response: {id: "resp_123"}) + + expect(result_without_response).not_to have_key(:response) + expect(result_with_response).to have_key(:response) + expect(result_with_response[:response]).to eq({id: "resp_123"}) + end + + it "only includes :status key when status is provided" do + result_without_status = chat.add("Hello", role: "user") + result_with_status = chat.add("Hi", role: "assistant", status: :completed) + + expect(result_without_status).not_to have_key(:status) + expect(result_with_status).to have_key(:status) + expect(result_with_status[:status]).to eq(:completed) + end + + it "only includes :content key when content is provided" do + result_with_content = chat.add("Hello", role: "user") + result_without_content = chat.add(nil, role: "system") + + expect(result_with_content).to have_key(:content) + expect(result_without_content).not_to have_key(:content) + end + end + + describe "#system" do + it "returns an AI::Message" do + result = chat.system("You are helpful") + + expect(result).to be_an(AI::Message) + expect(result[:role]).to eq("system") + end + end + + describe "#user" do + it "returns an AI::Message" do + result = chat.user("Hello") + + expect(result).to be_an(AI::Message) + expect(result[:role]).to eq("user") + end + end + + describe "#assistant" do + it "returns an AI::Message" do + result = chat.assistant("Hello!") + + expect(result).to be_an(AI::Message) + expect(result[:role]).to eq("assistant") + end + end + + describe "#inspectable_attributes" do + it "excludes :response key from displayed messages" do + chat.add("Hello", role: "user") + chat.add("Hi there!", role: "assistant", response: {id: "resp_123", model: "gpt-4"}) + + attrs = chat.inspectable_attributes + messages_attr = attrs.find { |name, _| name == :@messages } + display_messages = messages_attr[1] + + display_messages.each do |msg| + expect(msg).not_to have_key(:response) + end + end + + it "includes @last_response_id only when set" do + attrs_without = chat.inspectable_attributes + attr_names_without = attrs_without.map(&:first) + + expect(attr_names_without).not_to include(:@last_response_id) + + chat.instance_variable_set(:@last_response_id, "resp_123") + attrs_with = chat.inspectable_attributes + + last_response_attr = attrs_with.find { |name, _| name == :@last_response_id } + expect(last_response_attr).not_to be_nil + expect(last_response_attr[1]).to eq("resp_123") + end + + it "shows @last_response_id after @conversation_id" do + chat.instance_variable_set(:@conversation_id, "conv_123") + chat.instance_variable_set(:@last_response_id, "resp_456") + + attrs = chat.inspectable_attributes + attr_names = attrs.map(&:first) + + conv_index = attr_names.index(:@conversation_id) + resp_index = attr_names.index(:@last_response_id) + + expect(resp_index).to eq(conv_index + 1) + end + + it "excludes optional features when at default values" do + attrs = chat.inspectable_attributes + attr_names = attrs.map(&:first) + + expect(attr_names).not_to include(:@proxy) + expect(attr_names).not_to include(:@image_generation) + expect(attr_names).not_to include(:@image_folder) + end + + it "includes optional features when changed from defaults" do + chat.proxy = true + chat.image_generation = true + chat.image_folder = "./my_images" + + attrs = chat.inspectable_attributes + attr_names = attrs.map(&:first) + + expect(attr_names).to include(:@proxy) + expect(attr_names).to include(:@image_generation) + expect(attr_names).to include(:@image_folder) + end + + it "excludes optional state when not set" do + attrs = chat.inspectable_attributes + attr_names = attrs.map(&:first) + + expect(attr_names).not_to include(:@background) + expect(attr_names).not_to include(:@code_interpreter) + expect(attr_names).not_to include(:@web_search) + expect(attr_names).not_to include(:@schema) + end + + it "includes optional state when set" do + chat.background = true + chat.code_interpreter = true + chat.web_search = true + chat.schema = {name: "test", strict: true, schema: {type: "object", properties: {}, additionalProperties: false}} + + attrs = chat.inspectable_attributes + attr_names = attrs.map(&:first) + + expect(attr_names).to include(:@background) + expect(attr_names).to include(:@code_interpreter) + expect(attr_names).to include(:@web_search) + expect(attr_names).to include(:@schema) + end + end + + describe "#inspect" do + it "returns a String" do + expect(chat.inspect).to be_a(String) + end + end + + describe "#pretty_inspect" do + it "returns a String ending with newline" do + expect(chat.pretty_inspect).to be_a(String) + expect(chat.pretty_inspect).to end_with("\n") + end + end + + describe "#to_html" do + it "returns a String" do + expect(chat.to_html).to be_a(String) + end + end +end diff --git a/spec/unit/items_spec.rb b/spec/unit/items_spec.rb new file mode 100644 index 0000000..c9b5486 --- /dev/null +++ b/spec/unit/items_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AI::Items do + let(:sample_data) do + [ + {type: :message, role: "user", content: [{type: "input_text", text: "Hello"}]}, + {type: :message, role: "assistant", content: [{type: "output_text", text: "Hi there!"}]} + ] + end + let(:response) { OpenStruct.new(data: sample_data, has_more: false, first_id: "item_1", last_id: "item_2") } + let(:conversation_id) { "conv_abc123" } + let(:items) { AI::Items.new(response, conversation_id: conversation_id) } + + describe "delegation" do + it "delegates #data to the underlying response" do + expect(items.data).to eq(sample_data) + end + + it "delegates pagination fields to the underlying response" do + expect(items.has_more).to eq(false) + expect(items.first_id).to eq("item_1") + expect(items.last_id).to eq("item_2") + end + + it "allows iterating over data" do + results = [] + items.data.each { |item| results << item[:type] } + expect(results).to eq([:message, :message]) + end + end + + describe "#inspect" do + it "returns a String" do + expect(items.inspect).to be_a(String) + end + + it "includes conversation_id in the output" do + expect(items.inspect).to include("conv_abc123") + end + + it "includes item count in the output" do + expect(items.inspect).to include("Items: 2") + end + end + + describe "#pretty_inspect" do + it "returns a String ending with newline" do + expect(items.pretty_inspect).to be_a(String) + expect(items.pretty_inspect).to end_with("\n") + end + end + + describe "#to_html" do + it "returns a String" do + expect(items.to_html).to be_a(String) + end + + it "includes conversation_id in the output" do + expect(items.to_html).to include("conv_abc123") + end + + it "includes the dark background style" do + expect(items.to_html).to include("background-color: #1e1e1e") + end + end +end diff --git a/spec/unit/message_spec.rb b/spec/unit/message_spec.rb new file mode 100644 index 0000000..1cc1f96 --- /dev/null +++ b/spec/unit/message_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AI::Message do + describe "Hash subclass behavior" do + it "is a subclass of Hash" do + expect(AI::Message.new).to be_a(Hash) + end + + it "can be created with Hash.[] syntax" do + message = AI::Message[role: "user", content: "Hello"] + + expect(message[:role]).to eq("user") + expect(message[:content]).to eq("Hello") + end + + it "supports standard Hash operations" do + message = AI::Message[role: "user"] + message[:content] = "Hello" + + expect(message.keys).to contain_exactly(:role, :content) + expect(message.values).to contain_exactly("user", "Hello") + end + end + + describe "#inspect" do + it "returns a String" do + message = AI::Message[role: "user", content: "Hello"] + + expect(message.inspect).to be_a(String) + end + + it "does not return raw Hash representation" do + message = AI::Message[role: "user", content: "Hello"] + + expect(message.inspect).not_to eq({role: "user", content: "Hello"}.inspect) + end + end + + describe "#pretty_inspect" do + it "returns a String ending with newline" do + message = AI::Message[role: "user", content: "Hello"] + + expect(message.pretty_inspect).to be_a(String) + expect(message.pretty_inspect).to end_with("\n") + end + end + + describe "#to_html" do + it "returns a String" do + message = AI::Message[role: "user", content: "Hello"] + + expect(message.to_html).to be_a(String) + end + end + + describe "#pretty_print" do + it "writes directly to output to bypass IRB colorization" do + message = AI::Message[role: "user", content: "Hello"] + output = StringIO.new + mock_q = double("PrettyPrint", output: output) + + message.pretty_print(mock_q) + + expect(output.string).to eq(message.inspect) + end + end +end