From 7bf6877b42ab3fd68af685ef299c6bac232ee55f Mon Sep 17 00:00:00 2001 From: Sam Schmidt Date: Mon, 2 Mar 2026 14:00:00 -0500 Subject: [PATCH] Add Pi progress display and tool use formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ToolUse and ToolResult formatter classes for human-readable progress display during Pi agent execution, mirroring Claude's pattern. ToolUse provides specialized formatting for known Pi tools: - bash: shows command - read/write/edit: shows file path - grep: shows pattern and optional path - find: shows pattern and optional directory - ls: shows path - Unknown tools: shows name and arguments hash ToolResult formats tool execution results with: - OK/ERROR status indicator - Truncated content preview (max 200 chars, whitespace collapsed) Progress display enhancements: - MessageUpdateMessage#format now delegates toolcall_end events to ToolUse - TurnStartMessage#format shows '--- turn start ---' marker - TurnEndMessage#format shows model, token counts, and cost per turn - ToolExecutionStartMessage#format shows '⚙ executing tool...' - ToolExecutionEndMessage#format shows '⚙ tool execution complete' - PiInvocation#handle_message now calls format on all message types (not just MessageUpdateMessage) when show_progress is enabled 37 new tests (162 total Pi tests), full suite: 1037 tests. --- .../pi/messages/message_update_message.rb | 7 +- .../pi/messages/tool_execution_end_message.rb | 4 + .../messages/tool_execution_start_message.rb | 4 + .../providers/pi/messages/turn_end_message.rb | 11 ++ .../pi/messages/turn_start_message.rb | 4 + .../cogs/agent/providers/pi/pi_invocation.rb | 7 +- .../cogs/agent/providers/pi/tool_result.rb | 57 ++++++++++ lib/roast/cogs/agent/providers/pi/tool_use.rb | 90 +++++++++++++++ .../messages/message_update_message_test.rb | 15 ++- .../tool_execution_end_message_test.rb | 6 + .../tool_execution_start_message_test.rb | 6 + .../pi/messages/turn_end_message_test.rb | 44 ++++++++ .../pi/messages/turn_start_message_test.rb | 6 + .../agent/providers/pi/tool_result_test.rb | 78 +++++++++++++ .../cogs/agent/providers/pi/tool_use_test.rb | 106 ++++++++++++++++++ 15 files changed, 438 insertions(+), 7 deletions(-) create mode 100644 lib/roast/cogs/agent/providers/pi/tool_result.rb create mode 100644 lib/roast/cogs/agent/providers/pi/tool_use.rb create mode 100644 test/roast/cogs/agent/providers/pi/tool_result_test.rb create mode 100644 test/roast/cogs/agent/providers/pi/tool_use_test.rb diff --git a/lib/roast/cogs/agent/providers/pi/messages/message_update_message.rb b/lib/roast/cogs/agent/providers/pi/messages/message_update_message.rb index c326f6a0..c29a6348 100644 --- a/lib/roast/cogs/agent/providers/pi/messages/message_update_message.rb +++ b/lib/roast/cogs/agent/providers/pi/messages/message_update_message.rb @@ -84,8 +84,11 @@ def format return nil unless tc name = tc[:name] - args = tc[:arguments] - "TOOL: #{name} #{args.inspect}" if name + return nil unless name + + args = tc[:arguments] || {} + tool_use = ToolUse.new(name:, arguments: args) + tool_use.format end end end diff --git a/lib/roast/cogs/agent/providers/pi/messages/tool_execution_end_message.rb b/lib/roast/cogs/agent/providers/pi/messages/tool_execution_end_message.rb index 6dc291ed..513c75bb 100644 --- a/lib/roast/cogs/agent/providers/pi/messages/tool_execution_end_message.rb +++ b/lib/roast/cogs/agent/providers/pi/messages/tool_execution_end_message.rb @@ -14,6 +14,10 @@ module Messages # Example: # {"type":"tool_execution_end"} class ToolExecutionEndMessage < Message + #: () -> String + def format + "⚙ tool execution complete" + end end end end diff --git a/lib/roast/cogs/agent/providers/pi/messages/tool_execution_start_message.rb b/lib/roast/cogs/agent/providers/pi/messages/tool_execution_start_message.rb index f067dde5..8f1c3632 100644 --- a/lib/roast/cogs/agent/providers/pi/messages/tool_execution_start_message.rb +++ b/lib/roast/cogs/agent/providers/pi/messages/tool_execution_start_message.rb @@ -14,6 +14,10 @@ module Messages # Example: # {"type":"tool_execution_start"} class ToolExecutionStartMessage < Message + #: () -> String + def format + "⚙ executing tool..." + end end end end diff --git a/lib/roast/cogs/agent/providers/pi/messages/turn_end_message.rb b/lib/roast/cogs/agent/providers/pi/messages/turn_end_message.rb index 23283234..89d5c081 100644 --- a/lib/roast/cogs/agent/providers/pi/messages/turn_end_message.rb +++ b/lib/roast/cogs/agent/providers/pi/messages/turn_end_message.rb @@ -56,6 +56,17 @@ def content def stop_reason @message&.dig(:stopReason) end + + # Format for progress display + # + #: () -> String? + def format + u = usage + return nil unless u + + cost_str = u.dig(:cost, :total) ? sprintf("$%.6f", u.dig(:cost, :total)) : "n/a" + "--- turn end (#{model || "unknown"}: #{u[:input] || 0} in, #{u[:output] || 0} out, #{cost_str}) ---" + end end end end diff --git a/lib/roast/cogs/agent/providers/pi/messages/turn_start_message.rb b/lib/roast/cogs/agent/providers/pi/messages/turn_start_message.rb index a17b3548..38ca8a1f 100644 --- a/lib/roast/cogs/agent/providers/pi/messages/turn_start_message.rb +++ b/lib/roast/cogs/agent/providers/pi/messages/turn_start_message.rb @@ -12,6 +12,10 @@ module Messages # Example: # {"type":"turn_start"} class TurnStartMessage < Message + #: () -> String + def format + "--- turn start ---" + end end end end diff --git a/lib/roast/cogs/agent/providers/pi/pi_invocation.rb b/lib/roast/cogs/agent/providers/pi/pi_invocation.rb index 86e28a45..24df7a0c 100644 --- a/lib/roast/cogs/agent/providers/pi/pi_invocation.rb +++ b/lib/roast/cogs/agent/providers/pi/pi_invocation.rb @@ -130,10 +130,11 @@ def handle_message(message) @result.success = true when Messages::TurnEndMessage accumulate_turn_stats(message) - when Messages::MessageUpdateMessage - # Show progress for text deltas + end + + if @show_progress formatted = message.format - puts formatted if formatted.present? && @show_progress + puts formatted if formatted.present? end unless message.unparsed.blank? diff --git a/lib/roast/cogs/agent/providers/pi/tool_result.rb b/lib/roast/cogs/agent/providers/pi/tool_result.rb new file mode 100644 index 00000000..e2a38329 --- /dev/null +++ b/lib/roast/cogs/agent/providers/pi/tool_result.rb @@ -0,0 +1,57 @@ +# typed: true +# frozen_string_literal: true + +module Roast + module Cogs + class Agent < Cog + module Providers + class Pi < Provider + # Formats a Pi tool execution result for progress display + # + # Tool results represent the output of a tool execution, which may be + # successful content or an error. + class ToolResult + #: String + attr_reader :tool_name + + #: String? + attr_reader :content + + #: bool + attr_reader :is_error + + #: (tool_name: String, content: String?, is_error: bool) -> void + def initialize(tool_name:, content:, is_error:) + @tool_name = tool_name + @content = content + @is_error = is_error + end + + #: () -> String + def format + status = is_error ? "ERROR" : "OK" + preview = content_preview + "RESULT [#{tool_name}] #{status}#{preview ? ": #{preview}" : ""}" + end + + private + + MAX_PREVIEW_LENGTH = 200 #: Integer + + #: () -> String? + def content_preview + return nil if content.nil? || content.empty? + + truncated = content.strip.gsub(/\s+/, " ") + if truncated.length > MAX_PREVIEW_LENGTH + "#{truncated[0...MAX_PREVIEW_LENGTH]}..." + else + truncated + end + end + end + end + end + end + end +end diff --git a/lib/roast/cogs/agent/providers/pi/tool_use.rb b/lib/roast/cogs/agent/providers/pi/tool_use.rb new file mode 100644 index 00000000..58ee1650 --- /dev/null +++ b/lib/roast/cogs/agent/providers/pi/tool_use.rb @@ -0,0 +1,90 @@ +# typed: true +# frozen_string_literal: true + +module Roast + module Cogs + class Agent < Cog + module Providers + class Pi < Provider + # Formats a Pi tool call for progress display + # + # Pi tool calls come from `toolcall_end` events in `message_update` messages. + # Each tool has a name and arguments hash. Known tools get specialized formatting; + # unknown tools get a generic display. + class ToolUse + #: String + attr_reader :name + + #: Hash[Symbol, untyped] + attr_reader :arguments + + #: (name: String, arguments: Hash[Symbol, untyped]) -> void + def initialize(name:, arguments:) + @name = name + @arguments = arguments + end + + #: () -> String + def format + format_method_name = "format_#{name}".to_sym + return send(format_method_name) if respond_to?(format_method_name, true) + + format_unknown + end + + private + + #: () -> String + def format_bash + command = arguments[:command] || arguments[:cmd] + "BASH: #{command}" + end + + #: () -> String + def format_read + path = arguments[:path] + "READ: #{path}" + end + + #: () -> String + def format_write + path = arguments[:path] + "WRITE: #{path}" + end + + #: () -> String + def format_edit + path = arguments[:path] + "EDIT: #{path}" + end + + #: () -> String + def format_grep + pattern = arguments[:pattern] || arguments[:query] + path = arguments[:path] + "GREP: #{pattern}#{path ? " in #{path}" : ""}" + end + + #: () -> String + def format_find + path = arguments[:path] || arguments[:dir] + pattern = arguments[:pattern] || arguments[:name] + "FIND: #{pattern}#{path ? " in #{path}" : ""}" + end + + #: () -> String + def format_ls + path = arguments[:path] + "LS: #{path}" + end + + #: () -> String + def format_unknown + "TOOL [#{name}] #{arguments.inspect}" + end + end + end + end + end + end +end diff --git a/test/roast/cogs/agent/providers/pi/messages/message_update_message_test.rb b/test/roast/cogs/agent/providers/pi/messages/message_update_message_test.rb index 7f52e120..4992ad5a 100644 --- a/test/roast/cogs/agent/providers/pi/messages/message_update_message_test.rb +++ b/test/roast/cogs/agent/providers/pi/messages/message_update_message_test.rb @@ -115,8 +115,19 @@ class MessageUpdateMessageTest < ActiveSupport::TestCase message = MessageUpdateMessage.new(type: "message_update", hash:) result = message.format - assert_includes result, "TOOL: bash" - assert_includes result, "command" + assert_equal "BASH: ls", result + end + + test "format returns ToolUse formatted output for read tool" do + hash = { + assistantMessageEvent: { + type: "toolcall_end", + toolCall: { name: "read", arguments: { path: "/tmp/test.txt" } }, + }, + } + message = MessageUpdateMessage.new(type: "message_update", hash:) + + assert_equal "READ: /tmp/test.txt", message.format end test "format returns nil for text_start events" do diff --git a/test/roast/cogs/agent/providers/pi/messages/tool_execution_end_message_test.rb b/test/roast/cogs/agent/providers/pi/messages/tool_execution_end_message_test.rb index cb824313..ab62bf6a 100644 --- a/test/roast/cogs/agent/providers/pi/messages/tool_execution_end_message_test.rb +++ b/test/roast/cogs/agent/providers/pi/messages/tool_execution_end_message_test.rb @@ -14,6 +14,12 @@ class ToolExecutionEndMessageTest < ActiveSupport::TestCase assert_equal "tool_execution_end", message.type end + + test "format returns completion marker" do + message = ToolExecutionEndMessage.new(type: "tool_execution_end", hash: {}) + + assert_equal "⚙ tool execution complete", message.format + end end end end diff --git a/test/roast/cogs/agent/providers/pi/messages/tool_execution_start_message_test.rb b/test/roast/cogs/agent/providers/pi/messages/tool_execution_start_message_test.rb index dd6ce003..4adce313 100644 --- a/test/roast/cogs/agent/providers/pi/messages/tool_execution_start_message_test.rb +++ b/test/roast/cogs/agent/providers/pi/messages/tool_execution_start_message_test.rb @@ -14,6 +14,12 @@ class ToolExecutionStartMessageTest < ActiveSupport::TestCase assert_equal "tool_execution_start", message.type end + + test "format returns execution marker" do + message = ToolExecutionStartMessage.new(type: "tool_execution_start", hash: {}) + + assert_equal "⚙ executing tool...", message.format + end end end end diff --git a/test/roast/cogs/agent/providers/pi/messages/turn_end_message_test.rb b/test/roast/cogs/agent/providers/pi/messages/turn_end_message_test.rb index ef6cc115..01239c24 100644 --- a/test/roast/cogs/agent/providers/pi/messages/turn_end_message_test.rb +++ b/test/roast/cogs/agent/providers/pi/messages/turn_end_message_test.rb @@ -108,6 +108,50 @@ class TurnEndMessageTest < ActiveSupport::TestCase assert_nil message.stop_reason end + + # format tests + + test "format returns summary with model and usage" do + hash = { + message: { + model: "claude-opus-4-6", + usage: { input: 100, output: 50, cost: { total: 0.025 } }, + }, + } + message = TurnEndMessage.new(type: "turn_end", hash:) + + result = message.format + + assert_includes result, "turn end" + assert_includes result, "claude-opus-4-6" + assert_includes result, "100 in" + assert_includes result, "50 out" + assert_includes result, "$0.025000" + end + + test "format returns nil when no usage" do + hash = { message: { model: "test" } } + message = TurnEndMessage.new(type: "turn_end", hash:) + + assert_nil message.format + end + + test "format returns nil when message is nil" do + message = TurnEndMessage.new(type: "turn_end", hash: {}) + + assert_nil message.format + end + + test "format shows unknown model when model is nil" do + hash = { + message: { + usage: { input: 10, output: 5, cost: { total: 0.001 } }, + }, + } + message = TurnEndMessage.new(type: "turn_end", hash:) + + assert_includes message.format, "unknown" + end end end end diff --git a/test/roast/cogs/agent/providers/pi/messages/turn_start_message_test.rb b/test/roast/cogs/agent/providers/pi/messages/turn_start_message_test.rb index 46372d65..91a57364 100644 --- a/test/roast/cogs/agent/providers/pi/messages/turn_start_message_test.rb +++ b/test/roast/cogs/agent/providers/pi/messages/turn_start_message_test.rb @@ -14,6 +14,12 @@ class TurnStartMessageTest < ActiveSupport::TestCase assert_equal "turn_start", message.type end + + test "format returns turn start marker" do + message = TurnStartMessage.new(type: "turn_start", hash: {}) + + assert_equal "--- turn start ---", message.format + end end end end diff --git a/test/roast/cogs/agent/providers/pi/tool_result_test.rb b/test/roast/cogs/agent/providers/pi/tool_result_test.rb new file mode 100644 index 00000000..7b95b327 --- /dev/null +++ b/test/roast/cogs/agent/providers/pi/tool_result_test.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "test_helper" + +module Roast + module Cogs + class Agent < Cog + module Providers + class Pi < Provider + class ToolResultTest < ActiveSupport::TestCase + test "initialize sets attributes" do + result = ToolResult.new(tool_name: "bash", content: "output", is_error: false) + + assert_equal "bash", result.tool_name + assert_equal "output", result.content + refute result.is_error + end + + test "format shows OK status for successful results" do + result = ToolResult.new(tool_name: "read", content: "file contents", is_error: false) + + output = result.format + + assert_includes output, "RESULT [read]" + assert_includes output, "OK" + assert_includes output, "file contents" + end + + test "format shows ERROR status for error results" do + result = ToolResult.new(tool_name: "bash", content: "command not found", is_error: true) + + output = result.format + + assert_includes output, "RESULT [bash]" + assert_includes output, "ERROR" + assert_includes output, "command not found" + end + + test "format truncates long content" do + long_content = "x" * 300 + result = ToolResult.new(tool_name: "read", content: long_content, is_error: false) + + output = result.format + + assert_operator output.length, :<, 300 + assert_includes output, "..." + end + + test "format handles nil content" do + result = ToolResult.new(tool_name: "bash", content: nil, is_error: false) + + output = result.format + + assert_includes output, "RESULT [bash] OK" + refute_includes output, ":" # No content preview separator + end + + test "format handles empty content" do + result = ToolResult.new(tool_name: "bash", content: "", is_error: false) + + output = result.format + + assert_includes output, "RESULT [bash] OK" + end + + test "format collapses whitespace in content preview" do + result = ToolResult.new(tool_name: "read", content: "line 1\n line 2\n\tline 3", is_error: false) + + output = result.format + + assert_includes output, "line 1 line 2 line 3" + end + end + end + end + end + end +end diff --git a/test/roast/cogs/agent/providers/pi/tool_use_test.rb b/test/roast/cogs/agent/providers/pi/tool_use_test.rb new file mode 100644 index 00000000..d85d97a2 --- /dev/null +++ b/test/roast/cogs/agent/providers/pi/tool_use_test.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "test_helper" + +module Roast + module Cogs + class Agent < Cog + module Providers + class Pi < Provider + class ToolUseTest < ActiveSupport::TestCase + test "initialize sets name and arguments" do + tool_use = ToolUse.new(name: "bash", arguments: { command: "ls" }) + + assert_equal "bash", tool_use.name + assert_equal({ command: "ls" }, tool_use.arguments) + end + + test "format_bash shows command" do + tool_use = ToolUse.new(name: "bash", arguments: { command: "ls -la" }) + + assert_equal "BASH: ls -la", tool_use.format + end + + test "format_bash uses cmd key as fallback" do + tool_use = ToolUse.new(name: "bash", arguments: { cmd: "echo hello" }) + + assert_equal "BASH: echo hello", tool_use.format + end + + test "format_read shows path" do + tool_use = ToolUse.new(name: "read", arguments: { path: "/tmp/test.txt" }) + + assert_equal "READ: /tmp/test.txt", tool_use.format + end + + test "format_write shows path" do + tool_use = ToolUse.new(name: "write", arguments: { path: "/tmp/output.txt" }) + + assert_equal "WRITE: /tmp/output.txt", tool_use.format + end + + test "format_edit shows path" do + tool_use = ToolUse.new(name: "edit", arguments: { path: "/tmp/file.rb" }) + + assert_equal "EDIT: /tmp/file.rb", tool_use.format + end + + test "format_grep shows pattern and path" do + tool_use = ToolUse.new(name: "grep", arguments: { pattern: "TODO", path: "src/" }) + + assert_equal "GREP: TODO in src/", tool_use.format + end + + test "format_grep shows pattern without path" do + tool_use = ToolUse.new(name: "grep", arguments: { pattern: "TODO" }) + + assert_equal "GREP: TODO", tool_use.format + end + + test "format_grep uses query key as fallback" do + tool_use = ToolUse.new(name: "grep", arguments: { query: "FIXME" }) + + assert_equal "GREP: FIXME", tool_use.format + end + + test "format_find shows pattern and path" do + tool_use = ToolUse.new(name: "find", arguments: { pattern: "*.rb", path: "lib/" }) + + assert_equal "FIND: *.rb in lib/", tool_use.format + end + + test "format_find uses dir and name keys as fallback" do + tool_use = ToolUse.new(name: "find", arguments: { name: "*.txt", dir: "/tmp" }) + + assert_equal "FIND: *.txt in /tmp", tool_use.format + end + + test "format_ls shows path" do + tool_use = ToolUse.new(name: "ls", arguments: { path: "/tmp" }) + + assert_equal "LS: /tmp", tool_use.format + end + + test "format_unknown shows tool name and arguments" do + tool_use = ToolUse.new(name: "custom_tool", arguments: { key: "value" }) + + output = tool_use.format + + assert_match(/TOOL \[custom_tool\]/, output) + assert_match(/key.*value/, output) + end + + test "format dispatches to known tools" do + %w[bash read write edit grep find ls].each do |tool_name| + tool_use = ToolUse.new(name: tool_name, arguments: { path: "/test" }) + + # Should not include "TOOL [" prefix (that's the unknown format) + refute_match(/^TOOL \[/, tool_use.format) + end + end + end + end + end + end + end +end