diff --git a/lib/roast/cogs/agent/providers/claude/claude_invocation.rb b/lib/roast/cogs/agent/providers/claude/claude_invocation.rb index 073ff507..7b59e897 100644 --- a/lib/roast/cogs/agent/providers/claude/claude_invocation.rb +++ b/lib/roast/cogs/agent/providers/claude/claude_invocation.rb @@ -20,6 +20,7 @@ class ClaudeFailedError < ClaudeInvocationError; end class Context def initialize @tool_uses = {} #: Hash[String, Messages::ToolUseMessage] + @task_subjects = {} #: Hash[String, String] end #: (String?) -> Messages::ToolUseMessage? @@ -32,6 +33,23 @@ def add_tool_use(tool_use_message) id = tool_use_message.id @tool_uses[id] = tool_use_message if id end + + #: (Messages::ToolResultMessage) -> void + def add_tool_result(tool_result_message) + return if tool_result_message.is_error + + tool_use_message = tool_use(tool_result_message.tool_use_id) + return unless tool_use_message && tool_use_message.name == :taskcreate + + task_id = tool_result_message.content.to_s[/\ATask #(\d+) created successfully\b/, 1] + subject = tool_use_message.input[:subject] + @task_subjects[task_id] = subject if task_id && subject.present? + end + + #: ((String | Integer)?) -> String? + def task_subject(task_id) + @task_subjects[task_id.to_s] if task_id + end end class Result @@ -149,6 +167,8 @@ def handle_message(message) @result.stats = message.stats when Messages::ToolUseMessage @context.add_tool_use(message) + when Messages::ToolResultMessage + @context.add_tool_result(message) when Messages::UserMessage message.messages.each { |msg| handle_message(msg) } end diff --git a/lib/roast/cogs/agent/providers/claude/messages/tool_use_message.rb b/lib/roast/cogs/agent/providers/claude/messages/tool_use_message.rb index 0f894bf4..578685a2 100644 --- a/lib/roast/cogs/agent/providers/claude/messages/tool_use_message.rb +++ b/lib/roast/cogs/agent/providers/claude/messages/tool_use_message.rb @@ -33,7 +33,8 @@ def initialize(type:, hash:) #: (ClaudeInvocation::Context) -> String? def format(context) - tool_use = ToolUse.new(name:, input:) + task_subject = context.task_subject(input[:taskId]) if name == :taskupdate + tool_use = ToolUse.new(name:, input:, task_subject:) tool_use.format end end diff --git a/lib/roast/cogs/agent/providers/claude/tool_use.rb b/lib/roast/cogs/agent/providers/claude/tool_use.rb index d10648f5..4d95ad61 100644 --- a/lib/roast/cogs/agent/providers/claude/tool_use.rb +++ b/lib/roast/cogs/agent/providers/claude/tool_use.rb @@ -13,10 +13,11 @@ class ToolUse #: Hash[Symbol, untyped] attr_reader :input - #: (name: Symbol, input: Hash[Symbol, untyped]) -> void - def initialize(name:, input:) + #: (name: Symbol, input: Hash[Symbol, untyped], ?task_subject: String?) -> void + def initialize(name:, input:, task_subject: nil) @name = name @input = input + @task_subject = task_subject end #: () -> String @@ -320,17 +321,19 @@ def format_agent # :taskId (Integer) – id of the task to update [required] # :status (String) – the task's new status [required] # - # Output: "TASKUPDATE # → " — the id is prefixed with - # "#" and joined to the status with " → ". Both fields are always - # shown and neither is truncated. + # Output: 'TASKUPDATE "" → ' when the task's subject + # was observed in this invocation. Falls back to + # "TASKUPDATE # → " when it was not. Subjects are + # truncated to TRUNCATE_LIMIT chars and quoted. # # Examples: - # TASKUPDATE #1 → completed + # TASKUPDATE "Run formatter stress test" → completed # TASKUPDATE #2 → in_progress # #: () -> String def format_taskupdate - "TASKUPDATE ##{input[:taskId]} → #{input[:status]}" + task = @task_subject.present? ? truncate(@task_subject).inspect : "##{input[:taskId]}" + "TASKUPDATE #{task} → #{input[:status]}" end # Formats a TaskCreate tool-use line. diff --git a/test/roast/cogs/agent/providers/claude/claude_invocation_test.rb b/test/roast/cogs/agent/providers/claude/claude_invocation_test.rb index 3908e8cb..38828ef5 100644 --- a/test/roast/cogs/agent/providers/claude/claude_invocation_test.rb +++ b/test/roast/cogs/agent/providers/claude/claude_invocation_test.rb @@ -60,6 +60,44 @@ def failure_status assert_nil context.tool_use(nil) end + test "Context#add_tool_result ignores failed task creation" do + context = Claude::ClaudeInvocation::Context.new + task_create = Claude::Messages::ToolUseMessage.new( + type: :tool_use, + hash: { id: "create_123", name: "TaskCreate", input: { subject: "Read full code" } }, + ) + task_created = Claude::Messages::ToolResultMessage.new( + type: :tool_result, + hash: { + tool_use_id: "create_123", + content: "Task #1 created successfully", + is_error: true, + }, + ) + context.add_tool_use(task_create) + + context.add_tool_result(task_created) + + assert_nil context.task_subject("1") + end + + test "Context#add_tool_result ignores an unexpected task creation response" do + context = Claude::ClaudeInvocation::Context.new + task_create = Claude::Messages::ToolUseMessage.new( + type: :tool_use, + hash: { id: "create_123", name: "TaskCreate", input: { subject: "Read full code" } }, + ) + task_created = Claude::Messages::ToolResultMessage.new( + type: :tool_result, + hash: { tool_use_id: "create_123", content: "Task creation queued" }, + ) + context.add_tool_use(task_create) + + context.add_tool_result(task_created) + + assert_nil context.task_subject("1") + end + test "Result initializes with empty response and success false" do result = Claude::ClaudeInvocation::Result.new @@ -322,6 +360,61 @@ def failure_status assert_equal tool_use_message, context.tool_use("tool_123") end + test "handle_message uses the created task subject when formatting a task update" do + @config.show_progress! + invocation = Claude::ClaudeInvocation.new(@config, "Test prompt", nil) + task_create = Claude::Messages::ToolUseMessage.new( + type: :tool_use, + hash: { + id: "create_123", + name: "TaskCreate", + input: { subject: "Read full code" }, + }, + ) + task_created = Claude::Messages::ToolResultMessage.new( + type: :tool_result, + hash: { + tool_use_id: "create_123", + content: "Task #1 created successfully", + }, + ) + task_update = Claude::Messages::ToolUseMessage.new( + type: :tool_use, + hash: { + id: "update_123", + name: "TaskUpdate", + input: { taskId: 1, status: "completed" }, + }, + ) + + stdout, = capture_io do + invocation.send(:handle_message, task_create) + invocation.send(:handle_message, task_created) + invocation.send(:handle_message, task_update) + end + + assert_includes stdout.lines, "TASKUPDATE \"Read full code\" → completed\n" + end + + test "handle_message falls back to the task id when the create result is unavailable" do + @config.show_progress! + invocation = Claude::ClaudeInvocation.new(@config, "Test prompt", nil) + task_update = Claude::Messages::ToolUseMessage.new( + type: :tool_use, + hash: { + id: "update_123", + name: "TaskUpdate", + input: { taskId: "1", status: "completed" }, + }, + ) + + stdout, = capture_io do + invocation.send(:handle_message, task_update) + end + + assert_equal "TASKUPDATE #1 → completed\n", stdout + end + test "handle_message processes AssistantMessage recursively" do text_hash = { type: :text, text: "Hello" } assistant_message = Claude::Messages::AssistantMessage.new( diff --git a/test/roast/cogs/agent/providers/claude/tool_use_test.rb b/test/roast/cogs/agent/providers/claude/tool_use_test.rb index 8fd1cc7e..2a10c695 100644 --- a/test/roast/cogs/agent/providers/claude/tool_use_test.rb +++ b/test/roast/cogs/agent/providers/claude/tool_use_test.rb @@ -389,6 +389,18 @@ class Claude::ToolUseTest < ActiveSupport::TestCase assert_equal "TASKUPDATE #1 → completed", output end + test "format_taskupdate renders and escapes a known task subject" do + tool_use = Claude::ToolUse.new( + name: :taskupdate, + input: { taskId: 1, status: "completed" }, + task_subject: 'Read "full" code', + ) + + output = tool_use.format + + assert_equal 'TASKUPDATE "Read \\"full\\" code" → completed', output + end + # format_taskcreate test "format_taskcreate renders the subject" do