diff --git a/lua/codecompanion/interactions/chat/acp/formatters.lua b/lua/codecompanion/interactions/chat/acp/formatters.lua index 9ad9c59bf..92ed2557c 100644 --- a/lua/codecompanion/interactions/chat/acp/formatters.lua +++ b/lua/codecompanion/interactions/chat/acp/formatters.lua @@ -4,12 +4,13 @@ Author: Oli Morris ------------------------------------------------------------------------------- Description: - This module provides universal formatting for ACP tool calls across different agents. - It handles inconsistencies in JSON RPC output and ensures: - - Single-line output (no triple backticks or newlines) - - Consistent status formatting - - Smart content summarization - - Proper path handling + Formats ACP tool calls into a single-line label for the chat buffer. + + Output rules: + - Always single line (no triple backticks, no newlines) + - Label format: "Kind: " (e.g. "Read: config.json") + - When `verbose_output` is set on the adapter, completed calls may + append " — " or replace the label entirely (edits) ------------------------------------------------------------------------------- Attribution: If you use or distribute this code, please credit: @@ -19,36 +20,31 @@ local M = {} ----Get the relative path from a path +local MAX_TITLE = 60 +local MAX_TEXT = 100 + +---Normalize a path relative to cwd ---@param p string ---@return string local function relpath(p) return vim.fs.normalize(vim.fn.fnamemodify(p or "", ":.")) end ----Sanitize text content for single-line display +---Collapse text to a single line and truncate ---@param text string ----@param max_length? number Maximum length before truncation (default: 100) +---@param max_length? number ---@return string local function sanitize_text(text, max_length) if not text or text == "" then return "" end - max_length = max_length or 100 - - -- Remove triple backticks and code block markers - text = text:gsub("```[%w]*\n?", "") - text = text:gsub("```", "") - - -- Replace newlines with spaces and collapse multiple spaces - text = text:gsub("\r?\n", " ") - text = text:gsub("%s+", " ") + max_length = max_length or MAX_TEXT - -- Trim whitespace + text = text:gsub("```[%w]*\n?", ""):gsub("```", "") + text = text:gsub("\r?\n", " "):gsub("%s+", " ") text = text:match("^%s*(.-)%s*$") or "" - -- Truncate if too long if #text > max_length then text = text:sub(1, max_length - 3) .. "..." end @@ -56,13 +52,14 @@ local function sanitize_text(text, max_length) return text end ----Extract plain text from a ContentBlock +---Extract plain text from an ACP ContentBlock for display ---@param block table|nil ---@return string|nil function M.extract_text(block) if not block or type(block) ~= "table" then return nil end + if block.type == "text" and type(block.text) == "string" then return sanitize_text(block.text) end @@ -70,12 +67,11 @@ function M.extract_text(block) return ("[resource: %s]"):format(block.uri) end if block.type == "resource" and block.resource then - local r = block.resource - if type(r.text) == "string" then - return sanitize_text(r.text) + if type(block.resource.text) == "string" then + return sanitize_text(block.resource.text) end - if type(r.uri) == "string" then - return ("[resource: %s]"):format(r.uri) + if type(block.resource.uri) == "string" then + return ("[resource: %s]"):format(block.resource.uri) end end if block.type == "image" then @@ -87,295 +83,201 @@ function M.extract_text(block) return nil end ----Get the path to the diff ----@param tool_call table ----@return string|nil -local function diff_path(tool_call) - if type(tool_call) ~= "table" or type(tool_call.content) ~= "table" then - return nil - end - for _, c in ipairs(tool_call.content) do - if c and c.type == "diff" and type(c.path) == "string" then - return c.path - end - end -end - ----Make the kind element of a tool call, pretty +---Capitalise an ACP tool kind ("edit" → "Edit", "switch_mode" → "Switch mode") ---@param kind string|nil ---@return string -local function fmt_kind(kind) +local function format_kind(kind) if not kind or kind == "" then return "Tool" end local s = kind:gsub("_", " ") - s = s:sub(1, 1):upper() .. s:sub(2) - return s + return s:sub(1, 1):upper() .. s:sub(2) end ----Extract meaningful command or operation from title +---Find the first diff content block in a tool call +---@param tool_call table +---@return table|nil +local function find_diff(tool_call) + if type(tool_call.content) ~= "table" then + return nil + end + for _, c in ipairs(tool_call.content) do + if c and c.type == "diff" then + return c + end + end +end + +---Pull a meaningful command/operation out of a tool call title. +---Handles backtick-wrapped commands and strips trailing preview noise +---like " => …" or " — …" that some agents append. ---@param title string ---@return string -local function extract_operation(title) +local function parse_title(title) if not title or title == "" then return "Tool call" end - -- Remove common prefixes and clean up - title = title:gsub("^%s*", "") -- trim leading whitespace + title = title:gsub("^%s*", "") - -- Handle backtick-wrapped commands (e.g., "`ls -la`") local backtick_cmd = title:match("^`([^`]+)`") if backtick_cmd then return backtick_cmd end - -- Handle quoted commands (e.g., "Sheffield United") local quoted = title:match('^"([^"]+)"') if quoted then return quoted end - -- Strip " => …" preview and similar patterns - title = title:gsub("%s*=>.*$", "") - title = title:gsub("%s*—.*$", "") + title = title:gsub("%s*=>.*$", ""):gsub("%s*—.*$", "") - -- If "name: something", keep the full thing if it looks like a URL - -- Otherwise, for short prefixes, keep just the prefix local before_colon = title:match("^%s*([^:]+)%s*:") if before_colon and #before_colon < 40 then - -- Don't strip if the part after colon looks like a URL local after_colon = title:match("^%s*[^:]+%s*:%s*(.+)") if not (after_colon and (after_colon:match("^//") or after_colon:match("^https?://"))) then title = before_colon end end - -- Clean up trailing whitespace title = title:gsub("%s+$", "") + if #title > MAX_TITLE then + title = title:sub(1, MAX_TITLE - 3) .. "..." + end return title ~= "" and title or "Tool call" end ----Short, sanitized title for a tool call +---Build the "Kind: " label for a tool call. +---Preference order for target: diff path → first location path → parsed title. ---@param tool_call table ---@return string -function M.short_title(tool_call) - local kind = fmt_kind(tool_call.kind or "tool") - local p = diff_path(tool_call) - if p then - return ("%s: %s"):format(kind, relpath(p)) - end +local function build_label(tool_call) + local kind = format_kind(tool_call.kind) - local t = tool_call.title or "Tool call" - -- strip “ => …” preview - t = t:gsub("%s*=>.*$", "") - -- If "name: something", keep the name only - local before = t:match("^%s*([^:]+)%s*:") - if before then - t = before + local diff = find_diff(tool_call) + if diff and type(diff.path) == "string" then + return ("%s: %s"):format(kind, relpath(diff.path)) end - t = t:gsub("%s+$", "") - if #t > 80 then - t = t:sub(1, 77) .. "..." + + local location = tool_call.locations and tool_call.locations[1] + if location and type(location.path) == "string" then + return ("%s: %s"):format(kind, relpath(location.path)) end - return ("%s: %s"):format(kind, t) + + return ("%s: %s"):format(kind, parse_title(tool_call.title)) end ----Extract content summary from various tool call content types +---Build a verbose summary for an edit (replaces the label entirely) ---@param tool_call table ---@return string|nil -local function extract_content_summary(tool_call) - local contents = tool_call.content - if type(contents) ~= "table" then +local function diff_summary(tool_call) + local diff = find_diff(tool_call) + if not diff then return nil end - local summaries = {} - - for _, c in ipairs(contents) do - if c.type == "diff" then - local path = c.path and relpath(c.path) or "file" - local old_lines = vim.split(c.oldText or "", "\n", { plain = true }) - local new_lines = vim.split(c.newText or "", "\n", { plain = true }) - local delta = #new_lines - #old_lines - - if delta > 0 then - table.insert(summaries, ("Edited %s (+%d lines)"):format(path, delta)) - elseif delta < 0 then - table.insert(summaries, ("Edited %s (-%d lines)"):format(path, math.abs(delta))) - else - table.insert(summaries, ("Edited %s"):format(path)) - end - elseif c.type == "content" then - local text = M.extract_text(c.content) - if text and text ~= "" then - table.insert(summaries, text) - end - end - end + local path = diff.path and relpath(diff.path) or "file" + local old_lines = vim.split(diff.oldText or "", "\n", { plain = true }) + local new_lines = vim.split(diff.newText or "", "\n", { plain = true }) + local delta = #new_lines - #old_lines - if #summaries > 0 then - return table.concat(summaries, "; ") + if delta > 0 then + return ("Edited %s (+%d lines)"):format(path, delta) + elseif delta < 0 then + return ("Edited %s (-%d lines)"):format(path, math.abs(delta)) end - - return nil + return ("Edited %s"):format(path) end ----Summarize the content of a tool call (legacy function for compatibility) +---Extract a one-line summary from a tool call's content blocks ---@param tool_call table ---@return string|nil -function M.summarize_tool_content(tool_call) - return extract_content_summary(tool_call) -end - ----Generate a smart summary based on tool kind and content ----@param tool_call table ----@param adapter CodeCompanion.ACPAdapter ----@return string -local function generate_smart_summary(tool_call, adapter) - local kind = tool_call.kind or "tool" - local status = tool_call.status or "pending" - local show_verbose_output = adapter.opts and adapter.opts.verbose_output and adapter.opts.verbose_output == true - - -- Get base title (use enhanced version for better handling) - local title = M.enhanced_title(tool_call) - - if status == "pending" or status == "in_progress" or not show_verbose_output then - return title +local function content_summary(tool_call) + if type(tool_call.content) ~= "table" then + return nil end - -- Generate detailed summary based on kind - local content_summary = extract_content_summary(tool_call) - - if kind == "read" then - if status == "completed" and content_summary and content_summary ~= "" then - -- For read operations, show a snippet of what was read - return title .. " — " .. content_summary - else - return title - end - elseif kind == "edit" or kind == "write" then - if status == "completed" and content_summary then - return content_summary - else - return title - end - elseif kind == "execute" then - if status == "completed" and content_summary and content_summary ~= "" then - return title .. " — " .. content_summary - else - return title - end - elseif kind == "search" then - if status == "completed" and content_summary and content_summary ~= "" then - return title .. " — " .. content_summary - else - return title - end - elseif kind == "fetch" then - if status == "completed" and content_summary and content_summary ~= "" then - return title .. " — " .. content_summary - else - return title + local parts = {} + for _, c in ipairs(tool_call.content) do + if c.type == "content" then + local text = M.extract_text(c.content) + if text and text ~= "" then + table.insert(parts, text) + end end end - -- Fallback to generic handling - return title + if #parts > 0 then + return table.concat(parts, "; ") + end end ----Clean tool text for display in a markdown buffer +---Strip backticks and shorten cwd-rooted paths for display in a markdown buffer ---@param text string ---@return string -local function clean_tool_text(text) +local function clean_for_buffer(text) if not text or text == "" then return text end - text = text:gsub("`", "") - - -- Shorten absolute paths local cwd = vim.fn.getcwd() if cwd and cwd ~= "" then text = text:gsub(vim.pesc(cwd) .. "/", "") end - return text end ----Create a one-line message for tool-call events +---Fill in defaults for missing tool call fields ---@param tool_call table ----@param adapter CodeCompanion.ACPAdapter ----@return string -function M.tool_message(tool_call, adapter) - local normalized = M.normalize_tool_call(tool_call) - return clean_tool_text(generate_smart_summary(normalized, adapter)) -end +---@return table +local function normalize(tool_call) + if type(tool_call) ~= "table" then + return { + content = {}, + kind = "other", + locations = {}, + status = "failed", + title = "Invalid tool call", + toolCallId = "unknown", + } + end ----Create a one-line message for file-write ----@param info table ----@return string -function M.fs_write_message(info) - local path = relpath(info.path or "") - local bytes = tonumber(info.bytes or 0) or 0 - return ("Wrote %d bytes to %s"):format(bytes, path ~= "" and path or "file") + return { + content = tool_call.content or {}, + kind = tool_call.kind or "other", + locations = tool_call.locations or {}, + status = tool_call.status or "pending", + title = tool_call.title or "Tool call", + toolCallId = tool_call.toolCallId or "unknown", + } end ----Enhanced title generation that handles more edge cases +---Build the single-line display string for an ACP tool call ---@param tool_call table +---@param adapter CodeCompanion.ACPAdapter ---@return string -function M.enhanced_title(tool_call) - local kind = fmt_kind(tool_call.kind or "tool") +function M.tool_message(tool_call, adapter) + local call = normalize(tool_call) + local label = build_label(call) - -- Check for diff path first - local p = diff_path(tool_call) - if p then - return ("%s: %s"):format(kind, relpath(p)) + local verbose = adapter.opts and adapter.opts.verbose_output == true + if not verbose or call.status ~= "completed" then + return clean_for_buffer(label) end - -- Check locations for file paths - if tool_call.locations and #tool_call.locations > 0 then - local location = tool_call.locations[1] - if location and location.path then - return ("%s: %s"):format(kind, relpath(location.path)) - end + -- Edits get a custom summary that replaces the label entirely + local diff = diff_summary(call) + if diff then + return clean_for_buffer(diff) end - -- Extract operation from title - local operation = extract_operation(tool_call.title) - - -- Truncate if too long - if #operation > 60 then - operation = operation:sub(1, 57) .. "..." - end - - return ("%s: %s"):format(kind, operation) -end - ----Validate and normalize tool call data ----@param tool_call table ----@return table Normalized tool call -function M.normalize_tool_call(tool_call) - if type(tool_call) ~= "table" then - return { - toolCallId = "unknown", - title = "Invalid tool call", - kind = "other", - status = "failed", - content = {}, - locations = {}, - } + local summary = content_summary(call) + if summary and summary ~= "" then + return clean_for_buffer(label .. " — " .. summary) end - return { - toolCallId = tool_call.toolCallId or "unknown", - title = tool_call.title or "Tool call", - kind = tool_call.kind or "other", - status = tool_call.status or "pending", - content = tool_call.content or {}, - locations = tool_call.locations or {}, - } + return clean_for_buffer(label) end return M diff --git a/lua/codecompanion/interactions/chat/acp/handler.lua b/lua/codecompanion/interactions/chat/acp/handler.lua index a7d12165c..19dd2be0e 100644 --- a/lua/codecompanion/interactions/chat/acp/handler.lua +++ b/lua/codecompanion/interactions/chat/acp/handler.lua @@ -278,7 +278,6 @@ function ACPHandler:process_tool_call(tool_call) log:debug("[ACP::Handler] Failed to update tool call line for toolCallId %s", id) end - table.insert(self.output, content) local line_number, icon_id = self.chat:add_buf_message({ role = config.constants.LLM_ROLE, content = content, diff --git a/tests/interactions/chat/acp/test_formatters.lua b/tests/interactions/chat/acp/test_formatters.lua index 03ab6bcb1..18748f0d4 100644 --- a/tests/interactions/chat/acp/test_formatters.lua +++ b/tests/interactions/chat/acp/test_formatters.lua @@ -2,205 +2,134 @@ local h = require("tests.helpers") local new_set = MiniTest.new_set local child = MiniTest.new_child_neovim() + local T = new_set({ hooks = { pre_case = function() h.child_start(child) - child.lua([[ - formatters = require("codecompanion.interactions.chat.acp.formatters") - - -- Mock adapter configurations - mock_adapter_full = { - opts = { - verbose_output = false, - }, - } - - mock_adapter_trimmed = { - opts = { - verbose_output = true, - }, - } - - mock_adapter_no_opts = {} - ]]) + child.lua([[formatters = require("codecompanion.interactions.chat.acp.formatters")]]) end, post_once = child.stop, }, }) -T["ACP Formatters"] = new_set() - --- Helper function to test tool messages -local function test_tool_message(tool_call_lua, adapter_lua, expected) - child.lua(tool_call_lua) - child.lua(adapter_lua) - local result = child.lua_get("formatters.tool_message(_G.test_tool_call, _G.test_adapter)") - h.eq(expected, result) +---Send a tool call and adapter through `formatters.tool_message` in the child +---@param tool_call table +---@param opts? { verbose?: boolean } +---@return any +local function format(tool_call, opts) + opts = opts or {} + local adapter = { opts = { verbose_output = opts.verbose == true } } + local expr = ("formatters.tool_message(%s, %s)"):format(vim.inspect(tool_call), vim.inspect(adapter)) + return child.lua_get(expr) end -T["ACP Formatters"]["extract_text"] = function() - -- Test text content block with sanitization - local result = child.lua_get([[formatters.extract_text({ - type = "text", - text = "Hello\nWorld\n```lua\ncode\n```\nMore text", - })]]) - h.eq("Hello World code More text", result) - - -- Test resource link - result = child.lua_get([[formatters.extract_text({ - type = "resource_link", - uri = "file:///path/to/file.txt", - })]]) - h.eq("[resource: file:///path/to/file.txt]", result) +---Evaluate `formatters.extract_text(block)` in the child +---@param block table|nil +---@return any +local function extract_text(block) + return child.lua_get(("formatters.extract_text(%s)"):format(vim.inspect(block))) +end - -- Test image block - result = child.lua_get([[formatters.extract_text({ type = "image" })]]) - h.eq("[image]", result) +T["ACP Formatters"] = new_set() - -- Test invalid input - result = child.lua_get([[formatters.extract_text(nil)]]) - h.eq(vim.NIL, result) +T["ACP Formatters"]["extract_text - sanitises text blocks"] = function() + h.eq( + "Hello World code More text", + extract_text({ type = "text", text = "Hello\nWorld\n```lua\ncode\n```\nMore text" }) + ) end -T["ACP Formatters"]["short_title"] = function() - -- Test with diff path - local result = child.lua_get([[formatters.short_title({ - kind = "edit", - title = "Write file", - content = { { type = "diff", path = "/Users/test/project/file.lua" } }, - })]]) - h.eq("Edit: /Users/test/project/file.lua", result) +T["ACP Formatters"]["extract_text - resource_link returns uri"] = function() + h.eq( + "[resource: file:///path/to/file.txt]", + extract_text({ type = "resource_link", uri = "file:///path/to/file.txt" }) + ) +end - -- Test with backtick command - result = child.lua_get([[formatters.short_title({ - kind = "execute", - title = "`ls -la /tmp`", - })]]) - h.eq("Execute: `ls -la /tmp`", result) +T["ACP Formatters"]["extract_text - image"] = function() + h.eq("[image]", extract_text({ type = "image" })) +end - -- Test with quoted title - result = child.lua_get([[formatters.short_title({ - kind = "fetch", - title = '"Sheffield United"', - })]]) - h.eq('Fetch: "Sheffield United"', result) +T["ACP Formatters"]["extract_text - audio"] = function() + h.eq("[audio]", extract_text({ type = "audio" })) end -T["ACP Formatters"]["tool_message - Edit Tools"] = function() - -- Test completed edit with diff - test_tool_message( - [[ - _G.test_tool_call = { - toolCallId = "edit123", - title = "Write file.lua", - kind = "edit", - status = "completed", - content = { - { - type = "diff", - path = "/Users/test/file.lua", - oldText = "old content", - newText = "old content\nnew line", - }, - }, - locations = { { path = "/Users/test/file.lua" } }, - } - ]], - [[_G.test_adapter = { opts = { verbose_output = true } }]], - "Edited /Users/test/file.lua (+1 lines)" - ) +T["ACP Formatters"]["extract_text - nil input"] = function() + h.eq(vim.NIL, extract_text(nil)) +end - -- Test trimmed output - test_tool_message( - [[ - _G.test_tool_call = { - toolCallId = "edit123", - title = "Write file.lua", - kind = "edit", - status = "completed", - content = { - { - type = "diff", - path = "/Users/test/file.lua", - oldText = "old content", - newText = "old content\nnew line", - }, - }, - locations = { { path = "/Users/test/file.lua" } }, - } - ]], - [[_G.test_adapter = { opts = { verbose_output = false } }]], - "Edit: /Users/test/file.lua" +T["ACP Formatters"]["edit - non-verbose returns label"] = function() + h.eq( + "Edit: /Users/test/file.lua", + format({ + toolCallId = "edit-1", + kind = "edit", + status = "completed", + title = "Write file.lua", + locations = { { path = "/Users/test/file.lua" } }, + content = { + { type = "diff", path = "/Users/test/file.lua", oldText = "old", newText = "old\nnew" }, + }, + }) ) +end - -- Test pending edit - test_tool_message( - [[ - _G.test_tool_call = { - toolCallId = "edit123", - title = "Write file.lua", - kind = "edit", - status = "pending", - locations = { { path = "/Users/test/file.lua" } }, - } - ]], - [[_G.test_adapter = { opts = { verbose_output = false } }]], - "Edit: /Users/test/file.lua" +T["ACP Formatters"]["edit - verbose returns diff summary with +N lines"] = function() + h.eq( + "Edited /Users/test/file.lua (+1 lines)", + format({ + toolCallId = "edit-1", + kind = "edit", + status = "completed", + title = "Write file.lua", + locations = { { path = "/Users/test/file.lua" } }, + content = { + { type = "diff", path = "/Users/test/file.lua", oldText = "old", newText = "old\nnew" }, + }, + }, { verbose = true }) ) end -T["ACP Formatters"]["tool_message - Read Tools"] = function() - -- Test completed read with content - test_tool_message( - [[ - _G.test_tool_call = { - toolCallId = "read123", - title = "Read config.json", - kind = "read", - status = "completed", - content = { - { - type = "content", - content = { - type = "text", - text = '{"name": "test"}\n```json\nformatted\n```', - }, - }, - }, - locations = { { path = "/Users/test/config.json" } }, - } - ]], - [[_G.test_adapter = { opts = { verbose_output = true } }]], - 'Read: /Users/test/config.json — {"name": "test"} formatted' +T["ACP Formatters"]["edit - verbose returns diff summary with -N lines"] = function() + h.eq( + "Edited /Users/test/file.lua (-2 lines)", + format({ + toolCallId = "edit-1", + kind = "edit", + status = "completed", + title = "Write file.lua", + locations = { { path = "/Users/test/file.lua" } }, + content = { + { type = "diff", path = "/Users/test/file.lua", oldText = "a\nb\nc", newText = "a" }, + }, + }, { verbose = true }) ) +end - -- Test completed read without content - test_tool_message( - [[ - _G.test_tool_call = { - toolCallId = "read123", - title = "Read config.json", - kind = "read", - status = "completed", - content = {}, - locations = { { path = "/Users/test/config.json" } }, - } - ]], - [[_G.test_adapter = { opts = { verbose_output = false } }]], - "Read: /Users/test/config.json" +T["ACP Formatters"]["edit - pending status keeps label even when verbose"] = function() + h.eq( + "Edit: /Users/test/file.lua", + format({ + toolCallId = "edit-1", + kind = "edit", + status = "pending", + title = "Write file.lua", + locations = { { path = "/Users/test/file.lua" } }, + }, { verbose = true }) ) end -T["ACP Formatters"]["tool_message - Real-world Examples"] = function() - -- Test Claude Code edit example with dynamic path that works in any environment - child.lua([[ - local cwd = vim.fn.getcwd() - _G.test_tool_call = { - toolCallId = "toolu_01VRjmb5Vsv9WwwKu6cgH8a4", - title = "Write quotes.lua", +T["ACP Formatters"]["edit - cwd-relative diff path is shortened"] = function() + local cwd = child.lua_get("vim.fn.getcwd()") + h.eq( + "Edited quotes.lua (+2 lines)", + format({ + toolCallId = "edit-1", kind = "edit", status = "completed", + title = "Write quotes.lua", + locations = { { path = cwd .. "/quotes.lua" } }, content = { { type = "diff", @@ -209,76 +138,116 @@ T["ACP Formatters"]["tool_message - Real-world Examples"] = function() newText = "-- Simple test comment for ACP capture\nreturn {}\n", }, }, - locations = { - { path = cwd .. "/quotes.lua" }, + }, { verbose = true }) + ) +end + +T["ACP Formatters"]["read - non-verbose returns label only"] = function() + h.eq( + "Read: /Users/test/config.json", + format({ + toolCallId = "read-1", + kind = "read", + status = "completed", + title = "Read config.json", + locations = { { path = "/Users/test/config.json" } }, + content = {}, + }) + ) +end + +T["ACP Formatters"]["read - verbose appends content summary"] = function() + h.eq( + 'Read: /Users/test/config.json — {"name": "test"} formatted', + format({ + toolCallId = "read-1", + kind = "read", + status = "completed", + title = "Read config.json", + locations = { { path = "/Users/test/config.json" } }, + content = { + { + type = "content", + content = { type = "text", text = '{"name": "test"}\n```json\nformatted\n```' }, + }, }, - } - ]]) - child.lua([[_G.test_adapter = { opts = { verbose_output = true } }]]) - local result = child.lua_get("formatters.tool_message(_G.test_tool_call, _G.test_adapter)") - h.eq("Edited quotes.lua (+2 lines)", result) + }, { verbose = true }) + ) +end - -- Test Claude Code execute example - child.lua([[ - _G.claude_execute = { - toolCallId = "toolu_017FaiLJGYNSVToDmZhrHqhA", - title = "`ls -la lua/codecompanion/interactions/chat/acp/formatters/`", +T["ACP Formatters"]["execute - parses backtick-wrapped command from title"] = function() + h.eq( + "Execute: ls -la lua/codecompanion/interactions/chat/acp/formatters/", + format({ + toolCallId = "exec-1", kind = "execute", status = "completed", + title = "`ls -la lua/codecompanion/interactions/chat/acp/formatters/`", content = { { type = "content", content = { type = "text", - text = "total 56\ndrwxr-xr-x@ 6 Oli staff 192 4 Nov 18:04 .\ndrwxr-xr-x@ 7 Oli staff 224 4 Nov 18:05 ..\n-rw-r--r--@ 1 Oli staff 4153 4 Nov 18:04 claude_code.lua", + text = "total 56\ndrwxr-xr-x@ 6 Oli staff 192 4 Nov 18:04 .", }, }, }, - } - _G.result = formatters.tool_message(_G.claude_execute, mock_adapter_full) - ]]) - local result = child.lua_get("_G.result") - h.eq("Execute: ls -la lua/codecompanion/interactions/chat/acp/formatters/", result) - h.expect_truthy(not result:match("\n")) + }) + ) +end - -- Test Claude Code search example - test_tool_message( - [[ - _G.test_tool_call = { - toolCallId = "toolu_019YPt8kXTaoKTadxdQjfims", - title = "Find `**/*add_buf_message*`", - kind = "search", - status = "completed", - content = { - { - type = "content", - content = { - type = "text", - text = "No files found", - }, - }, - }, - } - ]], - [[_G.test_adapter = { opts = { verbose_output = true } }]], - "Search: Find **/*add_buf_message* — No files found" +T["ACP Formatters"]["search - verbose appends content summary"] = function() + h.eq( + "Search: Find **/*add_buf_message* — No files found", + format({ + toolCallId = "search-1", + kind = "search", + status = "completed", + title = "Find `**/*add_buf_message*`", + content = { + { type = "content", content = { type = "text", text = "No files found" } }, + }, + }, { verbose = true }) ) end -T["ACP Formatters"]["fs_write_message"] = function() - -- Test normal file write - local result = child.lua_get([[formatters.fs_write_message({ - path = "/Users/test/project/file.lua", - bytes = 1024, - })]]) - h.eq("Wrote 1024 bytes to /Users/test/project/file.lua", result) +T["ACP Formatters"]["missing kind defaults to 'Other'"] = function() + h.eq( + "Other: doing something", + format({ + toolCallId = "x-1", + status = "pending", + title = "doing something", + }) + ) +end + +T["ACP Formatters"]["snake_case kind is title-cased with space"] = function() + h.eq( + "Switch mode: plan", + format({ + toolCallId = "sm-1", + kind = "switch_mode", + status = "completed", + title = "plan", + }) + ) +end + +T["ACP Formatters"]["title with trailing ' => ...' preview is stripped"] = function() + h.eq( + "Fetch: GET /api/users", + format({ + toolCallId = "f-1", + kind = "fetch", + status = "pending", + title = "GET /api/users => 200 OK", + }) + ) +end - -- Test empty path - result = child.lua_get([[formatters.fs_write_message({ - path = "", - bytes = 1024, - })]]) - h.eq("Wrote 1024 bytes to file", result) +T["ACP Formatters"]["nil tool_call is normalised to 'Other: Invalid tool call'"] = function() + h.eq("Other: Invalid tool call", child.lua_get("formatters.tool_message(nil, { opts = {} })")) end return T